From 43d807c06251c40e1bd523c70980ef9e0418fe42 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Wed, 27 Aug 2025 12:11:52 -0700 Subject: [PATCH 1/3] Add some additional logs --- internal/project/filechange.go | 2 - internal/project/overlayfs.go | 5 -- internal/project/session.go | 120 +++++++-------------------------- internal/project/snapshot.go | 24 +++++++ 4 files changed, 49 insertions(+), 102 deletions(-) diff --git a/internal/project/filechange.go b/internal/project/filechange.go index afb5b24648..2f702add0e 100644 --- a/internal/project/filechange.go +++ b/internal/project/filechange.go @@ -38,8 +38,6 @@ type FileChangeSummary struct { Created collections.Set[lsproto.DocumentUri] // Only set when file watching is enabled Deleted collections.Set[lsproto.DocumentUri] - - IncludesWatchChangesOnly bool } func (f FileChangeSummary) IsEmpty() bool { diff --git a/internal/project/overlayfs.go b/internal/project/overlayfs.go index 1e3c2f70fb..a59e1a74d6 100644 --- a/internal/project/overlayfs.go +++ b/internal/project/overlayfs.go @@ -197,7 +197,6 @@ func (fs *overlayFS) processChanges(changes []FileChange) (FileChangeSummary, ma fs.mu.Lock() defer fs.mu.Unlock() - var includesNonWatchChange bool var result FileChangeSummary newOverlays := maps.Clone(fs.overlays) @@ -283,7 +282,6 @@ func (fs *overlayFS) processChanges(changes []FileChange) (FileChangeSummary, ma if result.Opened != "" { panic("can only process one file open event at a time") } - includesNonWatchChange = true result.Opened = uri newOverlays[path] = newOverlay( uri.FileName(), @@ -295,7 +293,6 @@ func (fs *overlayFS) processChanges(changes []FileChange) (FileChangeSummary, ma } if events.closeChange != nil { - includesNonWatchChange = true if result.Closed == nil { result.Closed = make(map[lsproto.DocumentUri]xxh3.Uint128) } @@ -316,7 +313,6 @@ func (fs *overlayFS) processChanges(changes []FileChange) (FileChangeSummary, ma } if len(events.changes) > 0 { - includesNonWatchChange = true result.Changed.Add(uri) if o == nil { panic("overlay not found for changed file: " + uri) @@ -361,6 +357,5 @@ func (fs *overlayFS) processChanges(changes []FileChange) (FileChangeSummary, ma } fs.overlays = newOverlays - result.IncludesWatchChangesOnly = !includesNonWatchChange return result, newOverlays } diff --git a/internal/project/session.go b/internal/project/session.go index fabaf9a3c0..d163b6b402 100644 --- a/internal/project/session.go +++ b/internal/project/session.go @@ -21,6 +21,17 @@ import ( "github.com/microsoft/typescript-go/internal/vfs" ) +type UpdateReason int + +const ( + UpdateReasonUnknown UpdateReason = iota + UpdateReasonDidOpenFile + UpdateReasonDidChangeCompilerOptionsForInferredProjects + UpdateReasonRequestedLanguageServicePendingChanges + UpdateReasonRequestedLanguageServiceProjectNotLoaded + UpdateReasonRequestedLanguageServiceProjectDirty +) + // SessionOptions are the immutable initialization options for a session. // Snapshots may reference them as a pointer since they never change. type SessionOptions struct { @@ -91,14 +102,6 @@ type Session struct { pendingATAChanges map[tspath.Path]*ATAStateChange pendingATAChangesMu sync.Mutex - // snapshotUpdateCancel is the cancelation function for a scheduled - // snapshot update. Snapshot updates are debounced after file watch - // changes since many watch events can occur in quick succession - // during `npm install` or git operations. - // !!! This can probably be replaced by ScheduleDiagnosticsRefresh() - snapshotUpdateCancel context.CancelFunc - snapshotUpdateMu sync.Mutex - // diagnosticsRefreshCancel is the cancelation function for a scheduled // diagnostics refresh. Diagnostics refreshes are scheduled and debounced // after file watch changes and ATA updates. @@ -185,6 +188,7 @@ func (s *Session) DidOpenFile(ctx context.Context, uri lsproto.DocumentUri, vers changes, overlays := s.flushChangesLocked(ctx) s.pendingFileChangesMu.Unlock() s.UpdateSnapshot(ctx, overlays, SnapshotChange{ + reason: UpdateReasonDidOpenFile, fileChanges: changes, requestedURIs: []lsproto.DocumentUri{uri}, }) @@ -247,68 +251,18 @@ func (s *Session) DidChangeWatchedFiles(ctx context.Context, changes []*lsproto. s.pendingFileChanges = append(s.pendingFileChanges, fileChanges...) s.pendingFileChangesMu.Unlock() - // Schedule a debounced snapshot update - s.ScheduleSnapshotUpdate() + // Schedule a debounced diagnostics refresh + s.ScheduleDiagnosticsRefresh() } func (s *Session) DidChangeCompilerOptionsForInferredProjects(ctx context.Context, options *core.CompilerOptions) { s.compilerOptionsForInferredProjects = options s.UpdateSnapshot(ctx, s.fs.Overlays(), SnapshotChange{ + reason: UpdateReasonDidChangeCompilerOptionsForInferredProjects, compilerOptionsForInferredProjects: options, }) } -// ScheduleSnapshotUpdate schedules a debounced snapshot update. -// If there's already a pending update, it will be cancelled and a new one scheduled. -// This is useful for batching rapid changes like file watch events. -func (s *Session) ScheduleSnapshotUpdate() { - s.snapshotUpdateMu.Lock() - defer s.snapshotUpdateMu.Unlock() - - // Cancel any existing scheduled update - if s.snapshotUpdateCancel != nil { - s.snapshotUpdateCancel() - s.logger.Log("Delaying scheduled snapshot update...") - } else { - s.logger.Log("Scheduling new snapshot update...") - } - - // Create a new cancellable context for the debounce task - debounceCtx, cancel := context.WithCancel(context.Background()) - s.snapshotUpdateCancel = cancel - - // Enqueue the debounced snapshot update - s.backgroundQueue.Enqueue(debounceCtx, func(ctx context.Context) { - // Sleep for the debounce delay - select { - case <-time.After(s.options.DebounceDelay): - // Delay completed, proceed with update - case <-ctx.Done(): - // Context was cancelled, newer events arrived - return - } - - // Clear the cancel function since we're about to execute the update - s.snapshotUpdateMu.Lock() - s.snapshotUpdateCancel = nil - s.snapshotUpdateMu.Unlock() - - // Process the accumulated changes - changeSummary, overlays, ataChanges := s.flushChanges(context.Background()) - if !changeSummary.IsEmpty() || len(ataChanges) > 0 { - if s.options.LoggingEnabled { - s.logger.Log("Running scheduled snapshot update") - } - s.UpdateSnapshot(context.Background(), overlays, SnapshotChange{ - fileChanges: changeSummary, - ataChanges: ataChanges, - }) - } else if s.options.LoggingEnabled { - s.logger.Log("Scheduled snapshot update skipped (no changes)") - } - }) -} - func (s *Session) ScheduleDiagnosticsRefresh() { s.diagnosticsRefreshMu.Lock() defer s.diagnosticsRefreshMu.Unlock() @@ -386,6 +340,7 @@ func (s *Session) GetLanguageService(ctx context.Context, uri lsproto.DocumentUr // If there are pending file changes, we need to update the snapshot. // Sending the requested URI ensures that the project for this URI is loaded. snapshot = s.UpdateSnapshot(ctx, overlays, SnapshotChange{ + reason: UpdateReasonRequestedLanguageServicePendingChanges, fileChanges: fileChanges, ataChanges: ataChanges, requestedURIs: []lsproto.DocumentUri{uri}, @@ -402,7 +357,10 @@ func (s *Session) GetLanguageService(ctx context.Context, uri lsproto.DocumentUr // The current snapshot does not have an up to date project for the URI, // so we need to update the snapshot to ensure the project is loaded. // !!! Allow multiple projects to update in parallel - snapshot = s.UpdateSnapshot(ctx, overlays, SnapshotChange{requestedURIs: []lsproto.DocumentUri{uri}}) + snapshot = s.UpdateSnapshot(ctx, overlays, SnapshotChange{ + reason: core.IfElse(project == nil, UpdateReasonRequestedLanguageServiceProjectNotLoaded, UpdateReasonRequestedLanguageServiceProjectDirty), + requestedURIs: []lsproto.DocumentUri{uri}, + }) project = snapshot.GetDefaultProject(uri) } if project == nil { @@ -412,15 +370,6 @@ func (s *Session) GetLanguageService(ctx context.Context, uri lsproto.DocumentUr } func (s *Session) UpdateSnapshot(ctx context.Context, overlays map[tspath.Path]*overlay, change SnapshotChange) *Snapshot { - // Cancel any pending scheduled update since we're doing an immediate update - s.snapshotUpdateMu.Lock() - if s.snapshotUpdateCancel != nil { - s.logger.Log("Canceling scheduled snapshot update and performing one now") - s.snapshotUpdateCancel() - s.snapshotUpdateCancel = nil - } - s.snapshotUpdateMu.Unlock() - s.snapshotMu.Lock() oldSnapshot := s.snapshot newSnapshot := oldSnapshot.Clone(ctx, change, overlays, s) @@ -449,9 +398,6 @@ func (s *Session) UpdateSnapshot(ctx context.Context, overlays map[tspath.Path]* s.logger.Log(err) } } - if change.fileChanges.IncludesWatchChangesOnly { - s.ScheduleDiagnosticsRefresh() - } }) return newSnapshot @@ -554,13 +500,8 @@ func (s *Session) updateWatches(oldSnapshot *Snapshot, newSnapshot *Snapshot) er } func (s *Session) Close() { - // Cancel any pending snapshot update - s.snapshotUpdateMu.Lock() - if s.snapshotUpdateCancel != nil { - s.snapshotUpdateCancel() - s.snapshotUpdateCancel = nil - } - s.snapshotUpdateMu.Unlock() + // Cancel any pending diagnostics refresh + s.cancelDiagnosticsRefresh() s.backgroundQueue.Close() } @@ -597,9 +538,9 @@ func (s *Session) logProjectChanges(oldSnapshot *Snapshot, newSnapshot *Snapshot project.print(s.logger.IsVerbose() /*writeFileNames*/, s.logger.IsVerbose() /*writeFileExplanation*/, &builder) s.logger.Log(builder.String()) } - core.DiffMaps( - oldSnapshot.ProjectCollection.configuredProjects, - newSnapshot.ProjectCollection.configuredProjects, + collections.DiffOrderedMaps( + oldSnapshot.ProjectCollection.ProjectsByPath(), + newSnapshot.ProjectCollection.ProjectsByPath(), func(path tspath.Path, addedProject *Project) { // New project added logProject(addedProject) @@ -615,17 +556,6 @@ func (s *Session) logProjectChanges(oldSnapshot *Snapshot, newSnapshot *Snapshot } }, ) - - oldInferred := oldSnapshot.ProjectCollection.inferredProject - newInferred := newSnapshot.ProjectCollection.inferredProject - - if oldInferred != nil && newInferred == nil { - // Inferred project removed - s.logger.Logf("\nProject '%s' removed\n%s", oldInferred.Name(), hr) - } else if newInferred != nil && newInferred.ProgramUpdateKind == ProgramUpdateKindNewFiles { - // Inferred project updated - logProject(newInferred) - } } func (s *Session) NpmInstall(cwd string, npmInstallArgs []string) ([]byte, error) { diff --git a/internal/project/snapshot.go b/internal/project/snapshot.go index 112e2225c8..2dde754109 100644 --- a/internal/project/snapshot.go +++ b/internal/project/snapshot.go @@ -95,6 +95,7 @@ type APISnapshotRequest struct { } type SnapshotChange struct { + reason UpdateReason // fileChanges are the changes that have occurred since the last snapshot. fileChanges FileChangeSummary // requestedURIs are URIs that were requested by the client. @@ -123,8 +124,31 @@ type ATAStateChange struct { func (s *Snapshot) Clone(ctx context.Context, change SnapshotChange, overlays map[tspath.Path]*overlay, session *Session) *Snapshot { var logger *logging.LogTree + + // Print in-progress logs immediately if cloning fails + if session.options.LoggingEnabled { + defer func() { + if r := recover(); r != nil { + session.logger.Write(logger.String()) + panic(r) + } + }() + } + if session.options.LoggingEnabled { logger = logging.NewLogTree(fmt.Sprintf("Cloning snapshot %d", s.id)) + switch change.reason { + case UpdateReasonDidOpenFile: + logger.Logf("Reason: DidOpenFile - %s", change.fileChanges.Opened) + case UpdateReasonDidChangeCompilerOptionsForInferredProjects: + logger.Logf("Reason: DidChangeCompilerOptionsForInferredProjects") + case UpdateReasonRequestedLanguageServicePendingChanges: + logger.Logf("Reason: RequestedLanguageService (pending file changes) - %v", change.requestedURIs) + case UpdateReasonRequestedLanguageServiceProjectNotLoaded: + logger.Logf("Reason: RequestedLanguageService (project not loaded) - %v", change.requestedURIs) + case UpdateReasonRequestedLanguageServiceProjectDirty: + logger.Logf("Reason: RequestedLanguageService (project dirty) - %v", change.requestedURIs) + } } start := time.Now() From 385e7d93ded70b415eead29348b918b96e26167f Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Thu, 28 Aug 2025 11:02:32 -0700 Subject: [PATCH 2/3] Fix disk file cache leak --- internal/lsp/server.go | 20 +++++++++++ internal/project/compilerhost.go | 30 +++++++++++++++- internal/project/projectcollectionbuilder.go | 4 +++ internal/project/session.go | 37 ++++++++++++++++++++ internal/project/snapshot.go | 34 ++++++++++++++++-- internal/project/snapshot_test.go | 32 +++++++++++++++++ 6 files changed, 154 insertions(+), 3 deletions(-) diff --git a/internal/lsp/server.go b/internal/lsp/server.go index ed1a15ddb6..7a7e022976 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -445,6 +445,7 @@ var handlers = sync.OnceValue(func() handlerMap { registerNotificationHandler(handlers, lsproto.TextDocumentDidSaveInfo, (*Server).handleDidSave) registerNotificationHandler(handlers, lsproto.TextDocumentDidCloseInfo, (*Server).handleDidClose) registerNotificationHandler(handlers, lsproto.WorkspaceDidChangeWatchedFilesInfo, (*Server).handleDidChangeWatchedFiles) + registerNotificationHandler(handlers, lsproto.SetTraceInfo, (*Server).handleSetTrace) registerLanguageServiceDocumentRequestHandler(handlers, lsproto.TextDocumentDiagnosticInfo, (*Server).handleDocumentDiagnostic) registerLanguageServiceDocumentRequestHandler(handlers, lsproto.TextDocumentHoverInfo, (*Server).handleHover) @@ -557,6 +558,10 @@ func (s *Server) handleInitialize(ctx context.Context, params *lsproto.Initializ s.locale = locale } + if s.initializeParams.Trace != nil && *s.initializeParams.Trace == "verbose" { + s.logger.SetVerbose(true) + } + response := &lsproto.InitializeResult{ ServerInfo: &lsproto.ServerInfo{ Name: "typescript-go", @@ -686,6 +691,21 @@ func (s *Server) handleDidChangeWatchedFiles(ctx context.Context, params *lsprot return nil } +func (s *Server) handleSetTrace(ctx context.Context, params *lsproto.SetTraceParams) error { + switch params.Value { + case "verbose": + s.logger.SetVerbose(true) + case "messages": + s.logger.SetVerbose(false) + case "off": + // !!! logging cannot be completely turned off for now + s.logger.SetVerbose(false) + default: + return fmt.Errorf("unknown trace value: %s", params.Value) + } + return nil +} + func (s *Server) handleDocumentDiagnostic(ctx context.Context, ls *ls.LanguageService, params *lsproto.DocumentDiagnosticParams) (lsproto.DocumentDiagnosticResponse, error) { return ls.ProvideDiagnostics(ctx, params.TextDocument.Uri) } diff --git a/internal/project/compilerhost.go b/internal/project/compilerhost.go index 262e55403f..36cf2dfc4c 100644 --- a/internal/project/compilerhost.go +++ b/internal/project/compilerhost.go @@ -4,6 +4,7 @@ import ( "time" "github.com/microsoft/typescript-go/internal/ast" + "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/compiler" "github.com/microsoft/typescript-go/internal/ls" "github.com/microsoft/typescript-go/internal/project/logging" @@ -22,24 +23,49 @@ type compilerHost struct { fs *snapshotFSBuilder compilerFS *compilerFS configFileRegistry *ConfigFileRegistry + seenFiles *collections.SyncSet[tspath.Path] project *Project builder *projectCollectionBuilder logger *logging.LogTree } +type builderFileSource struct { + seenFiles *collections.SyncSet[tspath.Path] + snapshotFSBuilder *snapshotFSBuilder +} + +func (c *builderFileSource) GetFile(fileName string) FileHandle { + path := c.snapshotFSBuilder.toPath(fileName) + c.seenFiles.Add(path) + return c.snapshotFSBuilder.GetFileByPath(fileName, path) +} + +func (c *builderFileSource) FS() vfs.FS { + return c.snapshotFSBuilder.FS() +} + func newCompilerHost( currentDirectory string, project *Project, builder *projectCollectionBuilder, logger *logging.LogTree, ) *compilerHost { + seenFiles := &collections.SyncSet[tspath.Path]{} + compilerFS := &compilerFS{ + source: &builderFileSource{ + seenFiles: seenFiles, + snapshotFSBuilder: builder.fs, + }, + } + return &compilerHost{ configFilePath: project.configFilePath, currentDirectory: currentDirectory, sessionOptions: builder.sessionOptions, - compilerFS: &compilerFS{source: builder.fs}, + compilerFS: compilerFS, + seenFiles: seenFiles, fs: builder.fs, project: project, @@ -88,6 +114,7 @@ func (c *compilerHost) GetResolvedProjectReference(fileName string, path tspath. if c.builder == nil { return c.configFileRegistry.GetConfig(path) } else { + c.seenFiles.Add(path) return c.builder.configFileRegistryBuilder.acquireConfigForProject(fileName, path, c.project, c.logger) } } @@ -97,6 +124,7 @@ func (c *compilerHost) GetResolvedProjectReference(fileName string, path tspath. // be a corresponding release for each call made. func (c *compilerHost) GetSourceFile(opts ast.SourceFileParseOptions) *ast.SourceFile { c.ensureAlive() + c.seenFiles.Add(opts.Path) if fh := c.fs.GetFileByPath(opts.FileName, opts.Path); fh != nil { return c.builder.parseCache.Acquire(fh, opts, fh.Kind()) } diff --git a/internal/project/projectcollectionbuilder.go b/internal/project/projectcollectionbuilder.go index f706bef8aa..309b31c4a4 100644 --- a/internal/project/projectcollectionbuilder.go +++ b/internal/project/projectcollectionbuilder.go @@ -772,12 +772,16 @@ func (b *projectCollectionBuilder) updateProgram(entry dirty.Value[*Project], lo } if updateProgram { entry.Change(func(project *Project) { + oldHost := project.host project.host = newCompilerHost(project.currentDirectory, project, b, logger.Fork("CompilerHost")) result := project.CreateProgram() project.Program = result.Program project.checkerPool = result.CheckerPool project.ProgramUpdateKind = result.UpdateKind project.ProgramLastUpdate = b.newSnapshotID + if result.UpdateKind == ProgramUpdateKindCloned { + project.host.seenFiles = oldHost.seenFiles + } if result.UpdateKind == ProgramUpdateKindNewFiles { filesChanged = true if b.sessionOptions.WatchEnabled { diff --git a/internal/project/session.go b/internal/project/session.go index d163b6b402..4e78c28884 100644 --- a/internal/project/session.go +++ b/internal/project/session.go @@ -11,6 +11,7 @@ import ( "github.com/microsoft/typescript-go/internal/ast" "github.com/microsoft/typescript-go/internal/collections" + "github.com/microsoft/typescript-go/internal/compiler" "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/ls" "github.com/microsoft/typescript-go/internal/lsp/lsproto" @@ -533,10 +534,12 @@ func (s *Session) flushChangesLocked(ctx context.Context) (FileChangeSummary, ma // logProjectChanges logs information about projects that have changed between snapshots func (s *Session) logProjectChanges(oldSnapshot *Snapshot, newSnapshot *Snapshot) { + var loggedProjectChanges bool logProject := func(project *Project) { var builder strings.Builder project.print(s.logger.IsVerbose() /*writeFileNames*/, s.logger.IsVerbose() /*writeFileExplanation*/, &builder) s.logger.Log(builder.String()) + loggedProjectChanges = true } collections.DiffOrderedMaps( oldSnapshot.ProjectCollection.ProjectsByPath(), @@ -556,6 +559,40 @@ func (s *Session) logProjectChanges(oldSnapshot *Snapshot, newSnapshot *Snapshot } }, ) + + if loggedProjectChanges || s.logger.IsVerbose() { + s.logCacheStats(newSnapshot) + } +} + +func (s *Session) logCacheStats(snapshot *Snapshot) { + var parseCacheSize int + var programCount int + var extendedConfigCount int + if s.logger.IsVerbose() { + s.parseCache.entries.Range(func(_ parseCacheKey, _ *parseCacheEntry) bool { + parseCacheSize++ + return true + }) + s.programCounter.refs.Range(func(_ *compiler.Program, _ *atomic.Int32) bool { + programCount++ + return true + }) + s.extendedConfigCache.entries.Range(func(_ tspath.Path, _ *extendedConfigCacheEntry) bool { + extendedConfigCount++ + return true + }) + } + s.logger.Write("\n======== Cache Statistics ========") + s.logger.Logf("Open file count: %6d", len(snapshot.fs.overlays)) + s.logger.Logf("Cached disk files: %6d", len(snapshot.fs.diskFiles)) + s.logger.Logf("Project count: %6d", len(snapshot.ProjectCollection.Projects())) + s.logger.Logf("Config count: %6d", len(snapshot.ConfigFileRegistry.configs)) + if s.logger.IsVerbose() { + s.logger.Logf("Parse cache size: %6d", parseCacheSize) + s.logger.Logf("Program count: %6d", programCount) + s.logger.Logf("Extended config cache size: %6d", extendedConfigCount) + } } func (s *Session) NpmInstall(cwd string, npmInstallArgs []string) ([]byte, error) { diff --git a/internal/project/snapshot.go b/internal/project/snapshot.go index 2dde754109..b5d7d1722e 100644 --- a/internal/project/snapshot.go +++ b/internal/project/snapshot.go @@ -11,6 +11,7 @@ import ( "github.com/microsoft/typescript-go/internal/ls" "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/project/ata" + "github.com/microsoft/typescript-go/internal/project/dirty" "github.com/microsoft/typescript-go/internal/project/logging" "github.com/microsoft/typescript-go/internal/tspath" ) @@ -193,8 +194,38 @@ func (s *Snapshot) Clone(ctx context.Context, change SnapshotChange, overlays ma } projectCollection, configFileRegistry := projectCollectionBuilder.Finalize(logger) - snapshotFS, _ := fs.Finalize() + // Clean cached disk files not touched by any open project. It's not important that we do this on + // file open specifically, but we don't need to do it on every snapshot clone. + if len(change.fileChanges.Opened) != 0 { + var changedFiles bool + for _, project := range projectCollection.Projects() { + if project.ProgramLastUpdate == newSnapshotID && project.ProgramUpdateKind != ProgramUpdateKindCloned { + changedFiles = true + break + } + } + // The set of seen files can change only if a program was constructed (not cloned) during this snapshot. + if changedFiles { + cleanFilesStart := time.Now() + removedFiles := 0 + fs.diskFiles.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *diskFile]) bool { + for _, project := range projectCollection.Projects() { + if project.host.seenFiles.Has(entry.Key()) { + return true + } + } + entry.Delete() + removedFiles++ + return true + }) + if session.options.LoggingEnabled { + logger.Logf("Removed %d cached files in %v", removedFiles, time.Since(cleanFilesStart)) + } + } + } + + snapshotFS, _ := fs.Finalize() newSnapshot := NewSnapshot( newSnapshotID, snapshotFS, @@ -205,7 +236,6 @@ func (s *Snapshot) Clone(ctx context.Context, change SnapshotChange, overlays ma compilerOptionsForInferredProjects, s.toPath, ) - newSnapshot.parentId = s.id newSnapshot.ProjectCollection = projectCollection newSnapshot.ConfigFileRegistry = configFileRegistry diff --git a/internal/project/snapshot_test.go b/internal/project/snapshot_test.go index d7ebeb3267..a075039b33 100644 --- a/internal/project/snapshot_test.go +++ b/internal/project/snapshot_test.go @@ -69,4 +69,36 @@ func TestSnapshot(t *testing.T) { // host for inferred project should not change assert.Equal(t, snapshotAfter.ProjectCollection.InferredProject().host.compilerFS.source, snapshotBefore.fs) }) + + t.Run("cached disk files are cleaned up", func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/home/projects/TS/p1/tsconfig.json": "{}", + "/home/projects/TS/p1/index.ts": "import { a } from './a'; console.log(a);", + "/home/projects/TS/p1/a.ts": "export const a = 1;", + "/home/projects/TS/p2/tsconfig.json": "{}", + "/home/projects/TS/p2/index.ts": "import { b } from './b'; console.log(b);", + "/home/projects/TS/p2/b.ts": "export const b = 2;", + } + session := setup(files) + session.DidOpenFile(context.Background(), "file:///home/projects/TS/p1/index.ts", 1, files["/home/projects/TS/p1/index.ts"].(string), lsproto.LanguageKindTypeScript) + session.DidOpenFile(context.Background(), "file:///home/projects/TS/p2/index.ts", 1, files["/home/projects/TS/p2/index.ts"].(string), lsproto.LanguageKindTypeScript) + snapshotBefore, release := session.Snapshot() + defer release() + + // a.ts and b.ts are cached + assert.Check(t, snapshotBefore.fs.diskFiles["/home/projects/ts/p1/a.ts"] != nil) + assert.Check(t, snapshotBefore.fs.diskFiles["/home/projects/ts/p2/b.ts"] != nil) + + // Close p1's only open file + session.DidCloseFile(context.Background(), "file:///home/projects/TS/p1/index.ts") + // Next open file is unrelated to p1, triggers p1 closing and file cache cleanup + session.DidOpenFile(context.Background(), "untitled:Untitled-1", 1, "", lsproto.LanguageKindTypeScript) + snapshotAfter, release := session.Snapshot() + defer release() + + // a.ts is cleaned up, b.ts is still cached + assert.Check(t, snapshotAfter.fs.diskFiles["/home/projects/ts/p1/a.ts"] == nil) + assert.Check(t, snapshotAfter.fs.diskFiles["/home/projects/ts/p2/b.ts"] != nil) + }) } From 7d12f57c57908d5f42e17145ee989b8e26c2ba80 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Thu, 28 Aug 2025 11:16:32 -0700 Subject: [PATCH 3/3] Fix test affected by snapshot scheduling change --- internal/project/ata/ata_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/project/ata/ata_test.go b/internal/project/ata/ata_test.go index da35cba548..7a6f2915ed 100644 --- a/internal/project/ata/ata_test.go +++ b/internal/project/ata/ata_test.go @@ -382,6 +382,8 @@ func TestATA(t *testing.T) { Type: lsproto.FileChangeTypeChanged, Uri: lsproto.DocumentUri("file:///user/username/projects/project/package.json"), }}) + // diagnostics refresh triggered - simulate by getting the language service + _, _ = session.GetLanguageService(context.Background(), uri) session.WaitForBackgroundTasks() calls = utils.NpmExecutor().NpmInstallCalls()