Skip to content

Language server retains the pre-edit program and its checkers for the rest of the session after the first edit #64465

Description

🔎 Search Terms

memory leak, language server, lsp, program clone, ReuseProgram, UpdateProgram, checker pool, retained program, projectReferenceFileMapper, RSS

🕗 Version & Regression Information

  • This is a memory retention bug in the native (Go) language server (tsc --lsp).
  • Reproduces on main at 87f2e8c.
  • I haven't bisected it. It has been present since projectReferenceFileMapper started keeping a copy of the full ProgramOptions, and the language server started passing CreateCheckerPool/CreateModuleResolver closures that capture the project.

⏯ Playground Link

N/A (language server / memory behavior)

💻 Code

Any project with a tsconfig.json shows this. Minimal case:

// tsconfig.json
{}
// index.ts
console.log('Hello, world!');

Steps:

  1. Start tsc --lsp --stdio and open index.ts.
  2. Request diagnostics, hover or completion so the server creates checkers.
  3. Edit index.ts, for example insert a newline, so the program is updated by cloning (ProgramUpdateKindCloned).
  4. Wait past the checker idle timeout (30s) and force a GC.
  5. The program from before the edit, its checker pool and its checkers are all still reachable.

🙁 Actual behavior

After the first edit, the program from before the edit is never garbage collected. Neither are its checker pool or its checkers.

The reference chain is:

new Program
  → processedFiles.projectReferenceFileMapper   (shared with every clone by ReuseProgram)
  → opts.CreateCheckerPool                      (closure capturing the *project.Project)
  → old *Project → old Program → old checkerPool → old Checkers

fileLoader.addProjectReferenceTasks stores the whole ProgramOptions on the mapper (opts: p.opts). In the language server those options include the CreateCheckerPool and CreateModuleResolver closures, and both capture the project that created the program. ReuseProgram shares processedFiles, including the mapper, with every cloned program. So as long as a descendant clone is alive, the original program and everything it created stays reachable, typically until a full program rebuild.

The retained checkers are also never idle-cleaned. Their pool was already Discard()ed when the program was replaced, and that stops its idle-cleanup timer.

Measured on a ~500-file Next.js app. A scripted LSP client opened 5 files, requested diagnostics, hover and completion, and made one edit. Heap is inuse_space after GC; each figure is the average of 3 runs.

observed with the retention removed
Go heap, active ~541 MB ~464 MB
Go heap, idle (after checker idle timeout) ~471 MB ~388 MB
RSS, idle ~718 MB ~600 MB

I confirmed this with liveness tracking via runtime.AddCleanup. After the idle timeout, 2 programs, 2 checker pools and 2 checkers from the pre-edit program were still alive. The expected state is 1 program and 1 pool.

🙂 Expected behavior

Once no snapshot references the pre-edit program, it should become unreachable and be garbage collected, together with its checker pool and checkers. Only the current program and its pool should stay alive. This is what Snapshot.dispose → checkerPool.Discard() is designed for: "allowing the pool and any idle checkers it still references to be reclaimed when the pool is garbage-collected".

Additional information about the issue

The mapper only reads opts.Config, opts.Host and opts.UseSourceOfProjectReference. It never calls the factory closures, so clearing CreateCheckerPool and CreateModuleResolver from the copy of the options it keeps breaks the chain without changing behavior.

I have a fix with a regression test. The test edits a file so the program is cloned, then asserts the pre-edit program is garbage collected. It fails before the fix and passes after, and I'm happy to send it as a PR.

Possibly related: microsoft/typescript-go#3032 (LSP memory growth on large projects, root cause never identified).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions