From 0375d12fb7817cb446a7d7ac05f47271efef90a4 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Sat, 26 Sep 2026 13:17:04 +0300 Subject: [PATCH] fix(perf): clear the two factory callbacks in the lsp --- tsc/internal/compiler/fileloader.go | 8 ++++- tsc/internal/project/snapshot_test.go | 48 +++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/tsc/internal/compiler/fileloader.go b/tsc/internal/compiler/fileloader.go index f8bba578a534a..ed0e6c21d8abc 100644 --- a/tsc/internal/compiler/fileloader.go +++ b/tsc/internal/compiler/fileloader.go @@ -338,8 +338,14 @@ func (p *fileLoader) resolveAutomaticTypeDirectives(containingFileName string) ( } func (p *fileLoader) addProjectReferenceTasks(singleThreaded bool) { + // The mapper is shared with every program cloned from this one (via processedFiles), + // so don't retain the factory closures: they can capture the creator of this program + // (e.g. the LSP project), which would keep this program and its checkers alive. + opts := p.opts + opts.CreateCheckerPool = nil + opts.CreateModuleResolver = nil p.projectReferenceFileMapper = &projectReferenceFileMapper{ - opts: p.opts, + opts: opts, host: p.opts.Host, } projectReferences := p.opts.Config.ResolvedProjectReferencePaths() diff --git a/tsc/internal/project/snapshot_test.go b/tsc/internal/project/snapshot_test.go index 36b57beb4d0c7..402213dff6aeb 100644 --- a/tsc/internal/project/snapshot_test.go +++ b/tsc/internal/project/snapshot_test.go @@ -3,7 +3,10 @@ package project import ( "context" "fmt" + "runtime" + "sync/atomic" "testing" + "time" "github.com/microsoft/TypeScript/tsc/internal/bundled" "github.com/microsoft/TypeScript/tsc/internal/collections" @@ -209,6 +212,51 @@ func TestSnapshot(t *testing.T) { assert.Equal(t, snapshotAfter.ProjectCollection.InferredProject().host.sourceFS.source, snapshotBefore.fs) }) + t.Run("cloned program does not retain the program it was cloned from", func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/home/projects/TS/p1/tsconfig.json": "{}", + "/home/projects/TS/p1/index.ts": "console.log('Hello, world!');", + } + session := setup(files) + uri := lsproto.DocumentUri("file:///home/projects/TS/p1/index.ts") + session.DidOpenFile(context.Background(), uri, 1, files["/home/projects/TS/p1/index.ts"].(string), lsproto.LanguageKindTypeScript) + _, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + + var collected atomic.Bool + func() { + oldProgram := session.Snapshot().ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p1/tsconfig.json")).GetProgram() + runtime.AddCleanup(oldProgram, func(b *atomic.Bool) { b.Store(true) }, &collected) + }() + + session.DidChangeFile(context.Background(), uri, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{ + { + Partial: &lsproto.TextDocumentContentChangePartial{ + Text: "\n", + Range: lsproto.Range{ + Start: lsproto.Position{Line: 0, Character: 24}, + End: lsproto.Position{Line: 0, Character: 24}, + }, + }, + }, + }) + _, err = session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + assert.Equal(t, session.Snapshot().ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p1/tsconfig.json")).ProgramUpdateKind, ProgramUpdateKindCloned) + + // Cleanups run asynchronously after GC; background snapshot work may also hold a ref briefly. + for range 50 { + runtime.GC() + if collected.Load() { + break + } + time.Sleep(20 * time.Millisecond) + } + assert.Assert(t, collected.Load(), "program from before the edit should be garbage collected") + runtime.KeepAlive(session) + }) + t.Run("cached disk files are cleaned up", func(t *testing.T) { t.Parallel() files := map[string]any{