🔎 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:
// index.ts
console.log('Hello, world!');
Steps:
- Start
tsc --lsp --stdio and open index.ts.
- Request diagnostics, hover or completion so the server creates checkers.
- Edit
index.ts, for example insert a newline, so the program is updated by cloning (ProgramUpdateKindCloned).
- Wait past the checker idle timeout (30s) and force a GC.
- 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).
🔎 Search Terms
memory leak, language server, lsp, program clone, ReuseProgram, UpdateProgram, checker pool, retained program, projectReferenceFileMapper, RSS
🕗 Version & Regression Information
tsc --lsp).mainat 87f2e8c.projectReferenceFileMapperstarted keeping a copy of the fullProgramOptions, and the language server started passingCreateCheckerPool/CreateModuleResolverclosures that capture the project.⏯ Playground Link
N/A (language server / memory behavior)
💻 Code
Any project with a
tsconfig.jsonshows this. Minimal case:// tsconfig.json {}Steps:
tsc --lsp --stdioand openindex.ts.index.ts, for example insert a newline, so the program is updated by cloning (ProgramUpdateKindCloned).🙁 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:
fileLoader.addProjectReferenceTasksstores the wholeProgramOptionson the mapper (opts: p.opts). In the language server those options include theCreateCheckerPoolandCreateModuleResolverclosures, and both capture the project that created the program.ReuseProgramsharesprocessedFiles, 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_spaceafter GC; each figure is the average of 3 runs.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.Hostandopts.UseSourceOfProjectReference. It never calls the factory closures, so clearingCreateCheckerPoolandCreateModuleResolverfrom 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).