diff --git a/internal/project/bulkcache_test.go b/internal/project/bulkcache_test.go new file mode 100644 index 0000000000..e9b116f7c8 --- /dev/null +++ b/internal/project/bulkcache_test.go @@ -0,0 +1,341 @@ +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", + "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() + 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) + + // 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 + + // 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", + "types": ["node"] + }, + "include": ["src/**/*"] + }`, false) + 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) + + // 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") + + 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", func(t *testing.T) { + t.Parallel() + test := func(t *testing.T, fileEvents []*lsproto.FileEvent, expectConfigReload 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) + + // 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", `{ + "compilerOptions": { + "strict": true, + "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) + + 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) + }) + + 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) { + 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 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 + 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("config file names cache", func(t *testing.T) { + t.Parallel() + 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") + }) + }) + + // 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() + 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 + }) +} + +// 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 +} 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..c8fc308500 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 { @@ -317,10 +317,48 @@ func (r changeFileResult) IsEmpty() bool { return len(r.affectedProjects) == 0 && len(r.affectedFiles) == 0 } +func (c *configFileRegistryBuilder) invalidateCache(logger *logging.LogTree) changeFileResult { + var affectedProjects map[tspath.Path]struct{} + var affectedFiles map[tspath.Path]struct{} + + 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, + } +} + 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()) @@ -366,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 { @@ -377,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 { @@ -400,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) } } @@ -465,7 +514,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 +523,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 +533,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 +546,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/filechange.go b/internal/project/filechange.go index 2f702add0e..e23184bff5 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 ( @@ -18,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 @@ -38,8 +44,20 @@ 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 { return f.Opened == "" && len(f.Closed) == 0 && f.Changed.Len() == 0 && f.Created.Len() == 0 && f.Deleted.Len() == 0 } + +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/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/projectcollectionbuilder.go b/internal/project/projectcollectionbuilder.go index c898292bea..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 { @@ -617,7 +629,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 +667,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, diff --git a/internal/project/snapshot.go b/internal/project/snapshot.go index 8d41e426c5..352e496b39 100644 --- a/internal/project/snapshot.go +++ b/internal/project/snapshot.go @@ -174,7 +174,21 @@ 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.HasExcessiveWatchEvents() { + invalidateStart := time.Now() + 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 { + fs.invalidateNodeModulesCache() + logger.Logf("npm install detected, invalidated node_modules 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..1537b8d0aa 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" @@ -122,6 +123,42 @@ 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) { + file.needsReload = true + }) + return true + }) +} + +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())