From 9dc9244a7a514633993b8c405c2bfb4a13aab4a5 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Thu, 9 Oct 2025 15:22:49 -0700 Subject: [PATCH 1/4] Invalidate file cache after large sets of watch changes --- internal/project/filechange.go | 6 ++++++ internal/project/snapshot.go | 8 +++++++- internal/project/snapshotfs.go | 9 +++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/internal/project/filechange.go b/internal/project/filechange.go index 2f702add0e..d24da8cbce 100644 --- a/internal/project/filechange.go +++ b/internal/project/filechange.go @@ -6,6 +6,8 @@ import ( "github.com/zeebo/xxh3" ) +const excessiveChangeThreshold = 1000 + type FileChangeKind int const ( @@ -43,3 +45,7 @@ type FileChangeSummary struct { func (f FileChangeSummary) IsEmpty() bool { return f.Opened == "" && len(f.Closed) == 0 && f.Changed.Len() == 0 && f.Created.Len() == 0 && f.Deleted.Len() == 0 } + +func (f FileChangeSummary) HasExcessiveWatchChanges() bool { + return f.Created.Len()+f.Deleted.Len()+f.Changed.Len() > excessiveChangeThreshold +} diff --git a/internal/project/snapshot.go b/internal/project/snapshot.go index 8d41e426c5..bdec5d78b9 100644 --- a/internal/project/snapshot.go +++ b/internal/project/snapshot.go @@ -174,7 +174,13 @@ func (s *Snapshot) Clone(ctx context.Context, change SnapshotChange, overlays ma start := time.Now() fs := newSnapshotFSBuilder(session.fs.fs, overlays, s.fs.diskFiles, session.options.PositionEncoding, s.toPath) - fs.markDirtyFiles(change.fileChanges) + if change.fileChanges.HasExcessiveWatchChanges() { + invalidateStart := time.Now() + fs.invalidateCache() + logger.Logf("Excessive watch changes detected, invalidated file cache in %v", time.Since(invalidateStart)) + } else { + fs.markDirtyFiles(change.fileChanges) + } compilerOptionsForInferredProjects := s.compilerOptionsForInferredProjects if change.compilerOptionsForInferredProjects != nil { diff --git a/internal/project/snapshotfs.go b/internal/project/snapshotfs.go index 441299e318..89ec02b952 100644 --- a/internal/project/snapshotfs.go +++ b/internal/project/snapshotfs.go @@ -122,6 +122,15 @@ func (s *snapshotFSBuilder) GetFileByPath(fileName string, path tspath.Path) Fil return entry.Value() } +func (s *snapshotFSBuilder) invalidateCache() { + s.diskFiles.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *diskFile]) bool { + entry.Change(func(file *diskFile) { + file.needsReload = true + }) + return true + }) +} + func (s *snapshotFSBuilder) markDirtyFiles(change FileChangeSummary) { for uri := range change.Changed.Keys() { path := s.toPath(uri.FileName()) From d9140a821f9d94ea023c5c25e2dc335c46c83f56 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Fri, 10 Oct 2025 09:24:20 -0700 Subject: [PATCH 2/4] Invalidate config cache on too many changes --- internal/project/configfileregistry.go | 6 ++- internal/project/configfileregistrybuilder.go | 47 ++++++++++++++----- internal/project/dirty/map.go | 5 ++ internal/project/projectcollectionbuilder.go | 4 +- 4 files changed, 48 insertions(+), 14 deletions(-) diff --git a/internal/project/configfileregistry.go b/internal/project/configfileregistry.go index 801d249be7..1b6613a613 100644 --- a/internal/project/configfileregistry.go +++ b/internal/project/configfileregistry.go @@ -19,6 +19,7 @@ type ConfigFileRegistry struct { } type configFileEntry struct { + fileName string pendingReload PendingReload commandLine *tsoptions.ParsedCommandLine // retainingProjects is the set of projects that have called acquireConfig @@ -46,6 +47,7 @@ type configFileEntry struct { func newConfigFileEntry(fileName string) *configFileEntry { return &configFileEntry{ + fileName: fileName, pendingReload: PendingReloadFull, rootFilesWatch: NewWatchedFiles( "root files for "+fileName, @@ -55,8 +57,9 @@ func newConfigFileEntry(fileName string) *configFileEntry { } } -func newExtendedConfigFileEntry(extendingConfigPath tspath.Path) *configFileEntry { +func newExtendedConfigFileEntry(fileName string, extendingConfigPath tspath.Path) *configFileEntry { return &configFileEntry{ + fileName: fileName, pendingReload: PendingReloadFull, retainingConfigs: map[tspath.Path]struct{}{extendingConfigPath: {}}, } @@ -64,6 +67,7 @@ func newExtendedConfigFileEntry(extendingConfigPath tspath.Path) *configFileEntr func (e *configFileEntry) Clone() *configFileEntry { return &configFileEntry{ + fileName: e.fileName, pendingReload: e.pendingReload, commandLine: e.commandLine, // !!! eagerly cloning these maps makes everything more convenient, diff --git a/internal/project/configfileregistrybuilder.go b/internal/project/configfileregistrybuilder.go index a4b5a7ff6d..19d269c413 100644 --- a/internal/project/configfileregistrybuilder.go +++ b/internal/project/configfileregistrybuilder.go @@ -122,7 +122,7 @@ func (c *configFileRegistryBuilder) updateExtendingConfigs(extendingConfigPath t for _, extendedConfig := range newCommandLine.ExtendedSourceFiles() { extendedConfigPath := c.fs.toPath(extendedConfig) newExtendedConfigPaths.Add(extendedConfigPath) - entry, loaded := c.configs.LoadOrStore(extendedConfigPath, newExtendedConfigFileEntry(extendingConfigPath)) + entry, loaded := c.configs.LoadOrStore(extendedConfigPath, newExtendedConfigFileEntry(extendedConfig, extendingConfigPath)) if loaded { entry.ChangeIf( func(config *configFileEntry) bool { @@ -320,6 +320,39 @@ func (r changeFileResult) IsEmpty() bool { func (c *configFileRegistryBuilder) DidChangeFiles(summary FileChangeSummary, logger *logging.LogTree) changeFileResult { var affectedProjects map[tspath.Path]struct{} var affectedFiles map[tspath.Path]struct{} + + if summary.HasExcessiveWatchChanges() { + logger.Log("Too many files changed; marking all configs for reload") + c.configFileNames.Range(func(entry *dirty.MapEntry[tspath.Path, *configFileNames]) bool { + if affectedFiles == nil { + affectedFiles = make(map[tspath.Path]struct{}) + } + affectedFiles[entry.Key()] = struct{}{} + return true + }) + c.configFileNames.Clear() + + c.configs.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *configFileEntry]) bool { + entry.Change(func(entry *configFileEntry) { + affectedProjects = core.CopyMapInto(affectedProjects, entry.retainingProjects) + if entry.pendingReload != PendingReloadFull { + text, ok := c.FS().ReadFile(entry.fileName) + if !ok || text != entry.commandLine.ConfigFile.SourceFile.Text() { + entry.pendingReload = PendingReloadFull + } else { + entry.pendingReload = PendingReloadFileNames + } + } + }) + return true + }) + + return changeFileResult{ + affectedProjects: affectedProjects, + affectedFiles: affectedFiles, + } + } + logger.Log("Summarizing file changes") createdFiles := make(map[tspath.Path]string, summary.Created.Len()) createdOrDeletedFiles := make(map[tspath.Path]struct{}, summary.Created.Len()+summary.Deleted.Len()) @@ -465,7 +498,7 @@ func (c *configFileRegistryBuilder) computeConfigFileName(fileName string, skipS return result } -func (c *configFileRegistryBuilder) getConfigFileNameForFile(fileName string, path tspath.Path, loadKind projectLoadKind, logger *logging.LogTree) string { +func (c *configFileRegistryBuilder) getConfigFileNameForFile(fileName string, path tspath.Path, logger *logging.LogTree) string { if isDynamicFileName(fileName) { return "" } @@ -474,10 +507,6 @@ func (c *configFileRegistryBuilder) getConfigFileNameForFile(fileName string, pa return entry.Value().nearestConfigFileName } - if loadKind == projectLoadKindFind { - return "" - } - configName := c.computeConfigFileName(fileName, false, logger) if _, ok := c.fs.overlays[path]; ok { @@ -488,7 +517,7 @@ func (c *configFileRegistryBuilder) getConfigFileNameForFile(fileName string, pa return configName } -func (c *configFileRegistryBuilder) getAncestorConfigFileName(fileName string, path tspath.Path, configFileName string, loadKind projectLoadKind, logger *logging.LogTree) string { +func (c *configFileRegistryBuilder) getAncestorConfigFileName(fileName string, path tspath.Path, configFileName string, logger *logging.LogTree) string { if isDynamicFileName(fileName) { return "" } @@ -501,10 +530,6 @@ func (c *configFileRegistryBuilder) getAncestorConfigFileName(fileName string, p return ancestorConfigName } - if loadKind == projectLoadKindFind { - return "" - } - // Look for config in parent folders of config file result := c.computeConfigFileName(configFileName, true, logger) diff --git a/internal/project/dirty/map.go b/internal/project/dirty/map.go index 9097afc579..10f72d7ae4 100644 --- a/internal/project/dirty/map.go +++ b/internal/project/dirty/map.go @@ -127,6 +127,11 @@ func (m *Map[K, V]) Range(fn func(*MapEntry[K, V]) bool) { } } +func (m *Map[K, V]) Clear() { + m.dirty = make(map[K]*MapEntry[K, V]) + m.base = make(map[K]V) +} + func (m *Map[K, V]) Finalize() (result map[K]V, changed bool) { if len(m.dirty) == 0 { return m.base, false // no changes, return base map diff --git a/internal/project/projectcollectionbuilder.go b/internal/project/projectcollectionbuilder.go index c898292bea..e2af3e7462 100644 --- a/internal/project/projectcollectionbuilder.go +++ b/internal/project/projectcollectionbuilder.go @@ -617,7 +617,7 @@ func (b *projectCollectionBuilder) findOrCreateDefaultConfiguredProjectWorker( return *fallback } } - if ancestorConfigName := b.configFileRegistryBuilder.getAncestorConfigFileName(fileName, path, configFileName, loadKind, logger); ancestorConfigName != "" { + if ancestorConfigName := b.configFileRegistryBuilder.getAncestorConfigFileName(fileName, path, configFileName, logger); ancestorConfigName != "" { return b.findOrCreateDefaultConfiguredProjectWorker( fileName, path, @@ -655,7 +655,7 @@ func (b *projectCollectionBuilder) findOrCreateDefaultConfiguredProjectForOpenSc entry, _ := b.configuredProjects.Load(key) return searchResult{project: entry} } - if configFileName := b.configFileRegistryBuilder.getConfigFileNameForFile(fileName, path, loadKind, logger); configFileName != "" { + if configFileName := b.configFileRegistryBuilder.getConfigFileNameForFile(fileName, path, logger); configFileName != "" { startTime := time.Now() result := b.findOrCreateDefaultConfiguredProjectWorker( fileName, From 5a7eb447b8af697eb7a1e775d90e085ab01953c0 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Fri, 10 Oct 2025 10:47:04 -0700 Subject: [PATCH 3/4] =?UTF-8?q?Treat=20node=5Fmodules-only=20changes=20sep?= =?UTF-8?q?arately=20so=20config=20caches=20don=E2=80=99t=20need=20to=20be?= =?UTF-8?q?=20invalidated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/project/bulkcache_test.go | 251 ++++++++++++++++++ internal/project/configfileregistrybuilder.go | 2 +- internal/project/filechange.go | 8 + internal/project/overlayfs.go | 5 + internal/project/snapshot.go | 9 +- internal/project/snapshotfs.go | 12 + 6 files changed, 284 insertions(+), 3 deletions(-) create mode 100644 internal/project/bulkcache_test.go diff --git a/internal/project/bulkcache_test.go b/internal/project/bulkcache_test.go new file mode 100644 index 0000000000..28585cf12a --- /dev/null +++ b/internal/project/bulkcache_test.go @@ -0,0 +1,251 @@ +package project_test + +import ( + "context" + "fmt" + "testing" + + "github.com/microsoft/typescript-go/internal/bundled" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/project" + "github.com/microsoft/typescript-go/internal/testutil/projecttestutil" + "gotest.tools/v3/assert" +) + +func TestBulkCacheInvalidation(t *testing.T) { + t.Parallel() + + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + + // Base file structure for testing + baseFiles := map[string]any{ + "/project/tsconfig.json": `{ + "compilerOptions": { + "strict": true, + "target": "es2015" + }, + "include": ["src/**/*"] + }`, + "/project/src/index.ts": `import { helper } from "./helper"; console.log(helper);`, + "/project/src/helper.ts": `export const helper = "test";`, + "/project/src/utils/lib.ts": `export function util() { return "util"; }`, + } + + t.Run("large number of node_modules changes invalidates only node_modules cache", func(t *testing.T) { + t.Parallel() + session, utils := projecttestutil.Setup(baseFiles) + + // Open a file to create the project + session.DidOpenFile(context.Background(), "file:///project/src/index.ts", 1, baseFiles["/project/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + + // Get initial snapshot and verify config + ls, err := session.GetLanguageService(context.Background(), "file:///project/src/index.ts") + assert.NilError(t, err) + assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetES2015) + + snapshotBefore, release := session.Snapshot() + defer release() + configBefore := snapshotBefore.ConfigFileRegistry + + // Create excessive changes in node_modules (1001 changes to exceed threshold) + fileEvents := generateFileEvents(1001, "file:///project/node_modules/generated/file%d.js", lsproto.FileChangeTypeCreated) + + // Update tsconfig.json on disk to test that configs don't get reloaded + err = utils.FS().WriteFile("/project/tsconfig.json", `{ + "compilerOptions": { + "strict": true, + "target": "esnext" + }, + "include": ["src/**/*"] + }`, false) + assert.NilError(t, err) + + // Process the excessive node_modules changes + session.DidChangeWatchedFiles(context.Background(), fileEvents) + + // Get language service again to trigger snapshot update + ls, err = session.GetLanguageService(context.Background(), "file:///project/src/index.ts") + assert.NilError(t, err) + + snapshotAfter, release := session.Snapshot() + defer release() + configAfter := snapshotAfter.ConfigFileRegistry + + // Config should NOT have been reloaded (target should remain ES2015, not esnext) + assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetES2015, "Config should not have been reloaded for node_modules-only changes") + + // Config registry should be the same instance (no configs reloaded) + assert.Equal(t, configBefore, configAfter, "Config registry should not have changed for node_modules-only changes") + }) + + t.Run("large number of changes outside node_modules causes config reload", func(t *testing.T) { + t.Parallel() + session, utils := projecttestutil.Setup(baseFiles) + + // Open a file to create the project + session.DidOpenFile(context.Background(), "file:///project/src/index.ts", 1, baseFiles["/project/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + + // Get initial state + ls, err := session.GetLanguageService(context.Background(), "file:///project/src/index.ts") + assert.NilError(t, err) + assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetES2015) + + snapshotBefore, release := session.Snapshot() + defer release() + + // Update tsconfig.json on disk + err = utils.FS().WriteFile("/project/tsconfig.json", `{ + "compilerOptions": { + "strict": true, + "target": "esnext" + }, + "include": ["src/**/*"] + }`, false) + assert.NilError(t, err) + // Add root file + err = utils.FS().WriteFile("/project/src/rootFile.ts", `console.log("root file")`, false) + assert.NilError(t, err) + + // Create excessive changes outside node_modules (1001 changes to exceed threshold) + fileEvents := generateFileEvents(1001, "file:///project/generated/file%d.ts", lsproto.FileChangeTypeCreated) + + // Process the excessive changes outside node_modules + session.DidChangeWatchedFiles(context.Background(), fileEvents) + + // Get language service again to trigger snapshot update + ls, err = session.GetLanguageService(context.Background(), "file:///project/src/index.ts") + assert.NilError(t, err) + + snapshotAfter, release := session.Snapshot() + defer release() + + // Config SHOULD have been reloaded (target should now be esnext and new root file present) + assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetESNext, "Config should have been reloaded for changes outside node_modules") + assert.Check(t, ls.GetProgram().GetSourceFile("/project/src/rootFile.ts") != nil, "New root file should be present") + + // Snapshots should be different + assert.Assert(t, snapshotBefore != snapshotAfter, "Snapshot should have changed after bulk invalidation outside node_modules") + }) + + t.Run("large number of changes outside node_modules causes project reevaluation", func(t *testing.T) { + t.Parallel() + session, utils := projecttestutil.Setup(baseFiles) + + // Open a file that will initially use the root tsconfig + session.DidOpenFile(context.Background(), "file:///project/src/utils/lib.ts", 1, baseFiles["/project/src/utils/lib.ts"].(string), lsproto.LanguageKindTypeScript) + + // Initially, the file should use the root project (strict mode) + snapshot, release := session.Snapshot() + defer release() + initialProject := snapshot.GetDefaultProject("file:///project/src/utils/lib.ts") + assert.Equal(t, initialProject.Name(), "/project/tsconfig.json", "Should initially use root tsconfig") + + // Get language service to verify initial strict mode + ls, err := session.GetLanguageService(context.Background(), "file:///project/src/utils/lib.ts") + assert.NilError(t, err) + assert.Equal(t, ls.GetProgram().Options().Strict, core.TSTrue, "Should initially use strict mode from root config") + + // Now create the nested tsconfig (this would normally be detected, but we'll simulate a missed event) + err = utils.FS().WriteFile("/project/src/utils/tsconfig.json", `{ + "compilerOptions": { + "strict": false, + "target": "esnext" + } + }`, false) + assert.NilError(t, err) + + // Create excessive changes outside node_modules to trigger bulk invalidation + // This simulates the scenario where the nested tsconfig creation was missed in the flood of changes + fileEvents := generateFileEvents(1001, "file:///project/src/generated/file%d.ts", lsproto.FileChangeTypeCreated) + + // Process the excessive changes - this should trigger project reevaluation + session.DidChangeWatchedFiles(context.Background(), fileEvents) + + // Get language service - this should now find the nested config and switch projects + ls, err = session.GetLanguageService(context.Background(), "file:///project/src/utils/lib.ts") + assert.NilError(t, err) + + snapshot, release = session.Snapshot() + defer release() + newProject := snapshot.GetDefaultProject("file:///project/src/utils/lib.ts") + + // The file should now use the nested tsconfig + assert.Equal(t, newProject.Name(), "/project/src/utils/tsconfig.json", "Should now use nested tsconfig after bulk invalidation") + assert.Equal(t, ls.GetProgram().Options().Strict, core.TSFalse, "Should now use non-strict mode from nested config") + assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetESNext, "Should use esnext target from nested config") + }) + + t.Run("excessive changes only in node_modules does not affect config file names cache", func(t *testing.T) { + t.Parallel() + testConfigFileNamesCacheBehavior(t, "file:///project/node_modules/generated/file%d.js", false, "node_modules changes should not clear config cache") + }) + + t.Run("excessive changes outside node_modules clears config file names cache", func(t *testing.T) { + t.Parallel() + testConfigFileNamesCacheBehavior(t, "file:///project/generated/file%d.ts", true, "non-node_modules changes should clear config cache") + }) +} + +// Helper function to generate excessive file change events +func generateFileEvents(count int, pathTemplate string, changeType lsproto.FileChangeType) []*lsproto.FileEvent { + var events []*lsproto.FileEvent + for i := range count { + events = append(events, &lsproto.FileEvent{ + Uri: lsproto.DocumentUri(fmt.Sprintf(pathTemplate, i)), + Type: changeType, + }) + } + return events +} + +// Helper function to test config file names cache behavior +func testConfigFileNamesCacheBehavior(t *testing.T, eventPathTemplate string, expectConfigDiscovery bool, testName string) { + files := map[string]any{ + "/project/src/index.ts": `console.log("test");`, // No tsconfig initially + } + session, utils := projecttestutil.Setup(files) + + // Open file without tsconfig - should create inferred project + session.DidOpenFile(context.Background(), "file:///project/src/index.ts", 1, files["/project/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + + snapshot, release := session.Snapshot() + defer release() + assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil, "Should have inferred project") + assert.Equal(t, snapshot.GetDefaultProject("file:///project/src/index.ts").Kind, project.KindInferred) + + // Create a tsconfig that would affect this file (simulating a missed creation event) + err := utils.FS().WriteFile("/project/tsconfig.json", `{ + "compilerOptions": { + "strict": true + }, + "include": ["src/**/*"] + }`, false) + assert.NilError(t, err) + + // Create excessive changes to trigger bulk invalidation + fileEvents := generateFileEvents(1001, eventPathTemplate, lsproto.FileChangeTypeCreated) + + // Process the changes + session.DidChangeWatchedFiles(context.Background(), fileEvents) + + // Get language service to trigger config discovery + _, err = session.GetLanguageService(context.Background(), "file:///project/src/index.ts") + assert.NilError(t, err) + + snapshot, release = session.Snapshot() + defer release() + newProject := snapshot.GetDefaultProject("file:///project/src/index.ts") + + // Check expected behavior + if expectConfigDiscovery { + // Should now use configured project instead of inferred + assert.Equal(t, newProject.Kind, project.KindConfigured, "Should now use configured project after cache invalidation") + assert.Equal(t, newProject.Name(), "/project/tsconfig.json", "Should use the newly discovered tsconfig") + } else { + // Should still use inferred project (config file names cache not cleared) + assert.Assert(t, newProject == snapshot.ProjectCollection.InferredProject(), "Should still use inferred project after node_modules-only changes") + } +} diff --git a/internal/project/configfileregistrybuilder.go b/internal/project/configfileregistrybuilder.go index 19d269c413..3420f7c396 100644 --- a/internal/project/configfileregistrybuilder.go +++ b/internal/project/configfileregistrybuilder.go @@ -321,7 +321,7 @@ func (c *configFileRegistryBuilder) DidChangeFiles(summary FileChangeSummary, lo var affectedProjects map[tspath.Path]struct{} var affectedFiles map[tspath.Path]struct{} - if summary.HasExcessiveWatchChanges() { + if summary.HasExcessiveWatchChanges() && summary.IncludesWatchChangeOutsideNodeModules { logger.Log("Too many files changed; marking all configs for reload") c.configFileNames.Range(func(entry *dirty.MapEntry[tspath.Path, *configFileNames]) bool { if affectedFiles == nil { diff --git a/internal/project/filechange.go b/internal/project/filechange.go index d24da8cbce..0ddf238a56 100644 --- a/internal/project/filechange.go +++ b/internal/project/filechange.go @@ -20,6 +20,10 @@ const ( FileChangeKindWatchDelete ) +func (k FileChangeKind) IsWatchKind() bool { + return k == FileChangeKindWatchCreate || k == FileChangeKindWatchChange || k == FileChangeKindWatchDelete +} + type FileChange struct { Kind FileChangeKind URI lsproto.DocumentUri @@ -40,6 +44,10 @@ type FileChangeSummary struct { Created collections.Set[lsproto.DocumentUri] // Only set when file watching is enabled Deleted collections.Set[lsproto.DocumentUri] + + // IncludesWatchChangeOutsideNodeModules is true if the summary includes a create, change, or delete watch + // event of a file outside a node_modules directory. + IncludesWatchChangeOutsideNodeModules bool } func (f FileChangeSummary) IsEmpty() bool { diff --git a/internal/project/overlayfs.go b/internal/project/overlayfs.go index 47ec2d95f9..26c6e2aa68 100644 --- a/internal/project/overlayfs.go +++ b/internal/project/overlayfs.go @@ -2,6 +2,7 @@ package project import ( "maps" + "strings" "sync" "github.com/microsoft/typescript-go/internal/core" @@ -237,6 +238,10 @@ func (fs *overlayFS) processChanges(changes []FileChange) (FileChangeSummary, ma fileEventMap[uri] = events } + if !result.IncludesWatchChangeOutsideNodeModules && change.Kind.IsWatchKind() && !strings.Contains(string(uri), "/node_modules/") { + result.IncludesWatchChangeOutsideNodeModules = true + } + switch change.Kind { case FileChangeKindOpen: events.openChange = &change diff --git a/internal/project/snapshot.go b/internal/project/snapshot.go index bdec5d78b9..3de8646f65 100644 --- a/internal/project/snapshot.go +++ b/internal/project/snapshot.go @@ -176,8 +176,13 @@ func (s *Snapshot) Clone(ctx context.Context, change SnapshotChange, overlays ma fs := newSnapshotFSBuilder(session.fs.fs, overlays, s.fs.diskFiles, session.options.PositionEncoding, s.toPath) if change.fileChanges.HasExcessiveWatchChanges() { invalidateStart := time.Now() - fs.invalidateCache() - logger.Logf("Excessive watch changes detected, invalidated file cache in %v", time.Since(invalidateStart)) + if change.fileChanges.IncludesWatchChangeOutsideNodeModules { + fs.invalidateCache() + logger.Logf("Excessive watch changes detected, invalidated file cache in %v", time.Since(invalidateStart)) + } else { + fs.invalidateNodeModulesCache() + logger.Logf("npm install detected, invalidated node_modules cache in %v", time.Since(invalidateStart)) + } } else { fs.markDirtyFiles(change.fileChanges) } diff --git a/internal/project/snapshotfs.go b/internal/project/snapshotfs.go index 89ec02b952..76882926f2 100644 --- a/internal/project/snapshotfs.go +++ b/internal/project/snapshotfs.go @@ -1,6 +1,7 @@ package project import ( + "strings" "sync" "github.com/microsoft/typescript-go/internal/collections" @@ -131,6 +132,17 @@ func (s *snapshotFSBuilder) invalidateCache() { }) } +func (s *snapshotFSBuilder) invalidateNodeModulesCache() { + s.diskFiles.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *diskFile]) bool { + if strings.Contains(string(entry.Key()), "/node_modules/") { + entry.Change(func(file *diskFile) { + file.needsReload = true + }) + } + return true + }) +} + func (s *snapshotFSBuilder) markDirtyFiles(change FileChangeSummary) { for uri := range change.Changed.Keys() { path := s.toPath(uri.FileName()) From 1234750c3468b35e785f3e98925ef7019e82c1c9 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Mon, 13 Oct 2025 12:15:36 -0700 Subject: [PATCH 4/4] Avoid invalidating anything if present changes have no effect --- internal/project/bulkcache_test.go | 334 +++++++++++------- internal/project/configfileregistrybuilder.go | 106 +++--- internal/project/filechange.go | 6 +- internal/project/projectcollectionbuilder.go | 12 + internal/project/snapshot.go | 7 +- internal/project/snapshotfs.go | 16 + 6 files changed, 311 insertions(+), 170 deletions(-) diff --git a/internal/project/bulkcache_test.go b/internal/project/bulkcache_test.go index 28585cf12a..e9b116f7c8 100644 --- a/internal/project/bulkcache_test.go +++ b/internal/project/bulkcache_test.go @@ -25,109 +25,154 @@ func TestBulkCacheInvalidation(t *testing.T) { "/project/tsconfig.json": `{ "compilerOptions": { "strict": true, - "target": "es2015" + "target": "es2015", + "types": ["node"] }, "include": ["src/**/*"] }`, "/project/src/index.ts": `import { helper } from "./helper"; console.log(helper);`, "/project/src/helper.ts": `export const helper = "test";`, "/project/src/utils/lib.ts": `export function util() { return "util"; }`, + + "/project/node_modules/@types/node/index.d.ts": `import "./fs"; import "./console";`, + "/project/node_modules/@types/node/fs.d.ts": ``, + "/project/node_modules/@types/node/console.d.ts": ``, } t.Run("large number of node_modules changes invalidates only node_modules cache", func(t *testing.T) { t.Parallel() - session, utils := projecttestutil.Setup(baseFiles) + test := func(t *testing.T, fileEvents []*lsproto.FileEvent, expectNodeModulesInvalidation bool) { + session, utils := projecttestutil.Setup(baseFiles) - // Open a file to create the project - session.DidOpenFile(context.Background(), "file:///project/src/index.ts", 1, baseFiles["/project/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + // Open a file to create the project + session.DidOpenFile(context.Background(), "file:///project/src/index.ts", 1, baseFiles["/project/src/index.ts"].(string), lsproto.LanguageKindTypeScript) - // Get initial snapshot and verify config - ls, err := session.GetLanguageService(context.Background(), "file:///project/src/index.ts") - assert.NilError(t, err) - assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetES2015) - - snapshotBefore, release := session.Snapshot() - defer release() - configBefore := snapshotBefore.ConfigFileRegistry + // Get initial snapshot and verify config + ls, err := session.GetLanguageService(context.Background(), "file:///project/src/index.ts") + assert.NilError(t, err) + assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetES2015) - // Create excessive changes in node_modules (1001 changes to exceed threshold) - fileEvents := generateFileEvents(1001, "file:///project/node_modules/generated/file%d.js", lsproto.FileChangeTypeCreated) + snapshotBefore, release := session.Snapshot() + defer release() + configBefore := snapshotBefore.ConfigFileRegistry - // Update tsconfig.json on disk to test that configs don't get reloaded - err = utils.FS().WriteFile("/project/tsconfig.json", `{ + // Update tsconfig.json on disk to test that configs don't get reloaded + err = utils.FS().WriteFile("/project/tsconfig.json", `{ "compilerOptions": { "strict": true, - "target": "esnext" + "target": "esnext", + "types": ["node"] }, "include": ["src/**/*"] }`, false) - assert.NilError(t, err) + assert.NilError(t, err) + // Update fs.d.ts in node_modules + err = utils.FS().WriteFile("/project/node_modules/@types/node/fs.d.ts", "new text", false) + assert.NilError(t, err) - // Process the excessive node_modules changes - session.DidChangeWatchedFiles(context.Background(), fileEvents) + // Process the excessive node_modules changes + session.DidChangeWatchedFiles(context.Background(), fileEvents) - // Get language service again to trigger snapshot update - ls, err = session.GetLanguageService(context.Background(), "file:///project/src/index.ts") - assert.NilError(t, err) + // Get language service again to trigger snapshot update + ls, err = session.GetLanguageService(context.Background(), "file:///project/src/index.ts") + assert.NilError(t, err) - snapshotAfter, release := session.Snapshot() - defer release() - configAfter := snapshotAfter.ConfigFileRegistry + snapshotAfter, release := session.Snapshot() + defer release() + configAfter := snapshotAfter.ConfigFileRegistry - // Config should NOT have been reloaded (target should remain ES2015, not esnext) - assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetES2015, "Config should not have been reloaded for node_modules-only changes") + // Config should NOT have been reloaded (target should remain ES2015, not esnext) + assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetES2015, "Config should not have been reloaded for node_modules-only changes") - // Config registry should be the same instance (no configs reloaded) - assert.Equal(t, configBefore, configAfter, "Config registry should not have changed for node_modules-only changes") + // Config registry should be the same instance (no configs reloaded) + assert.Equal(t, configBefore, configAfter, "Config registry should not have changed for node_modules-only changes") + + fsDtsText := snapshotAfter.GetFile("/project/node_modules/@types/node/fs.d.ts").Content() + if expectNodeModulesInvalidation { + assert.Equal(t, fsDtsText, "new text") + } else { + assert.Equal(t, fsDtsText, "") + } + } + + t.Run("with file existing in cache", func(t *testing.T) { + t.Parallel() + fileEvents := generateFileEvents(1001, "file:///project/node_modules/generated/file%d.js", lsproto.FileChangeTypeCreated) + // Include two files in the program to trigger a full program creation. + // Exclude fs.d.ts to show that its content still gets invalidated. + fileEvents = append(fileEvents, &lsproto.FileEvent{ + Uri: "file:///project/node_modules/@types/node/index.d.ts", + Type: lsproto.FileChangeTypeChanged, + }, &lsproto.FileEvent{ + Uri: "file:///project/node_modules/@types/node/console.d.ts", + Type: lsproto.FileChangeTypeChanged, + }) + + test(t, fileEvents, true) + }) + + t.Run("without file existing in cache", func(t *testing.T) { + t.Parallel() + fileEvents := generateFileEvents(1001, "file:///project/node_modules/generated/file%d.js", lsproto.FileChangeTypeCreated) + test(t, fileEvents, false) + }) }) - t.Run("large number of changes outside node_modules causes config reload", func(t *testing.T) { + t.Run("large number of changes outside node_modules", func(t *testing.T) { t.Parallel() - session, utils := projecttestutil.Setup(baseFiles) - - // Open a file to create the project - session.DidOpenFile(context.Background(), "file:///project/src/index.ts", 1, baseFiles["/project/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + test := func(t *testing.T, fileEvents []*lsproto.FileEvent, expectConfigReload bool) { + session, utils := projecttestutil.Setup(baseFiles) - // Get initial state - ls, err := session.GetLanguageService(context.Background(), "file:///project/src/index.ts") - assert.NilError(t, err) - assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetES2015) + // Open a file to create the project + session.DidOpenFile(context.Background(), "file:///project/src/index.ts", 1, baseFiles["/project/src/index.ts"].(string), lsproto.LanguageKindTypeScript) - snapshotBefore, release := session.Snapshot() - defer release() + // Get initial state + ls, err := session.GetLanguageService(context.Background(), "file:///project/src/index.ts") + assert.NilError(t, err) + assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetES2015) - // Update tsconfig.json on disk - err = utils.FS().WriteFile("/project/tsconfig.json", `{ + // Update tsconfig.json on disk + err = utils.FS().WriteFile("/project/tsconfig.json", `{ "compilerOptions": { "strict": true, - "target": "esnext" + "target": "esnext", + "types": ["node"] }, "include": ["src/**/*"] }`, false) - assert.NilError(t, err) - // Add root file - err = utils.FS().WriteFile("/project/src/rootFile.ts", `console.log("root file")`, false) - assert.NilError(t, err) - - // Create excessive changes outside node_modules (1001 changes to exceed threshold) - fileEvents := generateFileEvents(1001, "file:///project/generated/file%d.ts", lsproto.FileChangeTypeCreated) - - // Process the excessive changes outside node_modules - session.DidChangeWatchedFiles(context.Background(), fileEvents) - - // Get language service again to trigger snapshot update - ls, err = session.GetLanguageService(context.Background(), "file:///project/src/index.ts") - assert.NilError(t, err) - - snapshotAfter, release := session.Snapshot() - defer release() - - // Config SHOULD have been reloaded (target should now be esnext and new root file present) - assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetESNext, "Config should have been reloaded for changes outside node_modules") - assert.Check(t, ls.GetProgram().GetSourceFile("/project/src/rootFile.ts") != nil, "New root file should be present") + assert.NilError(t, err) + // Add root file + err = utils.FS().WriteFile("/project/src/rootFile.ts", `console.log("root file")`, false) + assert.NilError(t, err) + + session.DidChangeWatchedFiles(context.Background(), fileEvents) + ls, err = session.GetLanguageService(context.Background(), "file:///project/src/index.ts") + assert.NilError(t, err) + + if expectConfigReload { + assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetESNext, "Config should have been reloaded for changes outside node_modules") + assert.Check(t, ls.GetProgram().GetSourceFile("/project/src/rootFile.ts") != nil, "New root file should be present") + } else { + assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetES2015, "Config should not have been reloaded for changes outside node_modules") + assert.Check(t, ls.GetProgram().GetSourceFile("/project/src/rootFile.ts") == nil, "New root file should not be present") + } + } + + t.Run("with event matching include glob", func(t *testing.T) { + t.Parallel() + fileEvents := generateFileEvents(1001, "file:///project/generated/file%d.ts", lsproto.FileChangeTypeCreated) + fileEvents = append(fileEvents, &lsproto.FileEvent{ + Uri: "file:///project/src/rootFile.ts", + Type: lsproto.FileChangeTypeCreated, + }) + test(t, fileEvents, true) + }) - // Snapshots should be different - assert.Assert(t, snapshotBefore != snapshotAfter, "Snapshot should have changed after bulk invalidation outside node_modules") + t.Run("without event matching include glob", func(t *testing.T) { + t.Parallel() + fileEvents := generateFileEvents(1001, "file:///project/generated/file%d.ts", lsproto.FileChangeTypeCreated) + test(t, fileEvents, false) + }) }) t.Run("large number of changes outside node_modules causes project reevaluation", func(t *testing.T) { @@ -157,8 +202,7 @@ func TestBulkCacheInvalidation(t *testing.T) { }`, false) assert.NilError(t, err) - // Create excessive changes outside node_modules to trigger bulk invalidation - // This simulates the scenario where the nested tsconfig creation was missed in the flood of changes + // Create excessive changes to trigger bulk invalidation fileEvents := generateFileEvents(1001, "file:///project/src/generated/file%d.ts", lsproto.FileChangeTypeCreated) // Process the excessive changes - this should trigger project reevaluation @@ -178,14 +222,109 @@ func TestBulkCacheInvalidation(t *testing.T) { assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetESNext, "Should use esnext target from nested config") }) - t.Run("excessive changes only in node_modules does not affect config file names cache", func(t *testing.T) { + t.Run("config file names cache", func(t *testing.T) { t.Parallel() - testConfigFileNamesCacheBehavior(t, "file:///project/node_modules/generated/file%d.js", false, "node_modules changes should not clear config cache") + test := func(t *testing.T, fileEvents []*lsproto.FileEvent, expectConfigDiscovery bool, testName string) { + files := map[string]any{ + "/project/src/index.ts": `console.log("test");`, // No tsconfig initially + } + session, utils := projecttestutil.Setup(files) + + // Open file without tsconfig - should create inferred project + session.DidOpenFile(context.Background(), "file:///project/src/index.ts", 1, files["/project/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + + snapshot, release := session.Snapshot() + defer release() + assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil, "Should have inferred project") + assert.Equal(t, snapshot.GetDefaultProject("file:///project/src/index.ts").Kind, project.KindInferred) + + // Create a tsconfig that would affect this file (simulating a missed creation event) + err := utils.FS().WriteFile("/project/tsconfig.json", `{ + "compilerOptions": { + "strict": true + }, + "include": ["src/**/*"] + }`, false) + assert.NilError(t, err) + + // Process the changes + session.DidChangeWatchedFiles(context.Background(), fileEvents) + + // Get language service to trigger config discovery + _, err = session.GetLanguageService(context.Background(), "file:///project/src/index.ts") + assert.NilError(t, err) + + snapshot, release = session.Snapshot() + defer release() + newProject := snapshot.GetDefaultProject("file:///project/src/index.ts") + + // Check expected behavior + if expectConfigDiscovery { + // Should now use configured project instead of inferred + assert.Equal(t, newProject.Kind, project.KindConfigured, "Should now use configured project after cache invalidation") + assert.Equal(t, newProject.Name(), "/project/tsconfig.json", "Should use the newly discovered tsconfig") + } else { + // Should still use inferred project (config file names cache not cleared) + assert.Assert(t, newProject == snapshot.ProjectCollection.InferredProject(), "Should still use inferred project after node_modules-only changes") + } + } + + t.Run("excessive changes only in node_modules does not affect config file names cache", func(t *testing.T) { + t.Parallel() + fileEvents := generateFileEvents(1001, "file:///project/node_modules/generated/file%d.js", lsproto.FileChangeTypeCreated) + test(t, fileEvents, false, "node_modules changes should not clear config cache") + }) + + t.Run("excessive changes outside node_modules clears config file names cache", func(t *testing.T) { + t.Parallel() + fileEvents := generateFileEvents(1001, "file:///project/src/generated/file%d.ts", lsproto.FileChangeTypeCreated) + // Presence of any tsconfig.json file event triggers rediscovery for config for all open files + fileEvents = append(fileEvents, &lsproto.FileEvent{ + Uri: lsproto.DocumentUri("file:///project/src/generated/tsconfig.json"), + Type: lsproto.FileChangeTypeCreated, + }) + test(t, fileEvents, true, "non-node_modules changes should clear config cache") + }) }) - t.Run("excessive changes outside node_modules clears config file names cache", func(t *testing.T) { + // Simulate external build tool changing files in dist/ (not included by any project) + t.Run("excessive changes in dist folder do not invalidate", func(t *testing.T) { t.Parallel() - testConfigFileNamesCacheBehavior(t, "file:///project/generated/file%d.ts", true, "non-node_modules changes should clear config cache") + files := map[string]any{ + "/project/src/index.ts": `console.log("test");`, // No tsconfig initially + } + session, utils := projecttestutil.Setup(files) + + // Open file without tsconfig - should create inferred project + session.DidOpenFile(context.Background(), "file:///project/src/index.ts", 1, files["/project/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, snapshot.GetDefaultProject("file:///project/src/index.ts").Kind, project.KindInferred) + + // Create a tsconfig that would affect this file (simulating a missed creation event) + // This should NOT be discovered after dist-folder changes + err := utils.FS().WriteFile("/project/tsconfig.json", `{ + "compilerOptions": { + "strict": true + }, + "include": ["src/**/*"] + }`, false) + assert.NilError(t, err) + + // Create excessive changes in dist folder only + fileEvents := generateFileEvents(1001, "file:///project/dist/generated/file%d.js", lsproto.FileChangeTypeCreated) + session.DidChangeWatchedFiles(context.Background(), fileEvents) + + // File should still use inferred project (config file names cache NOT cleared for dist changes) + _, err = session.GetLanguageService(context.Background(), "file:///project/src/index.ts") + assert.NilError(t, err) + + snapshot, release = session.Snapshot() + defer release() + newProject := snapshot.GetDefaultProject("file:///project/src/index.ts") + assert.Equal(t, newProject.Kind, project.KindInferred, "dist-folder changes should not cause config discovery") + // This assertion will fail until we implement logic to ignore dist folder changes }) } @@ -200,52 +339,3 @@ func generateFileEvents(count int, pathTemplate string, changeType lsproto.FileC } return events } - -// Helper function to test config file names cache behavior -func testConfigFileNamesCacheBehavior(t *testing.T, eventPathTemplate string, expectConfigDiscovery bool, testName string) { - files := map[string]any{ - "/project/src/index.ts": `console.log("test");`, // No tsconfig initially - } - session, utils := projecttestutil.Setup(files) - - // Open file without tsconfig - should create inferred project - session.DidOpenFile(context.Background(), "file:///project/src/index.ts", 1, files["/project/src/index.ts"].(string), lsproto.LanguageKindTypeScript) - - snapshot, release := session.Snapshot() - defer release() - assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil, "Should have inferred project") - assert.Equal(t, snapshot.GetDefaultProject("file:///project/src/index.ts").Kind, project.KindInferred) - - // Create a tsconfig that would affect this file (simulating a missed creation event) - err := utils.FS().WriteFile("/project/tsconfig.json", `{ - "compilerOptions": { - "strict": true - }, - "include": ["src/**/*"] - }`, false) - assert.NilError(t, err) - - // Create excessive changes to trigger bulk invalidation - fileEvents := generateFileEvents(1001, eventPathTemplate, lsproto.FileChangeTypeCreated) - - // Process the changes - session.DidChangeWatchedFiles(context.Background(), fileEvents) - - // Get language service to trigger config discovery - _, err = session.GetLanguageService(context.Background(), "file:///project/src/index.ts") - assert.NilError(t, err) - - snapshot, release = session.Snapshot() - defer release() - newProject := snapshot.GetDefaultProject("file:///project/src/index.ts") - - // Check expected behavior - if expectConfigDiscovery { - // Should now use configured project instead of inferred - assert.Equal(t, newProject.Kind, project.KindConfigured, "Should now use configured project after cache invalidation") - assert.Equal(t, newProject.Name(), "/project/tsconfig.json", "Should use the newly discovered tsconfig") - } else { - // Should still use inferred project (config file names cache not cleared) - assert.Assert(t, newProject == snapshot.ProjectCollection.InferredProject(), "Should still use inferred project after node_modules-only changes") - } -} diff --git a/internal/project/configfileregistrybuilder.go b/internal/project/configfileregistrybuilder.go index 3420f7c396..c8fc308500 100644 --- a/internal/project/configfileregistrybuilder.go +++ b/internal/project/configfileregistrybuilder.go @@ -317,43 +317,48 @@ func (r changeFileResult) IsEmpty() bool { return len(r.affectedProjects) == 0 && len(r.affectedFiles) == 0 } -func (c *configFileRegistryBuilder) DidChangeFiles(summary FileChangeSummary, logger *logging.LogTree) changeFileResult { +func (c *configFileRegistryBuilder) invalidateCache(logger *logging.LogTree) changeFileResult { var affectedProjects map[tspath.Path]struct{} var affectedFiles map[tspath.Path]struct{} - if summary.HasExcessiveWatchChanges() && summary.IncludesWatchChangeOutsideNodeModules { - logger.Log("Too many files changed; marking all configs for reload") - c.configFileNames.Range(func(entry *dirty.MapEntry[tspath.Path, *configFileNames]) bool { - if affectedFiles == nil { - affectedFiles = make(map[tspath.Path]struct{}) - } - affectedFiles[entry.Key()] = struct{}{} - return true - }) - c.configFileNames.Clear() + logger.Log("Too many files changed; marking all configs for reload") + c.configFileNames.Range(func(entry *dirty.MapEntry[tspath.Path, *configFileNames]) bool { + if affectedFiles == nil { + affectedFiles = make(map[tspath.Path]struct{}) + } + affectedFiles[entry.Key()] = struct{}{} + return true + }) + c.configFileNames.Clear() - c.configs.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *configFileEntry]) bool { - entry.Change(func(entry *configFileEntry) { - affectedProjects = core.CopyMapInto(affectedProjects, entry.retainingProjects) - if entry.pendingReload != PendingReloadFull { - text, ok := c.FS().ReadFile(entry.fileName) - if !ok || text != entry.commandLine.ConfigFile.SourceFile.Text() { - entry.pendingReload = PendingReloadFull - } else { - entry.pendingReload = PendingReloadFileNames - } + c.configs.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *configFileEntry]) bool { + entry.Change(func(entry *configFileEntry) { + affectedProjects = core.CopyMapInto(affectedProjects, entry.retainingProjects) + if entry.pendingReload != PendingReloadFull { + text, ok := c.FS().ReadFile(entry.fileName) + if !ok || text != entry.commandLine.ConfigFile.SourceFile.Text() { + entry.pendingReload = PendingReloadFull + } else { + entry.pendingReload = PendingReloadFileNames } - }) - return true + } }) + return true + }) - return changeFileResult{ - affectedProjects: affectedProjects, - affectedFiles: affectedFiles, - } + return changeFileResult{ + affectedProjects: affectedProjects, + affectedFiles: affectedFiles, } +} + +func (c *configFileRegistryBuilder) DidChangeFiles(summary FileChangeSummary, logger *logging.LogTree) changeFileResult { + var affectedProjects map[tspath.Path]struct{} + var affectedFiles map[tspath.Path]struct{} + var shouldInvalidateCache bool logger.Log("Summarizing file changes") + hasExcessiveChanges := summary.HasExcessiveWatchEvents() && summary.IncludesWatchChangeOutsideNodeModules createdFiles := make(map[tspath.Path]string, summary.Created.Len()) createdOrDeletedFiles := make(map[tspath.Path]struct{}, summary.Created.Len()+summary.Deleted.Len()) createdOrChangedOrDeletedFiles := make(map[tspath.Path]struct{}, summary.Changed.Len()+summary.Deleted.Len()) @@ -399,6 +404,10 @@ func (c *configFileRegistryBuilder) DidChangeFiles(summary FileChangeSummary, lo logger.Log("Checking if any changed files are config files") for path := range createdOrChangedOrDeletedFiles { if entry, ok := c.configs.Load(path); ok { + if hasExcessiveChanges { + return c.invalidateCache(logger) + } + affectedProjects = core.CopyMapInto(affectedProjects, c.handleConfigChange(entry, logger)) for extendingConfigPath := range entry.Value().retainingConfigs { if extendingConfigEntry, ok := c.configs.Load(extendingConfigPath); ok { @@ -410,6 +419,27 @@ func (c *configFileRegistryBuilder) DidChangeFiles(summary FileChangeSummary, lo } } + // Handle created/deleted files named "tsconfig.json" or "jsconfig.json" + for path := range createdOrDeletedFiles { + baseName := tspath.GetBaseFileName(string(path)) + if baseName == "tsconfig.json" || baseName == "jsconfig.json" { + if hasExcessiveChanges { + return c.invalidateCache(logger) + } + directoryPath := path.GetDirectoryPath() + c.configFileNames.Range(func(entry *dirty.MapEntry[tspath.Path, *configFileNames]) bool { + if directoryPath.ContainsPath(entry.Key()) { + if affectedFiles == nil { + affectedFiles = make(map[tspath.Path]struct{}) + } + affectedFiles[entry.Key()] = struct{}{} + entry.Delete() + } + return true + }) + } + } + // Handle possible root file creation if len(createdFiles) > 0 { c.configs.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *configFileEntry]) bool { @@ -433,27 +463,13 @@ func (c *configFileRegistryBuilder) DidChangeFiles(summary FileChangeSummary, lo } maps.Copy(affectedProjects, config.retainingProjects) logger.Logf("Root files for config %s changed", entry.Key()) + shouldInvalidateCache = hasExcessiveChanges }, ) - return true + return !shouldInvalidateCache }) - } - - // Handle created/deleted files named "tsconfig.json" or "jsconfig.json" - for path := range createdOrDeletedFiles { - baseName := tspath.GetBaseFileName(string(path)) - if baseName == "tsconfig.json" || baseName == "jsconfig.json" { - directoryPath := path.GetDirectoryPath() - c.configFileNames.Range(func(entry *dirty.MapEntry[tspath.Path, *configFileNames]) bool { - if directoryPath.ContainsPath(entry.Key()) { - if affectedFiles == nil { - affectedFiles = make(map[tspath.Path]struct{}) - } - affectedFiles[entry.Key()] = struct{}{} - entry.Delete() - } - return true - }) + if shouldInvalidateCache { + return c.invalidateCache(logger) } } diff --git a/internal/project/filechange.go b/internal/project/filechange.go index 0ddf238a56..e23184bff5 100644 --- a/internal/project/filechange.go +++ b/internal/project/filechange.go @@ -54,6 +54,10 @@ func (f FileChangeSummary) IsEmpty() bool { return f.Opened == "" && len(f.Closed) == 0 && f.Changed.Len() == 0 && f.Created.Len() == 0 && f.Deleted.Len() == 0 } -func (f FileChangeSummary) HasExcessiveWatchChanges() bool { +func (f FileChangeSummary) HasExcessiveWatchEvents() bool { return f.Created.Len()+f.Deleted.Len()+f.Changed.Len() > excessiveChangeThreshold } + +func (f FileChangeSummary) HasExcessiveNonCreateWatchEvents() bool { + return f.Deleted.Len()+f.Changed.Len() > excessiveChangeThreshold +} diff --git a/internal/project/projectcollectionbuilder.go b/internal/project/projectcollectionbuilder.go index e2af3e7462..c7ed8bd2f9 100644 --- a/internal/project/projectcollectionbuilder.go +++ b/internal/project/projectcollectionbuilder.go @@ -186,6 +186,18 @@ func (b *projectCollectionBuilder) DidChangeFiles(summary FileChangeSummary, log logChangeFileResult(configChangeResult, configChangeLogger) b.forEachProject(func(entry dirty.Value[*Project]) bool { + // Only consider change/delete; creates are handled by the config file registry + if summary.HasExcessiveNonCreateWatchEvents() { + entry.Change(func(p *Project) { + p.dirty = true + p.dirtyFilePath = "" + if logger != nil { + logger.Logf("Marking project as dirty due to excessive watch changes: %s", p.configFilePath) + } + }) + return true + } + // Handle closed and changed files b.markFilesChanged(entry, changedFiles, lsproto.FileChangeTypeChanged, logger) if entry.Value().Kind == KindInferred && len(summary.Closed) > 0 { diff --git a/internal/project/snapshot.go b/internal/project/snapshot.go index 3de8646f65..352e496b39 100644 --- a/internal/project/snapshot.go +++ b/internal/project/snapshot.go @@ -174,9 +174,12 @@ func (s *Snapshot) Clone(ctx context.Context, change SnapshotChange, overlays ma start := time.Now() fs := newSnapshotFSBuilder(session.fs.fs, overlays, s.fs.diskFiles, session.options.PositionEncoding, s.toPath) - if change.fileChanges.HasExcessiveWatchChanges() { + if change.fileChanges.HasExcessiveWatchEvents() { invalidateStart := time.Now() - if change.fileChanges.IncludesWatchChangeOutsideNodeModules { + if !fs.watchChangesOverlapCache(change.fileChanges) { + change.fileChanges.Changed = collections.Set[lsproto.DocumentUri]{} + change.fileChanges.Deleted = collections.Set[lsproto.DocumentUri]{} + } else if change.fileChanges.IncludesWatchChangeOutsideNodeModules { fs.invalidateCache() logger.Logf("Excessive watch changes detected, invalidated file cache in %v", time.Since(invalidateStart)) } else { diff --git a/internal/project/snapshotfs.go b/internal/project/snapshotfs.go index 76882926f2..1537b8d0aa 100644 --- a/internal/project/snapshotfs.go +++ b/internal/project/snapshotfs.go @@ -123,6 +123,22 @@ func (s *snapshotFSBuilder) GetFileByPath(fileName string, path tspath.Path) Fil return entry.Value() } +func (s *snapshotFSBuilder) watchChangesOverlapCache(change FileChangeSummary) bool { + for uri := range change.Changed.Keys() { + path := s.toPath(uri.FileName()) + if _, ok := s.diskFiles.Load(path); ok { + return true + } + } + for uri := range change.Deleted.Keys() { + path := s.toPath(uri.FileName()) + if _, ok := s.diskFiles.Load(path); ok { + return true + } + } + return false +} + func (s *snapshotFSBuilder) invalidateCache() { s.diskFiles.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *diskFile]) bool { entry.Change(func(file *diskFile) {