From 6f85a13fce045874acfee47cbb9c34dc47fd9966 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:12:18 -0700 Subject: [PATCH 01/12] Restrict cached typings to inferred packages --- tsc/internal/project/ata/discovertypings.go | 2 +- tsc/internal/project/ata/discovertypings_test.go | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/tsc/internal/project/ata/discovertypings.go b/tsc/internal/project/ata/discovertypings.go index d09f0fa68b4c1..0fcc5d8a40dd3 100644 --- a/tsc/internal/project/ata/discovertypings.go +++ b/tsc/internal/project/ata/discovertypings.go @@ -86,7 +86,7 @@ func DiscoverTypings( // Add the cached typing locations for inferred typings that are already installed packageNameToTypingLocation.Range(func(name string, typing *CachedTyping) bool { registryEntry := typesRegistry[name] - if inferredTypings[name] == "" && registryEntry != nil && isTypingUpToDate(typing, registryEntry) { + if inferred, ok := inferredTypings[name]; ok && inferred == "" && registryEntry != nil && isTypingUpToDate(typing, registryEntry) { inferredTypings[name] = typing.TypingsLocation } return true diff --git a/tsc/internal/project/ata/discovertypings_test.go b/tsc/internal/project/ata/discovertypings_test.go index bff1aef4c1cd4..de43c7fb235ad 100644 --- a/tsc/internal/project/ata/discovertypings_test.go +++ b/tsc/internal/project/ata/discovertypings_test.go @@ -85,8 +85,9 @@ func TestDiscoverTypings(t *testing.T) { t.Parallel() logger := logging.NewLogTree("DiscoverTypings") files := map[string]string{ - "/home/src/projects/project/app.js": "", - "/home/src/projects/project/node.d.ts": "", + "/home/src/projects/project/app.js": "", + "/home/src/projects/project/jquery.d.ts": "", + "/home/src/projects/project/node.d.ts": "", } fs := vfstest.FromMap(files, tspath.CaseInsensitive /*caseSensitivity*/) cache := collections.SyncMap[string, *ata.CachedTyping]{} @@ -95,6 +96,10 @@ func TestDiscoverTypings(t *testing.T) { TypingsLocation: "/home/src/projects/project/node.d.ts", Version: &version, }) + cache.Store("jquery", &ata.CachedTyping{ + TypingsLocation: "/home/src/projects/project/jquery.d.ts", + Version: &version, + }) unresolvedImports := collections.NewSetFromItems("fs", "bar") cachedTypingPaths, newTypingNames, filesToWatch := ata.DiscoverTypings( fs, @@ -108,7 +113,8 @@ func TestDiscoverTypings(t *testing.T) { "/home/src/projects/project", &cache, map[string]map[string]string{ - "node": projecttestutil.TypesRegistryConfig(), + "jquery": projecttestutil.TypesRegistryConfig(), + "node": projecttestutil.TypesRegistryConfig(), }, ) assert.DeepEqual(t, cachedTypingPaths, []tspath.RootedFilePath{ From e9846d37c4dd8991d10073545bd0282ed1eaaf7b Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:17:01 -0700 Subject: [PATCH 02/12] Preserve ATA state across project lifecycles --- tsc/internal/project/ata/ata.go | 2 + tsc/internal/project/ata/ata_test.go | 176 ++++++++++ tsc/internal/project/project.go | 130 +++++++- tsc/internal/project/projectcollection.go | 34 +- .../project/projectcollectionbuilder.go | 303 ++++++++++++++++-- tsc/internal/project/session.go | 59 +++- tsc/internal/project/snapshot.go | 34 +- 7 files changed, 658 insertions(+), 80 deletions(-) diff --git a/tsc/internal/project/ata/ata.go b/tsc/internal/project/ata/ata.go index ec074fa3e2095..08daefbf128ea 100644 --- a/tsc/internal/project/ata/ata.go +++ b/tsc/internal/project/ata/ata.go @@ -27,6 +27,8 @@ type TypingsInfo struct { func (ti TypingsInfo) Equals(other TypingsInfo) bool { return ti.TypeAcquisition.Equals(other.TypeAcquisition) && ti.CompilerOptions.GetAllowJS() == other.CompilerOptions.GetAllowJS() && + (ti.CompilerOptions.Types == nil) == (other.CompilerOptions.Types == nil) && + slices.Equal(ti.CompilerOptions.Types, other.CompilerOptions.Types) && ti.UnresolvedImports.Equals(other.UnresolvedImports) } diff --git a/tsc/internal/project/ata/ata_test.go b/tsc/internal/project/ata/ata_test.go index 258788185c9c0..20ff0cc5024d0 100644 --- a/tsc/internal/project/ata/ata_test.go +++ b/tsc/internal/project/ata/ata_test.go @@ -3,16 +3,30 @@ package ata_test import ( "context" "slices" + "sync" "testing" + "time" "github.com/microsoft/TypeScript/tsc/internal/bundled" + "github.com/microsoft/TypeScript/tsc/internal/collections" "github.com/microsoft/TypeScript/tsc/internal/ls/lsutil" "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" "github.com/microsoft/TypeScript/tsc/internal/project" "github.com/microsoft/TypeScript/tsc/internal/testutil/projecttestutil" + "github.com/microsoft/TypeScript/tsc/internal/tspath" "gotest.tools/v3/assert" ) +func waitForInstall(t *testing.T, installStarted <-chan struct{}) { + t.Helper() + select { + case <-installStarted: + return + case <-time.After(10 * time.Second): + t.Fatal("timed out waiting for typings installation to start") + } +} + func TestATA(t *testing.T) { t.Parallel() if !bundled.Embedded { @@ -89,6 +103,168 @@ func TestATA(t *testing.T) { assert.Equal(t, len(utils.Client().RefreshDiagnosticsCalls()), 1) }) + t.Run("configured project ignores ATA results from before manifest invalidation", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/tsconfig.json": `{ + "compilerOptions": { "allowJs": true }, + "typeAcquisition": { "enable": true }, + }`, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } + }`, + } + + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "commander": `declare const commander: { x: number }`, + "jquery": `declare const $: { x: number }`, + }, + }) + + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + packageJSONURI := lsproto.DocumentUri("file:///user/username/projects/project/package.json") + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var releaseInstallOnce sync.Once + release := func() { releaseInstallOnce.Do(func() { close(releaseInstall) }) } + defer release() + utils.NpmExecutor().NpmInstallFunc = func(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + if slices.Contains(args, "@types/commander@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(ctx, cwd, args) + } + + err = utils.FS().WriteFile(packageJSONURI.FileName(), `{ + "name": "test", + "dependencies": { + "commander": "^14.0.0", + "jquery": "^3.1.0" + } + }`) + assert.NilError(t, err) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: packageJSONURI, + Type: lsproto.FileChangeTypeChanged, + }}) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + waitForInstall(t, installStarted) + + session.Configure(lsutil.ParseUserPreferences(map[string]any{ + "js/ts": map[string]any{ + "tsserver": map[string]any{ + "automaticTypeAcquisition": map[string]any{ + "enabled": false, + }, + }, + }, + })) + err = utils.FS().WriteFile(packageJSONURI.FileName(), files["/user/username/projects/project/package.json"].(string)) + assert.NilError(t, err) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: packageJSONURI, + Type: lsproto.FileChangeTypeChanged, + }}) + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + + release() + session.WaitForBackgroundTasks() + + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + commanderTypesFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/commander/index.d.ts") + assert.Assert(t, commanderTypesFile == nil, "stale commander typings should not be applied after the manifest changes") + }) + + t.Run("configured project ignores ATA results from a previous project lifetime", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/tsconfig.json": `{ + "compilerOptions": { "allowJs": true }, + "typeAcquisition": { "enable": true }, + }`, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "commander": "^14.0.0" + } + }`, + } + + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "commander": `declare const commander: { x: number }`, + }, + }) + + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var releaseInstallOnce sync.Once + release := func() { releaseInstallOnce.Do(func() { close(releaseInstall) }) } + defer release() + utils.NpmExecutor().NpmInstallFunc = func(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + if slices.Contains(args, "@types/commander@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(ctx, cwd, args) + } + + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + waitForInstall(t, installStarted) + + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, &project.APISnapshotRequest{ + CloseFiles: &collections.Set[tspath.PathKey]{}, + }) + assert.NilError(t, err) + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 0) + snapshot.Deref() + + session.Configure(lsutil.ParseUserPreferences(map[string]any{ + "js/ts": map[string]any{ + "tsserver": map[string]any{ + "automaticTypeAcquisition": map[string]any{ + "enabled": false, + }, + }, + }, + })) + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + + release() + session.WaitForBackgroundTasks() + + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + commanderTypesFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/commander/index.d.ts") + assert.Assert(t, commanderTypesFile == nil, "stale commander typings should not be applied to a replacement project") + }) + t.Run("inferred projects", func(t *testing.T) { t.Parallel() diff --git a/tsc/internal/project/project.go b/tsc/internal/project/project.go index f88a09b297a35..fa9f6c73af791 100644 --- a/tsc/internal/project/project.go +++ b/tsc/internal/project/project.go @@ -188,6 +188,84 @@ type Project struct { installedTypingsInfo *ata.TypingsInfo // typingsFiles are the root files added by the typings installer. typingsFiles []tspath.RootedFilePath + // installedTypingsFileNames are the JavaScript files used during the most + // recently completed typings installation. + installedTypingsFileNames []tspath.RootedFilePath + // installedTypingsFilesToWatch are discovery inputs whose changes require + // typings discovery to run again. + installedTypingsFilesToWatch []tspath.RootedPath + // ataInvalidationSnapshotID is the latest snapshot that invalidated this + // project's ATA discovery inputs. + ataInvalidationSnapshotID uint64 +} + +type inferredProjectATAState struct { + installedTypingsInfo *ata.TypingsInfo + installedTypingsFileNames []tspath.RootedFilePath + installedTypingsFilesToWatch []tspath.RootedPath + typingsFiles []tspath.RootedFilePath + typingsWatch *WatchedFiles[PatternsAndIgnored] +} + +func (p *Project) inferredProjectATAState() *inferredProjectATAState { + if p.installedTypingsInfo == nil && len(p.installedTypingsFilesToWatch) == 0 { + return nil + } + return &inferredProjectATAState{ + installedTypingsInfo: p.installedTypingsInfo, + installedTypingsFileNames: slices.Clone(p.installedTypingsFileNames), + installedTypingsFilesToWatch: slices.Clone(p.installedTypingsFilesToWatch), + typingsFiles: slices.Clone(p.typingsFiles), + typingsWatch: p.typingsWatch, + } +} + +func (s *inferredProjectATAState) canApply(project *Project, fs *snapshotFSBuilder, watchEnabled bool) bool { + if s == nil || s.installedTypingsInfo == nil { + return false + } + if !watchEnabled && len(s.installedTypingsFilesToWatch) > 0 { + return false + } + if !s.installedTypingsInfo.Equals(project.ComputeTypingsInfo()) || + !slices.Equal(s.installedTypingsFileNames, project.ComputeTypingsFileNames()) { + return false + } + for _, fileName := range s.typingsFiles { + if !fs.FileExists(fileName, fs.fs.CaseSensitivity().PathKey(fileName.AsPath())) { + return false + } + } + return true +} + +func (s *inferredProjectATAState) apply(project *Project) { + typingsFilesChanged := !slices.Equal(project.typingsFiles, s.typingsFiles) + project.installedTypingsInfo = s.installedTypingsInfo + project.installedTypingsFileNames = slices.Clone(s.installedTypingsFileNames) + project.installedTypingsFilesToWatch = slices.Clone(s.installedTypingsFilesToWatch) + project.typingsFiles = slices.Clone(s.typingsFiles) + project.typingsWatch = s.typingsWatch + if typingsFilesChanged { + project.dirty = true + project.dirtyFilePath = "" + } +} + +func (s *inferredProjectATAState) canApplyWatchState(project *Project, watchEnabled bool) bool { + return s != nil && + s.installedTypingsInfo == nil && + watchEnabled && + slices.Equal(s.installedTypingsFileNames, project.ComputeTypingsFileNames()) +} + +func (s *inferredProjectATAState) applyWatchState(project *Project) { + project.installedTypingsFileNames = slices.Clone(s.installedTypingsFileNames) + project.installedTypingsFilesToWatch = slices.Concat( + slices.Clone(s.installedTypingsFilesToWatch), + core.Map(s.typingsFiles, func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() }), + ) + project.typingsWatch = s.typingsWatch } var _ ls.Project = (*Project)(nil) @@ -218,6 +296,7 @@ func NewInferredProject( logger *logging.LogTree, ) *Project { p := NewProject(inferredProjectID.AsID(), KindInferred, projectDirectory, builder, logger) + p.ataInvalidationSnapshotID = builder.inferredProjectATAInvalidationSnapshotID if compilerOptions == nil { compilerOptions = &core.CompilerOptions{ AllowJs: core.TSTrue, @@ -287,13 +366,14 @@ func NewProject( logger *logging.LogTree, ) *Project { if logger != nil { - logger.Log(fmt.Sprintf("Creating %sProject: %s, currentDirectory: %s", kind.String(), id, projectDirectory)) + logger.Log(fmt.Sprintf("Creating %sProject: %s, projectDirectory: %s", kind.String(), id, projectDirectory)) } project := &Project{ - Kind: kind, - id: id, - projectDirectory: projectDirectory, - dirty: true, + Kind: kind, + id: id, + projectDirectory: projectDirectory, + dirty: true, + ataInvalidationSnapshotID: builder.newSnapshotID, } project.programFilesWatch = NewWatchedFiles( @@ -308,12 +388,7 @@ func NewProject( ), ) if builder.sessionOptions.TypingsLocation != "" { - project.typingsWatch = NewWatchedFiles( - "typings installer files", - lsproto.WatchKindCreate|lsproto.WatchKindChange|lsproto.WatchKindDelete, - lsproto.GetClientCapabilities(builder.ctx).Workspace.DidChangeWatchedFiles.RelativePatternSupport, - core.Identity, - ) + project.typingsWatch = newTypingsWatch(builder) } project.contentMapperWatch = NewWatchedFilesForPaths( "content mapper configuration files for "+string(id), @@ -325,6 +400,15 @@ func NewProject( return project } +func newTypingsWatch(builder *ProjectCollectionBuilder) *WatchedFiles[PatternsAndIgnored] { + return NewWatchedFiles( + "typings installer files", + lsproto.WatchKindCreate|lsproto.WatchKindChange|lsproto.WatchKindDelete, + lsproto.GetClientCapabilities(builder.ctx).Workspace.DidChangeWatchedFiles.RelativePatternSupport, + core.Identity, + ) +} + func (p *Project) CurrentDirectory() tspath.RootedDirectoryPath { return p.projectDirectory } @@ -435,8 +519,11 @@ func (p *Project) Clone() *Project { moduleResolverFactory: p.moduleResolverFactory, moduleResolverID: p.moduleResolverID, - installedTypingsInfo: p.installedTypingsInfo, - typingsFiles: p.typingsFiles, + installedTypingsInfo: p.installedTypingsInfo, + installedTypingsFileNames: p.installedTypingsFileNames, + installedTypingsFilesToWatch: p.installedTypingsFilesToWatch, + typingsFiles: p.typingsFiles, + ataInvalidationSnapshotID: p.ataInvalidationSnapshotID, } } @@ -675,7 +762,8 @@ func (p *Project) ShouldTriggerATA(snapshotID uint64) bool { return true } - return !p.installedTypingsInfo.Equals(p.ComputeTypingsInfo()) + return !p.installedTypingsInfo.Equals(p.ComputeTypingsInfo()) || + !slices.Equal(p.installedTypingsFileNames, p.ComputeTypingsFileNames()) } func (p *Project) ComputeTypingsInfo() ata.TypingsInfo { @@ -685,3 +773,17 @@ func (p *Project) ComputeTypingsInfo() ata.TypingsInfo { UnresolvedImports: p.GetUnresolvedImports(), } } + +func (p *Project) ComputeTypingsFileNames() []tspath.RootedFilePath { + if p.Program == nil { + return nil + } + var fileNames []tspath.RootedFilePath + for _, file := range p.Program.GetSourceFiles() { + if file.FileName().HasJSFileExtension() && !p.Program.IsSourceFileFromExternalLibrary(file) { + fileNames = append(fileNames, file.FileName()) + } + } + slices.Sort(fileNames) + return fileNames +} diff --git a/tsc/internal/project/projectcollection.go b/tsc/internal/project/projectcollection.go index 4f64a3a275095..4cffc70efc389 100644 --- a/tsc/internal/project/projectcollection.go +++ b/tsc/internal/project/projectcollection.go @@ -31,6 +31,12 @@ type ProjectCollection struct { // inferredProject is a fallback project that is used when no configured // project can be found for an open file. inferredProject *Project + // inferredProjectATAState preserves acquired typings while there is no active + // inferred project, so reopening a loose file does not wait for ATA again. + inferredProjectATAState *inferredProjectATAState + // inferredProjectATAInvalidationSnapshotID is the latest snapshot that + // invalidated inferred-project ATA discovery inputs. + inferredProjectATAInvalidationSnapshotID uint64 // apiState tracks the projects and files that API clients have explicitly // opened so they are kept loaded across snapshots. apiState APIState @@ -72,6 +78,16 @@ type apiOpenedFile struct { func (c *ProjectCollection) ConfigFileRegistry() *ConfigFileRegistry { return c.configFileRegistry } +func (c *ProjectCollection) inferredProjectTypingsWatch() *WatchedFiles[PatternsAndIgnored] { + if c.inferredProject != nil { + return c.inferredProject.typingsWatch + } + if c.inferredProjectATAState != nil { + return c.inferredProjectATAState.typingsWatch + } + return nil +} + func (c *ProjectCollection) ConfiguredProject(path tspath.PathKey) *Project { return c.configuredProjects[ConfiguredProjectIDFromPathKey(path)] } @@ -329,14 +345,16 @@ func (c *ProjectCollection) findDefaultConfiguredProjectWorker(path tspath.PathK // clone creates a shallow copy of the project collection. func (c *ProjectCollection) clone() *ProjectCollection { return &ProjectCollection{ - caseSensitivity: c.caseSensitivity, - configFileRegistry: c.configFileRegistry, - configuredProjects: c.configuredProjects, - syntheticProjects: c.syntheticProjects, - openFiles: c.openFiles, - inferredProject: c.inferredProject, - fileDefaultProjects: c.fileDefaultProjects, - apiState: c.apiState, + caseSensitivity: c.caseSensitivity, + configFileRegistry: c.configFileRegistry, + configuredProjects: c.configuredProjects, + syntheticProjects: c.syntheticProjects, + openFiles: c.openFiles, + inferredProject: c.inferredProject, + inferredProjectATAState: c.inferredProjectATAState, + inferredProjectATAInvalidationSnapshotID: c.inferredProjectATAInvalidationSnapshotID, + fileDefaultProjects: c.fileDefaultProjects, + apiState: c.apiState, } } diff --git a/tsc/internal/project/projectcollectionbuilder.go b/tsc/internal/project/projectcollectionbuilder.go index 2a190503e8468..add5c1bb634b3 100644 --- a/tsc/internal/project/projectcollectionbuilder.go +++ b/tsc/internal/project/projectcollectionbuilder.go @@ -55,11 +55,13 @@ type ProjectCollectionBuilder struct { defaultProjectsInvalidated bool openFilesChanged bool - fileDefaultProjects map[tspath.PathKey]ID - configuredProjects *dirty.SyncMap[ConfiguredProjectID, *Project] - syntheticProjects *dirty.SyncMap[SyntheticProjectID, *Project] - inferredProject *dirty.Box[*Project] - createdPrograms []*Project + fileDefaultProjects map[tspath.PathKey]ID + configuredProjects *dirty.SyncMap[ConfiguredProjectID, *Project] + syntheticProjects *dirty.SyncMap[SyntheticProjectID, *Project] + inferredProject *dirty.Box[*Project] + inferredProjectATAState *inferredProjectATAState + inferredProjectATAInvalidationSnapshotID uint64 + createdPrograms []*Project apiState APIState } @@ -85,26 +87,28 @@ func newProjectCollectionBuilder( ) *ProjectCollectionBuilder { openFiles := openFilePaths(overlays) return &ProjectCollectionBuilder{ - ctx: ctx, - fs: fs, - overlays: overlays, - compilerOptionsForInferredProjects: compilerOptionsForInferredProjects, - inferredContentMappers: inferredContentMappers, - inferredContentMapperExtensions: inferredContentMapperExtensions, - sessionOptions: sessionOptions, - parseCache: parseCache, - contentMappedParseCache: contentMappedParseCache, - extendedConfigCache: extendedConfigCache, - contentMapperHost: contentMapperHost, - base: oldProjectCollection, - configFileRegistryBuilder: newConfigFileRegistryBuilder(lsproto.GetClientCapabilities(ctx).Workspace.DidChangeWatchedFiles.RelativePatternSupport, fs, func(path tspath.PathKey) bool { _, ok := overlays[path]; return ok }, oldConfigFileRegistry, extendedConfigCache, newSnapshotID, sessionOptions, customConfigFileName, nil), - newSnapshotID: newSnapshotID, - openFilesChanged: !openFiles.Equals(&oldProjectCollection.openFiles), - configuredProjects: dirty.NewSyncMap(oldProjectCollection.configuredProjects), - syntheticProjects: dirty.NewSyncMap(oldProjectCollection.syntheticProjects), - inferredProject: dirty.NewBox(oldProjectCollection.inferredProject), - apiState: oldAPIState.clone(), - client: client, + ctx: ctx, + fs: fs, + overlays: overlays, + compilerOptionsForInferredProjects: compilerOptionsForInferredProjects, + inferredContentMappers: inferredContentMappers, + inferredContentMapperExtensions: inferredContentMapperExtensions, + sessionOptions: sessionOptions, + parseCache: parseCache, + contentMappedParseCache: contentMappedParseCache, + extendedConfigCache: extendedConfigCache, + contentMapperHost: contentMapperHost, + base: oldProjectCollection, + configFileRegistryBuilder: newConfigFileRegistryBuilder(lsproto.GetClientCapabilities(ctx).Workspace.DidChangeWatchedFiles.RelativePatternSupport, fs, func(path tspath.PathKey) bool { _, ok := overlays[path]; return ok }, oldConfigFileRegistry, extendedConfigCache, newSnapshotID, sessionOptions, customConfigFileName, nil), + newSnapshotID: newSnapshotID, + openFilesChanged: !openFiles.Equals(&oldProjectCollection.openFiles), + configuredProjects: dirty.NewSyncMap(oldProjectCollection.configuredProjects), + syntheticProjects: dirty.NewSyncMap(oldProjectCollection.syntheticProjects), + inferredProject: dirty.NewBox(oldProjectCollection.inferredProject), + inferredProjectATAState: oldProjectCollection.inferredProjectATAState, + inferredProjectATAInvalidationSnapshotID: oldProjectCollection.inferredProjectATAInvalidationSnapshotID, + apiState: oldAPIState.clone(), + client: client, } } @@ -150,6 +154,14 @@ func (b *ProjectCollectionBuilder) Finalize(logger *logging.LogTree) (*ProjectCo ensureCloned() newProjectCollection.inferredProject = newInferredProject } + if b.inferredProjectATAState != b.base.inferredProjectATAState { + ensureCloned() + newProjectCollection.inferredProjectATAState = b.inferredProjectATAState + } + if b.inferredProjectATAInvalidationSnapshotID != b.base.inferredProjectATAInvalidationSnapshotID { + ensureCloned() + newProjectCollection.inferredProjectATAInvalidationSnapshotID = b.inferredProjectATAInvalidationSnapshotID + } configFileRegistry := b.configFileRegistryBuilder.Finalize() if configFileRegistry != b.base.configFileRegistry { @@ -462,6 +474,40 @@ func (b *ProjectCollectionBuilder) DidChangeFiles(summary FileChangeSummary, log return true }) + b.forEachProject(func(entry dirty.Value[*Project]) bool { + projectID := entry.Value().ID() + if entry.ChangeIf( + func(project *Project) bool { + return fileChangeSummaryAffectsTypingsWatch( + summary, + project.installedTypingsFilesToWatch, + project.typingsFiles, + b.sessionOptions.TypingsLocation, + b.fs.fs.CaseSensitivity(), + ) + }, + func(project *Project) { + project.installedTypingsInfo = nil + }, + ) { + b.invalidateProjectATAState(projectID) + if _, inferred := projectID.Inferred(); inferred { + b.clearInferredProjectATAState("typings watch changes", logger) + } + } + return true + }) + + if b.inferredProjectATAState != nil && fileChangeSummaryAffectsTypingsWatch( + summary, + b.inferredProjectATAState.installedTypingsFilesToWatch, + b.inferredProjectATAState.typingsFiles, + b.sessionOptions.TypingsLocation, + b.fs.fs.CaseSensitivity(), + ) { + b.invalidateInferredProjectATAState("typings watch changes", logger) + } + // Handle opened file if summary.Opened != "" || summary.Reopened != "" { fileName := core.FirstNonZero(summary.Opened, summary.Reopened).FileName() @@ -471,6 +517,131 @@ func (b *ProjectCollectionBuilder) DidChangeFiles(summary FileChangeSummary, log } } +func fileChangeSummaryAffectsTypingsWatch( + summary FileChangeSummary, + filesToWatch []tspath.RootedPath, + typingsFiles []tspath.RootedFilePath, + typingsLocation tspath.RootedDirectoryPath, + caseSensitivity tspath.CaseSensitivity, +) bool { + if summary.InvalidateAll { + return true + } + if len(filesToWatch) == 0 { + return false + } + affectsWatch := func(uri lsproto.DocumentUri) bool { + fileName := uri.FileName().AsPath() + return slices.ContainsFunc(slices.Concat(filesToWatch, core.Map(typingsFiles, func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() })), func(watchedPath tspath.RootedPath) bool { + if caseSensitivity.PathKey(watchedPath) == caseSensitivity.PathKey(fileName) || + caseSensitivity.PathKey(watchedPath).ContainsPath(caseSensitivity.PathKey(fileName)) { + return true + } + switch watchedPath.BaseName() { + case "node_modules": + return caseSensitivity.ComparePaths(watchedPath.Directory().ResolveFile("package.json").AsPath(), fileName) == 0 + case "bower_components": + return caseSensitivity.ComparePaths(watchedPath.Directory().ResolveFile("bower.json").AsPath(), fileName) == 0 + } + return false + }) || typingsLocation != "" && caseSensitivity.ContainsPath(typingsLocation, fileName) + } + for uri := range summary.Changed.Keys() { + if affectsWatch(uri) { + return true + } + } + for uri := range summary.Created.Keys() { + if affectsWatch(uri) { + return true + } + } + for uri := range summary.Deleted.Keys() { + if affectsWatch(uri) { + return true + } + } + return false +} + +func (b *ProjectCollectionBuilder) DidInvalidateTypingsWatchState(logger *logging.LogTree) { + b.forEachProject(func(entry dirty.Value[*Project]) bool { + b.invalidateProjectATAState(entry.Value().ID()) + entry.ChangeIf( + func(project *Project) bool { + return project.installedTypingsInfo != nil + }, + func(project *Project) { + project.installedTypingsInfo = nil + }, + ) + return true + }) + b.invalidateInferredProjectATAState("excessive file changes", logger) +} + +func (b *ProjectCollectionBuilder) invalidateProjectATAState(projectID ID) { + setProjectGeneration := func(project dirty.Value[*Project]) { + if project == nil || project.Value() == nil { + return + } + project.ChangeIf( + func(project *Project) bool { + return project.ataInvalidationSnapshotID != b.newSnapshotID + }, + func(project *Project) { + project.ataInvalidationSnapshotID = b.newSnapshotID + }, + ) + } + if _, inferred := projectID.Inferred(); inferred { + b.inferredProjectATAInvalidationSnapshotID = b.newSnapshotID + setProjectGeneration(b.inferredProject) + return + } + if syntheticProjectID, ok := projectID.Synthetic(); ok { + if project, loaded := b.syntheticProjects.Load(syntheticProjectID); loaded { + setProjectGeneration(project) + } + return + } + if configuredProjectID, ok := projectID.Configured(); ok { + if project, loaded := b.configuredProjects.Load(configuredProjectID); loaded { + setProjectGeneration(project) + } + } +} + +func (b *ProjectCollectionBuilder) ataInvalidationSnapshotID(projectID ID) uint64 { + if _, inferred := projectID.Inferred(); inferred { + return b.inferredProjectATAInvalidationSnapshotID + } + if syntheticProjectID, ok := projectID.Synthetic(); ok { + if project, loaded := b.syntheticProjects.Load(syntheticProjectID); loaded { + return project.Value().ataInvalidationSnapshotID + } + return 0 + } + if configuredProjectID, ok := projectID.Configured(); ok { + if project, loaded := b.configuredProjects.Load(configuredProjectID); loaded { + return project.Value().ataInvalidationSnapshotID + } + } + return 0 +} + +func (b *ProjectCollectionBuilder) invalidateInferredProjectATAState(reason string, logger *logging.LogTree) { + b.invalidateProjectATAState(inferredProjectID.AsID()) + b.clearInferredProjectATAState(reason, logger) +} + +func (b *ProjectCollectionBuilder) clearInferredProjectATAState(reason string, logger *logging.LogTree) { + b.inferredProjectATAState = nil + if logger != nil { + logger.Log("Invalidating cached inferred project ATA state due to " + reason) + } +} + func (b *ProjectCollectionBuilder) refreshContentMapperProjectForChanges(entry dirty.Value[*Project], paths []tspath.PathKey, refreshAll bool, logger *logging.LogTree) { project := entry.Value() if project.Program == nil || project.contentMapperWatchedFiles == nil { @@ -820,7 +991,7 @@ func (b *ProjectCollectionBuilder) ensureProjectTree( } } -func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAStateChange, logger *logging.LogTree) { +func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAStateChange, fileChanges FileChangeSummary, logger *logging.LogTree) { updateProject := func(project dirty.Value[*Project], ataChange *ATAStateChange) { project.ChangeIf( func(p *Project) bool { @@ -830,15 +1001,16 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState // Consistency check: the ATA demands (project options, unresolved imports) of this project // has not changed since the time the ATA request was dispatched; the change can still be // applied to this project in its current state. - return ataChange.TypingsInfo.Equals(p.ComputeTypingsInfo()) + return ataChange.TypingsInfo.Equals(p.ComputeTypingsInfo()) && + slices.Equal(ataChange.FileNames, p.ComputeTypingsFileNames()) }, func(p *Project) { - // We checked before triggering this change (in Session.triggerATAForUpdatedProjects) that - // the set of typings files is actually different. p.installedTypingsInfo = ataChange.TypingsInfo + p.installedTypingsFileNames = slices.Clone(ataChange.FileNames) + p.installedTypingsFilesToWatch = slices.Clone(ataChange.TypingsFilesToWatch) p.typingsFiles = ataChange.TypingsFiles typingsWatchGlobs := getTypingsLocationsGlobs( - ataChange.TypingsFilesToWatch, + slices.Concat(ataChange.TypingsFilesToWatch, core.Map(ataChange.TypingsFiles, func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() })), b.sessionOptions.TypingsLocation, b.sessionOptions.CurrentDirectory, b.fs.fs.CaseSensitivity(), @@ -852,8 +1024,46 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState for projectID, ataChange := range ataChanges { logger.Embed(ataChange.Logs) + if fileChangeSummaryAffectsTypingsWatch( + fileChanges, + slices.Concat(ataChange.TypingsFilesToWatch, core.Map(ataChange.FileNames, func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() })), + ataChange.TypingsFiles, + b.sessionOptions.TypingsLocation, + b.fs.fs.CaseSensitivity(), + ) { + b.invalidateProjectATAState(projectID) + if _, inferred := projectID.Inferred(); inferred { + b.clearInferredProjectATAState("typings watch changes", logger) + } + } + if ataChange.SnapshotID < b.ataInvalidationSnapshotID(projectID) { + if logger != nil { + logger.Logf("Ignoring stale ATA state for project %s", projectID) + } + continue + } if _, ok := projectID.Inferred(); ok { - updateProject(b.inferredProject, ataChange) + if b.inferredProject.Value() != nil { + updateProject(b.inferredProject, ataChange) + } else { + typingsWatch := newTypingsWatch(b) + if b.inferredProjectATAState != nil && b.inferredProjectATAState.typingsWatch != nil { + typingsWatch = b.inferredProjectATAState.typingsWatch + } + typingsWatchGlobs := getTypingsLocationsGlobs( + slices.Concat(ataChange.TypingsFilesToWatch, core.Map(ataChange.TypingsFiles, func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() })), + b.sessionOptions.TypingsLocation, + b.sessionOptions.CurrentDirectory, + b.fs.fs.CaseSensitivity(), + ) + b.inferredProjectATAState = &inferredProjectATAState{ + installedTypingsInfo: ataChange.TypingsInfo, + installedTypingsFileNames: slices.Clone(ataChange.FileNames), + installedTypingsFilesToWatch: slices.Clone(ataChange.TypingsFilesToWatch), + typingsFiles: slices.Clone(ataChange.TypingsFiles), + typingsWatch: typingsWatch.Clone(typingsWatchGlobs), + } + } } else if syntheticProjectID, ok := projectID.Synthetic(); ok { if project, loaded := b.syntheticProjects.Load(syntheticProjectID); loaded { updateProject(project, ataChange) @@ -902,6 +1112,10 @@ func (b *ProjectCollectionBuilder) markProjectsAffectedByConfigChanges( logger *logging.LogTree, ) bool { for projectID := range configChangeResult.affectedProjects { + b.invalidateProjectATAState(projectID) + if _, inferred := projectID.Inferred(); inferred { + b.clearInferredProjectATAState("config changes", logger) + } var project dirty.Value[*Project] if _, ok := projectID.Inferred(); ok { project = b.inferredProject @@ -1377,6 +1591,7 @@ func (b *ProjectCollectionBuilder) deleteInferredProject(logger *logging.LogTree return true }) } + b.inferredProjectATAState = project.inferredProjectATAState() b.inferredProject.Delete() return true } @@ -1553,6 +1768,32 @@ func (b *ProjectCollectionBuilder) updateProgram(entry dirty.Value[*Project], lo elapsed := time.Since(startTime) logger.Log(fmt.Sprintf("Program update for %s completed in %v", projectID, elapsed)) } + if _, inferred := projectID.Inferred(); inferred && b.inferredProjectATAState != nil { + state := b.inferredProjectATAState + b.inferredProjectATAState = nil + if entry.ChangeIf( + func(project *Project) bool { + return state.canApply(project, b.fs, b.sessionOptions.WatchEnabled) + }, + func(project *Project) { + state.apply(project) + }, + ) { + if logger != nil { + logger.Log("Reusing cached inferred project ATA state") + } + filesChanged = b.updateProgram(entry, logger) || filesChanged + } else { + entry.ChangeIf( + func(project *Project) bool { + return state.canApplyWatchState(project, b.sessionOptions.WatchEnabled) + }, + func(project *Project) { + state.applyWatchState(project) + }, + ) + } + } return filesChanged } diff --git a/tsc/internal/project/session.go b/tsc/internal/project/session.go index 9af28b85f6d05..ea87db5a079fe 100644 --- a/tsc/internal/project/session.go +++ b/tsc/internal/project/session.go @@ -1660,12 +1660,16 @@ func (s *Session) updateWatches(oldSnapshot *Snapshot, newSnapshot *Snapshot) er newSnapshot.ProjectCollection.ProjectsByID(), func(_ ID, addedProject *Project) { errors = append(errors, s.updateWatch(ctx, nil, addedProject.programFilesWatch)...) - errors = append(errors, s.updateWatch(ctx, nil, addedProject.typingsWatch)...) + if addedProject.Kind != KindInferred { + errors = append(errors, s.updateWatch(ctx, nil, addedProject.typingsWatch)...) + } errors = append(errors, s.updateWatch(ctx, nil, addedProject.contentMapperWatch)...) }, func(_ ID, removedProject *Project) { errors = append(errors, s.updateWatch(ctx, removedProject.programFilesWatch, nil)...) - errors = append(errors, s.updateWatch(ctx, removedProject.typingsWatch, nil)...) + if removedProject.Kind != KindInferred { + errors = append(errors, s.updateWatch(ctx, removedProject.typingsWatch, nil)...) + } errors = append(errors, s.updateWatch(ctx, removedProject.contentMapperWatch, nil)...) }, func(_ ID, oldProject, newProject *Project) { @@ -1676,11 +1680,13 @@ func (s *Session) updateWatches(oldSnapshot *Snapshot, newSnapshot *Snapshot) er errors = append(errors, s.updateWatch(ctx, nil, newProject.programFilesWatch)...) } } - if oldProject.typingsWatch.ID() != newProject.typingsWatch.ID() { - errors = append(errors, s.updateWatch(ctx, oldProject.typingsWatch, newProject.typingsWatch)...) - } else { - if s.watches.IsPending(newProject.typingsWatch.ID()) { - errors = append(errors, s.updateWatch(ctx, nil, newProject.typingsWatch)...) + if newProject.Kind != KindInferred { + if oldProject.typingsWatch.ID() != newProject.typingsWatch.ID() { + errors = append(errors, s.updateWatch(ctx, oldProject.typingsWatch, newProject.typingsWatch)...) + } else { + if s.watches.IsPending(newProject.typingsWatch.ID()) { + errors = append(errors, s.updateWatch(ctx, nil, newProject.typingsWatch)...) + } } } if oldProject.contentMapperWatch.ID() != newProject.contentMapperWatch.ID() { @@ -1690,6 +1696,13 @@ func (s *Session) updateWatches(oldSnapshot *Snapshot, newSnapshot *Snapshot) er } }, ) + oldInferredTypingsWatch := oldSnapshot.ProjectCollection.inferredProjectTypingsWatch() + newInferredTypingsWatch := newSnapshot.ProjectCollection.inferredProjectTypingsWatch() + if oldInferredTypingsWatch.ID() != newInferredTypingsWatch.ID() { + errors = append(errors, s.updateWatch(ctx, oldInferredTypingsWatch, newInferredTypingsWatch)...) + } else if newInferredTypingsWatch != nil && s.watches.IsPending(newInferredTypingsWatch.ID()) { + errors = append(errors, s.updateWatch(ctx, nil, newInferredTypingsWatch)...) + } if oldSnapshot.autoImportsWatch.ID() != newSnapshot.autoImportsWatch.ID() { errors = append(errors, s.updateWatch(ctx, oldSnapshot.autoImportsWatch, newSnapshot.autoImportsWatch)...) @@ -2034,10 +2047,11 @@ func (s *Session) triggerATAForUpdatedProjects(newSnapshot *Snapshot) { } typingsInfo := project.ComputeTypingsInfo() + fileNames := project.ComputeTypingsFileNames() request := &ata.TypingsInstallRequest{ ProjectID: project.ID(), TypingsInfo: &typingsInfo, - FileNames: core.Map(project.Program.GetSourceFiles(), func(file *ast.SourceFile) tspath.RootedFilePath { return file.FileName() }), + FileNames: fileNames, ProjectRootPath: project.projectDirectory, FS: s.fs, Logger: logTree, @@ -2057,16 +2071,29 @@ func (s *Session) triggerATAForUpdatedProjects(newSnapshot *Snapshot) { s.logger.Log(logTree.String()) } } else { - if !slices.Equal(result.TypingsFiles, project.typingsFiles) { + if !slices.Equal(result.TypingsFiles, project.typingsFiles) || + project.installedTypingsInfo == nil || + !typingsInfo.Equals(*project.installedTypingsInfo) || + !slices.Equal(fileNames, project.installedTypingsFileNames) || + !slices.Equal(result.FilesToWatch, project.installedTypingsFilesToWatch) { s.pendingATAChangesMu.Lock() - defer s.pendingATAChangesMu.Unlock() - s.pendingATAChanges[project.ID()] = &ATAStateChange{ - TypingsInfo: &typingsInfo, - TypingsFiles: result.TypingsFiles, - TypingsFilesToWatch: result.FilesToWatch, - Logs: logTree, + projectID := project.ID() + pendingChange := s.pendingATAChanges[projectID] + updatedPendingChange := pendingChange == nil || pendingChange.SnapshotID <= newSnapshot.ID() + if updatedPendingChange { + s.pendingATAChanges[projectID] = &ATAStateChange{ + SnapshotID: newSnapshot.ID(), + TypingsInfo: &typingsInfo, + FileNames: fileNames, + TypingsFiles: result.TypingsFiles, + TypingsFilesToWatch: result.FilesToWatch, + Logs: logTree, + } + } + s.pendingATAChangesMu.Unlock() + if updatedPendingChange { + s.ScheduleDiagnosticsRefresh() } - s.ScheduleDiagnosticsRefresh() } } }) diff --git a/tsc/internal/project/snapshot.go b/tsc/internal/project/snapshot.go index 4203b49cc47ad..9a1f20d668ee9 100644 --- a/tsc/internal/project/snapshot.go +++ b/tsc/internal/project/snapshot.go @@ -417,8 +417,12 @@ type SnapshotChange struct { // ATAStateChange represents a change to a project's ATA state. type ATAStateChange struct { + // SnapshotID is the snapshot whose project state triggered the ATA request. + SnapshotID uint64 // TypingsInfo is the new typings info for the project. TypingsInfo *ata.TypingsInfo + // FileNames are the JavaScript files used for typings discovery. + FileNames []tspath.RootedFilePath // TypingsFiles is the new list of typing files for the project. TypingsFiles []tspath.RootedFilePath // TypingsFilesToWatch is the new list of typing files to watch for changes. @@ -494,6 +498,7 @@ func (s *Snapshot) Clone( } start := time.Now() + hadExcessiveWatchEvents := change.fileChanges.HasExcessiveWatchEvents() inferredContentMappers := s.inferredProjectContentMappers inferredContentMapperExtensions := s.inferredProjectContentMapperExtensions if change.contentMapperContributions != nil { @@ -547,22 +552,26 @@ func (s *Snapshot) Clone( client, ) - if len(change.ataChanges) != 0 { - projectCollectionBuilder.DidUpdateATAState(change.ataChanges, logger.Fork("DidUpdateATAState")) + if hadExcessiveWatchEvents { + projectCollectionBuilder.DidInvalidateTypingsWatchState(logger.Fork("DidInvalidateTypingsWatchState")) } projectCollectionBuilder.DidChangeCustomConfigFileName(logger.Fork("DidChangeCustomConfigFileName")) - if change.compilerOptionsForInferredProjects != nil && projectCollectionBuilder.inferredProject.Value() != nil { - projectCollectionBuilder.updateInferredProject( - projectCollectionBuilder.inferredProject.Value().CommandLine.FileNames(), - change.compilerOptionsForInferredProjects, - projectCollectionBuilder.inferredProject.Value().CommandLine.ProjectReferences(), - projectCollectionBuilder.inferredProject.Value().CommandLine.Errors, - projectCollectionBuilder.inferredProject.Value().CommandLine.ContentMappers(), - logger.Fork("DidChangeCompilerOptionsForInferredProjects"), - ) + if change.compilerOptionsForInferredProjects != nil { + projectCollectionBuilder.invalidateInferredProjectATAState("inferred compiler options changes", logger.Fork("InvalidateInferredProjectATAState")) + if projectCollectionBuilder.inferredProject.Value() != nil { + projectCollectionBuilder.updateInferredProject( + projectCollectionBuilder.inferredProject.Value().CommandLine.FileNames(), + change.compilerOptionsForInferredProjects, + projectCollectionBuilder.inferredProject.Value().CommandLine.ProjectReferences(), + projectCollectionBuilder.inferredProject.Value().CommandLine.Errors, + projectCollectionBuilder.inferredProject.Value().CommandLine.ContentMappers(), + logger.Fork("DidChangeCompilerOptionsForInferredProjects"), + ) + } } if change.contentMapperContributions != nil { + projectCollectionBuilder.invalidateInferredProjectATAState("content mapper changes", logger.Fork("InvalidateInferredProjectATAState")) projectCollectionBuilder.DidChangeContentMapperContributions(logger.Fork("DidChangeContentMapperContributions")) } if change.newConfig != nil { @@ -572,6 +581,9 @@ func (s *Snapshot) Clone( if !change.fileChanges.IsEmpty() { projectCollectionBuilder.DidChangeFiles(change.fileChanges, logger.Fork("DidChangeFiles")) } + if len(change.ataChanges) != 0 { + projectCollectionBuilder.DidUpdateATAState(change.ataChanges, change.fileChanges, logger.Fork("DidUpdateATAState")) + } var apiError error if change.apiRequest != nil { From 2ea204cdf6d6ac48215eee43bfec4c49222d9aec Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:17:19 -0700 Subject: [PATCH 03/12] test: Cover inferred project ATA reuse --- tsc/internal/project/ata/ata_test.go | 422 +++++++++++++++++++++ tsc/internal/project/contentmapper_test.go | 18 + 2 files changed, 440 insertions(+) diff --git a/tsc/internal/project/ata/ata_test.go b/tsc/internal/project/ata/ata_test.go index 20ff0cc5024d0..26238cf9c474f 100644 --- a/tsc/internal/project/ata/ata_test.go +++ b/tsc/internal/project/ata/ata_test.go @@ -9,11 +9,14 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/bundled" "github.com/microsoft/TypeScript/tsc/internal/collections" + "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/glob" "github.com/microsoft/TypeScript/tsc/internal/ls/lsutil" "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" "github.com/microsoft/TypeScript/tsc/internal/project" "github.com/microsoft/TypeScript/tsc/internal/testutil/projecttestutil" "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" "gotest.tools/v3/assert" ) @@ -302,6 +305,416 @@ func TestATA(t *testing.T) { assert.Assert(t, jqueryTypesFile != nil, "jquery types should be installed") }) + t.Run("inferred project retains typings after closing last file", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } + }`, + } + + session, _ := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + }, + }) + + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(context.Background(), uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + + session.DidCloseFile(context.Background(), uri) + session.WaitForBackgroundTasks() + session.DidOpenFile(context.Background(), uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + + ls, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + typingsFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") + assert.Assert(t, typingsFile != nil, "jquery types should be available immediately after reopening") + }) + + t.Run("inferred project retains ATA result completed after closing last file", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } + }`, + } + + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + }, + }) + + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var releaseInstallOnce sync.Once + release := func() { releaseInstallOnce.Do(func() { close(releaseInstall) }) } + defer release() + utils.NpmExecutor().NpmInstallFunc = func(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + if slices.Contains(args, "@types/jquery@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(ctx, cwd, args) + } + + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + waitForInstall(t, installStarted) + + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() + + release() + session.WaitForBackgroundTasks() + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + typingsFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") + assert.Assert(t, typingsFile != nil, "jquery types should be available immediately after reopening") + }) + + t.Run("inferred project rejects ATA result invalidated after closing last file", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } + }`, + } + + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "commander": `declare const commander: { x: number }`, + "jquery": `declare const $: { x: number }`, + }, + }) + + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + packageJSONURI := lsproto.DocumentUri("file:///user/username/projects/project/package.json") + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var releaseInstallOnce sync.Once + release := func() { releaseInstallOnce.Do(func() { close(releaseInstall) }) } + defer release() + utils.NpmExecutor().NpmInstallFunc = func(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + if slices.Contains(args, "@types/commander@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(ctx, cwd, args) + } + + err = utils.FS().WriteFile(packageJSONURI.FileName(), `{ + "name": "test", + "dependencies": { + "commander": "^14.0.0", + "jquery": "^3.1.0" + } + }`) + assert.NilError(t, err) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: packageJSONURI, + Type: lsproto.FileChangeTypeChanged, + }}) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + waitForInstall(t, installStarted) + + session.DidCloseFile(ctx, uri) + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() + + session.Configure(lsutil.ParseUserPreferences(map[string]any{ + "js/ts": map[string]any{ + "tsserver": map[string]any{ + "automaticTypeAcquisition": map[string]any{ + "enabled": false, + }, + }, + }, + })) + err = utils.FS().WriteFile(packageJSONURI.FileName(), files["/user/username/projects/project/package.json"].(string)) + assert.NilError(t, err) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: packageJSONURI, + Type: lsproto.FileChangeTypeChanged, + }}) + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + + release() + session.WaitForBackgroundTasks() + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + commanderTypesFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/commander/index.d.ts") + assert.Assert(t, commanderTypesFile == nil, "stale commander typings should not be retained after the manifest changes") + }) + + t.Run("inferred project retains typings after closing changed file", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } + }`, + } + + session, _ := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + }, + }) + + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(context.Background(), uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + + const changedContent = "// changed" + session.DidChangeFile(context.Background(), uri, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{{ + WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: changedContent}, + }}) + session.DidCloseFile(context.Background(), uri) + session.WaitForBackgroundTasks() + session.DidOpenFile(context.Background(), uri, 1, changedContent, lsproto.LanguageKindJavaScript) + + ls, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + typingsFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") + assert.Assert(t, typingsFile != nil, "jquery types should be available immediately after reopening a changed file") + }) + + t.Run("inferred project does not reuse typings for unrelated roots", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project1/app.js": ``, + "/user/username/projects/project1/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } + }`, + "/user/username/projects/project2/app.js": ``, + } + + session, _ := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + }, + }) + + firstURI := lsproto.DocumentUri("file:///user/username/projects/project1/app.js") + session.DidOpenFile(context.Background(), firstURI, 1, files["/user/username/projects/project1/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(context.Background(), firstURI) + assert.NilError(t, err) + + session.DidCloseFile(context.Background(), firstURI) + session.WaitForBackgroundTasks() + + secondURI := lsproto.DocumentUri("file:///user/username/projects/project2/app.js") + session.DidOpenFile(context.Background(), secondURI, 1, files["/user/username/projects/project2/app.js"].(string), lsproto.LanguageKindJavaScript) + ls, err := session.GetLanguageService(context.Background(), secondURI) + assert.NilError(t, err) + typingsFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") + assert.Assert(t, typingsFile == nil, "jquery types should not be reused for an unrelated root") + }) + + t.Run("inferred project does not reuse typings after manifest change", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } + }`, + } + + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + }, + }) + + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(context.Background(), uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + var typingsWatcherID project.WatcherID + for _, call := range utils.Client().WatchFilesCalls() { + for _, watcher := range call.Watchers { + if watcher.GlobPattern.Pattern != nil { + g, parseErr := glob.Parse(*watcher.GlobPattern.Pattern) + if parseErr == nil && g.Match("/user/username/projects/project/package.json") { + typingsWatcherID = call.ID + break + } + } + } + if typingsWatcherID != "" { + break + } + } + assert.Assert(t, typingsWatcherID != "", "expected typings watcher to be registered") + + session.DidCloseFile(context.Background(), uri) + session.WaitForBackgroundTasks() + assert.Assert(t, !slices.ContainsFunc(utils.Client().UnwatchFilesCalls(), func(call struct { + Ctx context.Context + ID project.WatcherID + }, + ) bool { + return call.ID == typingsWatcherID + }), "typings watcher should remain registered while the inferred project is inactive") + err = utils.FS().WriteFile("/user/username/projects/project/package.json", `{"name":"test"}`) + assert.NilError(t, err) + session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{{ + Uri: "file:///user/username/projects/project/package.json", + Type: lsproto.FileChangeTypeChanged, + }}) + + session.DidOpenFile(context.Background(), uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + ls, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + typingsFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") + assert.Assert(t, typingsFile == nil, "jquery types should not be reused after the manifest changes") + }) + + t.Run("inferred project does not reuse typings after compiler options change", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } + }`, + } + + session, _ := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + }, + }) + + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(context.Background(), uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + + session.DidCloseFile(context.Background(), uri) + session.WaitForBackgroundTasks() + session.DidChangeCompilerOptionsForInferredProjects(context.Background(), &core.CompilerOptions{ + AllowJs: core.TSTrue, + Types: []string{}, + }) + session.DidOpenFile(context.Background(), uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + + ls, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + typingsFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") + assert.Assert(t, typingsFile == nil, "jquery types should not be reused after inferred compiler options change") + }) + + t.Run("inferred project does not reuse typings after filesystem replacement", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } + }`, + } + + session, _ := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + }, + }) + + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + + session.DidCloseFile(ctx, uri) + session.WaitForBackgroundTasks() + replacement := bundled.WrapFS(vfstest.FromMap(map[string]string{ + "/user/username/projects/project/app.js": "", + "/user/username/projects/project/package.json": `{"name":"test"}`, + }, tspath.CaseInsensitive)) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, &project.APISnapshotRequest{ + FileSystem: replacement, + ReplaceFileSystem: true, + }) + assert.NilError(t, err) + snapshot.Deref() + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + typingsFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") + assert.Assert(t, typingsFile == nil, "jquery types should not be reused after replacing the filesystem") + }) + t.Run("type acquisition with disableFilenameBasedTypeAcquisition:true", func(t *testing.T) { t.Parallel() @@ -603,6 +1016,15 @@ func TestATA(t *testing.T) { assert.Assert(t, program.GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/commander/index.d.ts") != nil) // JS resolution should be dropped assert.Assert(t, program.GetSourceFile("/user/username/projects/node_modules/commander/index.js") == nil) + + session.DidCloseFile(context.Background(), uri) + session.WaitForBackgroundTasks() + session.DidOpenFile(context.Background(), uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + ls, err = session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + program = ls.GetProgram() + assert.Assert(t, program.GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/commander/index.d.ts") != nil) + assert.Assert(t, program.GetSourceFile("/user/username/projects/node_modules/commander/index.js") == nil) }) t.Run("expired cache entry (inferred project, should install typings)", func(t *testing.T) { diff --git a/tsc/internal/project/contentmapper_test.go b/tsc/internal/project/contentmapper_test.go index db1459c760465..89dbee6af23a7 100644 --- a/tsc/internal/project/contentmapper_test.go +++ b/tsc/internal/project/contentmapper_test.go @@ -982,6 +982,7 @@ func TestContentMapperInferredProjectSurvivesTypingsInstall(t *testing.T) { PositionEncoding: lsproto.PositionEncodingKindUTF8, LoggingEnabled: true, RunExternalCode: true, + WatchEnabled: true, }, &projecttestutil.TypingsInstallerOptions{ PackageToFile: map[string]string{ "jquery": `declare const $: { x: number }`, @@ -1032,6 +1033,23 @@ func TestContentMapperInferredProjectSurvivesTypingsInstall(t *testing.T) { } } assert.Assert(t, typingsFile != nil, "expected installed typings in the inferred program (the typings-augmented rebuild did not happen)") + + session.DidCloseFile(ctx, boxURI) + session.WaitForBackgroundTasks() + session.DidOpenFile(ctx, boxURI, 1, files["/home/loose/app.box"].(string), lsproto.LanguageKind("box")) + languageService, err = session.GetLanguageService(ctx, boxURI) + assert.NilError(t, err) + boxFile = languageService.GetProgram().GetSourceFile("/home/loose/app.box") + assert.Assert(t, boxFile != nil) + assert.Assert(t, boxFile.ContentMapper() != "") + typingsFile = nil + for _, file := range languageService.GetProgram().SourceFiles() { + if strings.HasSuffix(file.FileName().AsString(), "@types/jquery/index.d.ts") { + typingsFile = file + break + } + } + assert.Assert(t, typingsFile != nil, "expected cached typings immediately after reopening a content-mapped root") } func TestContentMapperCreatedFileAdoptedByConfiguredProject(t *testing.T) { From 7c29ddde9e0fa204dd91880a4e924c3b3be2d973 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:00:08 -0700 Subject: [PATCH 04/12] Handle unbuilt inferred projects during ATA An inferred project can exist without a program while cached ATA state is still authoritative. Keep that state and its watcher until a built program validates it, and reject results older than the latest installed or confirmed state. --- tsc/internal/project/ata/ata_test.go | 434 ++++++++++++++++++ tsc/internal/project/project.go | 15 + tsc/internal/project/projectcollection.go | 6 +- .../project/projectcollectionbuilder.go | 44 +- 4 files changed, 494 insertions(+), 5 deletions(-) diff --git a/tsc/internal/project/ata/ata_test.go b/tsc/internal/project/ata/ata_test.go index 26238cf9c474f..e1ab59d972d37 100644 --- a/tsc/internal/project/ata/ata_test.go +++ b/tsc/internal/project/ata/ata_test.go @@ -3,6 +3,7 @@ package ata_test import ( "context" "slices" + "strings" "sync" "testing" "time" @@ -30,6 +31,60 @@ func waitForInstall(t *testing.T, installStarted <-chan struct{}) { } } +func typingsWatchIDs(utils *projecttestutil.SessionUtils) []project.WatcherID { + var ids []project.WatcherID + for _, call := range utils.Client().WatchFilesCalls() { + if strings.HasPrefix(string(call.ID), "typings installer files watcher") { + ids = append(ids, call.ID) + } + } + return ids +} + +func unwatchedAny(utils *projecttestutil.SessionUtils, ids []project.WatcherID) bool { + return slices.ContainsFunc(utils.Client().UnwatchFilesCalls(), func(call struct { + Ctx context.Context + ID project.WatcherID + }, + ) bool { + return slices.Contains(ids, call.ID) + }) +} + +func leaveUnbuiltInferredProject(t *testing.T, ctx context.Context, session *project.Session, utils *projecttestutil.SessionUtils) { + t.Helper() + const config = "/user/username/projects/other/tsconfig.json" + const file = "/user/username/projects/other/x.js" + openProjects := &collections.Set[tspath.RootedFilePath]{} + openProjects.Add(tspath.RootedFilePathFromNormalized(config)) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, &project.APISnapshotRequest{ + OpenProjects: openProjects, + OpenFiles: map[tspath.PathKey]tspath.RootedFilePath{utils.FS().CaseSensitivity().PathKey(tspath.RootedPath(file)): tspath.RootedFilePathFromNormalized(file)}, + }) + assert.NilError(t, err) + snapshot.Deref() + closeProjects := &collections.Set[tspath.PathKey]{} + closeProjects.Add(utils.FS().CaseSensitivity().PathKey(tspath.RootedPath(config))) + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, &project.APISnapshotRequest{CloseProjects: closeProjects}) + assert.NilError(t, err) + snapshot.Deref() + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, &project.APISnapshotRequest{CloseFiles: &collections.Set[tspath.PathKey]{}}) + assert.NilError(t, err) + inferred := snapshot.ProjectCollection.InferredProject() + assert.Assert(t, inferred != nil && inferred.GetProgram() == nil, "replacement inferred project should be unbuilt") + snapshot.Deref() +} + +func closeUnbuiltInferredProject(t *testing.T, ctx context.Context, session *project.Session, utils *projecttestutil.SessionUtils) { + t.Helper() + closeFiles := &collections.Set[tspath.PathKey]{} + closeFiles.Add(utils.FS().CaseSensitivity().PathKey(tspath.RootedPath("/user/username/projects/other/x.js"))) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, &project.APISnapshotRequest{CloseFiles: closeFiles}) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() +} + func TestATA(t *testing.T) { t.Parallel() if !bundled.Embedded { @@ -630,6 +685,385 @@ func TestATA(t *testing.T) { assert.Assert(t, typingsFile == nil, "jquery types should not be reused after the manifest changes") }) + t.Run("cached inferred typings survive an unbuilt replacement project", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + "/user/username/projects/other/tsconfig.json": `{"compilerOptions":{"allowJs":true}}`, + "/user/username/projects/other/x.js": ``, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"jquery": `declare const $: { x: number }`}, + }) + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() + session.WaitForBackgroundTasks() + watchIDs := typingsWatchIDs(utils) + assert.Assert(t, len(watchIDs) > 0) + + leaveUnbuiltInferredProject(t, ctx, session, utils) + session.WaitForBackgroundTasks() + assert.Assert(t, !unwatchedAny(utils, watchIDs), "cached ATA watch must stay registered while the replacement project is unbuilt") + + closeUnbuiltInferredProject(t, ctx, session, utils) + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + }) + + t.Run("ATA result survives an unbuilt replacement project", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + "/user/username/projects/other/tsconfig.json": `{"compilerOptions":{"allowJs":true}}`, + "/user/username/projects/other/x.js": ``, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"jquery": `declare const $: { x: number }`}, + }) + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseInstall) }) } + defer release() + utils.NpmExecutor().NpmInstallFunc = func(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + if slices.Contains(args, "@types/jquery@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(ctx, cwd, args) + } + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + waitForInstall(t, installStarted) + + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() + + leaveUnbuiltInferredProject(t, ctx, session, utils) + release() + session.WaitForBackgroundTasks() + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + + closeUnbuiltInferredProject(t, ctx, session, utils) + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + }) + + t.Run("cached inferred typings survive an LSP-created unbuilt project", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + "/user/username/projects/other/tsconfig.json": `{"compilerOptions":{"allowJs":true}}`, + "/user/username/projects/other/y.js": ``, + "/user/username/projects/other/z.js": ``, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"jquery": `declare const $: { x: number }`}, + }) + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + yURI := lsproto.DocumentUri("file:///user/username/projects/other/y.js") + zURI := lsproto.DocumentUri("file:///user/username/projects/other/z.js") + configURI := lsproto.DocumentUri("file:///user/username/projects/other/tsconfig.json") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + session.DidCloseFile(ctx, uri) + session.WaitForBackgroundTasks() + watchIDs := typingsWatchIDs(utils) + assert.Assert(t, len(watchIDs) > 0) + + session.DidOpenFile(ctx, yURI, 1, "", lsproto.LanguageKindJavaScript) + assert.NilError(t, utils.FS().WriteFile(configURI.FileName(), `{"compilerOptions":{"allowJs":true},"exclude":["y.js"]}`)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{Uri: configURI, Type: lsproto.FileChangeTypeChanged}}) + session.WaitForBackgroundTasks() + session.DidOpenFile(ctx, zURI, 1, "", lsproto.LanguageKindJavaScript) + inferred := session.Snapshot().ProjectCollection.InferredProject() + assert.Assert(t, inferred != nil && inferred.GetProgram() == nil, "replacement inferred project should be unbuilt") + session.WaitForBackgroundTasks() + assert.Assert(t, !unwatchedAny(utils, watchIDs), "cached ATA watch must stay registered while the replacement project is unbuilt") + + session.DidCloseFile(ctx, yURI) + session.WaitForBackgroundTasks() + assert.Assert(t, session.Snapshot().ProjectCollection.InferredProject() == nil) + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + }) + + t.Run("older ATA result does not replace newer cached state", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": `import "commander";`, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + "commander": `declare const commander: { x: number }`, + }, + }) + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseInstall) }) } + defer release() + utils.NpmExecutor().NpmInstallFunc = func(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + if slices.Contains(args, "@types/commander@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(ctx, cwd, args) + } + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, `import "commander";`, lsproto.LanguageKindJavaScript) + waitForInstall(t, installStarted) + session.DidChangeFile(ctx, uri, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{{ + WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: ``}, + }}) + deadline := time.Now().Add(10 * time.Second) + for { + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + if ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil { + break + } + assert.Assert(t, time.Now().Before(deadline), "newer ATA result was not applied") + time.Sleep(10 * time.Millisecond) + } + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + + release() + session.WaitForBackgroundTasks() + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + + session.DidOpenFile(ctx, uri, 3, ``, lsproto.LanguageKindJavaScript) + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + }) + + t.Run("older ATA result does not replace newer built project state", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"commander":"^1.0.0"}}`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + "commander": `declare const commander: { x: number }`, + }, + }) + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseInstall) }) } + defer release() + utils.NpmExecutor().NpmInstallFunc = func(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + if slices.Contains(args, "@types/commander@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(ctx, cwd, args) + } + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + manifestURI := lsproto.DocumentUri("file:///user/username/projects/project/package.json") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + waitForInstall(t, installStarted) + + assert.NilError(t, utils.FS().WriteFile(manifestURI.FileName(), `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{Uri: manifestURI, Type: lsproto.FileChangeTypeChanged}}) + deadline := time.Now().Add(10 * time.Second) + for { + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + if ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil { + break + } + assert.Assert(t, time.Now().Before(deadline), "newer ATA result was not applied") + time.Sleep(10 * time.Millisecond) + } + + release() + session.WaitForBackgroundTasks() + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + session.DidCloseFile(ctx, uri) + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + + session.DidOpenFile(ctx, uri, 2, "", lsproto.LanguageKindJavaScript) + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + }) + + t.Run("older ATA result does not invalidate newer state while closing", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": `import "commander";`, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + "commander": `declare const commander: { x: number }`, + }, + }) + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseInstall) }) } + defer release() + utils.NpmExecutor().NpmInstallFunc = func(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + if slices.Contains(args, "@types/commander@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(ctx, cwd, args) + } + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, `import "commander";`, lsproto.LanguageKindJavaScript) + waitForInstall(t, installStarted) + session.DidChangeFile(ctx, uri, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{{ + WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: ``}, + }}) + deadline := time.Now().Add(10 * time.Second) + for { + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + if ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil { + break + } + assert.Assert(t, time.Now().Before(deadline), "newer ATA result was not applied") + time.Sleep(10 * time.Millisecond) + } + + release() + session.WaitForBackgroundTasks() + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + + session.DidOpenFile(ctx, uri, 3, ``, lsproto.LanguageKindJavaScript) + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + }) + + t.Run("superseded ATA result does not replace state confirmed by a newer program", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + "commander": `declare const commander: { x: number }`, + }, + }) + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseInstall) }) } + defer release() + utils.NpmExecutor().NpmInstallFunc = func(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + if slices.Contains(args, "@types/commander@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(ctx, cwd, args) + } + + session.DidChangeFile(ctx, uri, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{{ + WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: `import "commander";`}, + }}) + _, err = session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + waitForInstall(t, installStarted) + session.DidChangeFile(ctx, uri, 3, []lsproto.TextDocumentContentChangePartialOrWholeDocument{{ + WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: ``}, + }}) + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + release() + session.WaitForBackgroundTasks() + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + + session.DidOpenFile(ctx, uri, 4, "", lsproto.LanguageKindJavaScript) + ls, err = session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + }) + t.Run("inferred project does not reuse typings after compiler options change", func(t *testing.T) { t.Parallel() diff --git a/tsc/internal/project/project.go b/tsc/internal/project/project.go index fa9f6c73af791..481dd06c38c7b 100644 --- a/tsc/internal/project/project.go +++ b/tsc/internal/project/project.go @@ -197,6 +197,9 @@ type Project struct { // ataInvalidationSnapshotID is the latest snapshot that invalidated this // project's ATA discovery inputs. ataInvalidationSnapshotID uint64 + // installedTypingsSnapshotID is the snapshot that triggered the most recently + // applied typings installation. + installedTypingsSnapshotID uint64 } type inferredProjectATAState struct { @@ -205,18 +208,27 @@ type inferredProjectATAState struct { installedTypingsFilesToWatch []tspath.RootedPath typingsFiles []tspath.RootedFilePath typingsWatch *WatchedFiles[PatternsAndIgnored] + snapshotID uint64 } func (p *Project) inferredProjectATAState() *inferredProjectATAState { if p.installedTypingsInfo == nil && len(p.installedTypingsFilesToWatch) == 0 { return nil } + snapshotID := p.installedTypingsSnapshotID + if p.installedTypingsInfo != nil && + p.installedTypingsInfo.Equals(p.ComputeTypingsInfo()) && + slices.Equal(p.installedTypingsFileNames, p.ComputeTypingsFileNames()) && + p.ProgramLastUpdate > snapshotID { + snapshotID = p.ProgramLastUpdate + } return &inferredProjectATAState{ installedTypingsInfo: p.installedTypingsInfo, installedTypingsFileNames: slices.Clone(p.installedTypingsFileNames), installedTypingsFilesToWatch: slices.Clone(p.installedTypingsFilesToWatch), typingsFiles: slices.Clone(p.typingsFiles), typingsWatch: p.typingsWatch, + snapshotID: snapshotID, } } @@ -246,6 +258,7 @@ func (s *inferredProjectATAState) apply(project *Project) { project.installedTypingsFilesToWatch = slices.Clone(s.installedTypingsFilesToWatch) project.typingsFiles = slices.Clone(s.typingsFiles) project.typingsWatch = s.typingsWatch + project.installedTypingsSnapshotID = s.snapshotID if typingsFilesChanged { project.dirty = true project.dirtyFilePath = "" @@ -266,6 +279,7 @@ func (s *inferredProjectATAState) applyWatchState(project *Project) { core.Map(s.typingsFiles, func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() }), ) project.typingsWatch = s.typingsWatch + project.installedTypingsSnapshotID = s.snapshotID } var _ ls.Project = (*Project)(nil) @@ -524,6 +538,7 @@ func (p *Project) Clone() *Project { installedTypingsFilesToWatch: p.installedTypingsFilesToWatch, typingsFiles: p.typingsFiles, ataInvalidationSnapshotID: p.ataInvalidationSnapshotID, + installedTypingsSnapshotID: p.installedTypingsSnapshotID, } } diff --git a/tsc/internal/project/projectcollection.go b/tsc/internal/project/projectcollection.go index 4cffc70efc389..b036ab0efa646 100644 --- a/tsc/internal/project/projectcollection.go +++ b/tsc/internal/project/projectcollection.go @@ -79,12 +79,12 @@ type apiOpenedFile struct { func (c *ProjectCollection) ConfigFileRegistry() *ConfigFileRegistry { return c.configFileRegistry } func (c *ProjectCollection) inferredProjectTypingsWatch() *WatchedFiles[PatternsAndIgnored] { - if c.inferredProject != nil { - return c.inferredProject.typingsWatch - } if c.inferredProjectATAState != nil { return c.inferredProjectATAState.typingsWatch } + if c.inferredProject != nil { + return c.inferredProject.typingsWatch + } return nil } diff --git a/tsc/internal/project/projectcollectionbuilder.go b/tsc/internal/project/projectcollectionbuilder.go index add5c1bb634b3..2304aaf0731f0 100644 --- a/tsc/internal/project/projectcollectionbuilder.go +++ b/tsc/internal/project/projectcollectionbuilder.go @@ -14,6 +14,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/compiler" "github.com/microsoft/TypeScript/tsc/internal/contentmapper" "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/debug" "github.com/microsoft/TypeScript/tsc/internal/diagnostics" "github.com/microsoft/TypeScript/tsc/internal/ls/lsutil" "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" @@ -154,6 +155,11 @@ func (b *ProjectCollectionBuilder) Finalize(logger *logging.LogTree) (*ProjectCo ensureCloned() newProjectCollection.inferredProject = newInferredProject } + if b.inferredProjectATAState != nil { + if inferred := b.inferredProject.Value(); inferred != nil { + debug.Assert(inferred.Program == nil, "cached inferred ATA state coexists with a built inferred project") + } + } if b.inferredProjectATAState != b.base.inferredProjectATAState { ensureCloned() newProjectCollection.inferredProjectATAState = b.inferredProjectATAState @@ -992,6 +998,30 @@ func (b *ProjectCollectionBuilder) ensureProjectTree( } func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAStateChange, fileChanges FileChangeSummary, logger *logging.LogTree) { + installedTypingsSnapshotID := func(projectID ID) uint64 { + if _, inferred := projectID.Inferred(); inferred { + if state := b.inferredProjectATAState; state != nil { + return state.snapshotID + } + if project := b.inferredProject.Value(); project != nil { + return project.installedTypingsSnapshotID + } + } else if syntheticID, synthetic := projectID.Synthetic(); synthetic { + if entry, loaded := b.syntheticProjects.Load(syntheticID); loaded { + if project := entry.Value(); project != nil { + return project.installedTypingsSnapshotID + } + } + } else if configuredID, configured := projectID.Configured(); configured { + if entry, loaded := b.configuredProjects.Load(configuredID); loaded { + if project := entry.Value(); project != nil { + return project.installedTypingsSnapshotID + } + } + } + return 0 + } + updateProject := func(project dirty.Value[*Project], ataChange *ATAStateChange) { project.ChangeIf( func(p *Project) bool { @@ -1006,6 +1036,7 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState }, func(p *Project) { p.installedTypingsInfo = ataChange.TypingsInfo + p.installedTypingsSnapshotID = ataChange.SnapshotID p.installedTypingsFileNames = slices.Clone(ataChange.FileNames) p.installedTypingsFilesToWatch = slices.Clone(ataChange.TypingsFilesToWatch) p.typingsFiles = ataChange.TypingsFiles @@ -1024,6 +1055,12 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState for projectID, ataChange := range ataChanges { logger.Embed(ataChange.Logs) + if ataChange.SnapshotID < installedTypingsSnapshotID(projectID) { + if logger != nil { + logger.Logf("Ignoring ATA state older than installed state for project %s", projectID) + } + continue + } if fileChangeSummaryAffectsTypingsWatch( fileChanges, slices.Concat(ataChange.TypingsFilesToWatch, core.Map(ataChange.FileNames, func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() })), @@ -1043,7 +1080,7 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState continue } if _, ok := projectID.Inferred(); ok { - if b.inferredProject.Value() != nil { + if inferred := b.inferredProject.Value(); inferred != nil && inferred.Program != nil { updateProject(b.inferredProject, ataChange) } else { typingsWatch := newTypingsWatch(b) @@ -1062,6 +1099,7 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState installedTypingsFilesToWatch: slices.Clone(ataChange.TypingsFilesToWatch), typingsFiles: slices.Clone(ataChange.TypingsFiles), typingsWatch: typingsWatch.Clone(typingsWatchGlobs), + snapshotID: ataChange.SnapshotID, } } } else if syntheticProjectID, ok := projectID.Synthetic(); ok { @@ -1591,7 +1629,9 @@ func (b *ProjectCollectionBuilder) deleteInferredProject(logger *logging.LogTree return true }) } - b.inferredProjectATAState = project.inferredProjectATAState() + if project.Program != nil { + b.inferredProjectATAState = project.inferredProjectATAState() + } b.inferredProject.Delete() return true } From 62372fbe292c9c095a723029843aa3d8d026ef15 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:39:39 -0700 Subject: [PATCH 05/12] Separate ATA inputs from installer outputs Typings cache writes are outputs of acquisition, not discovery changes, and can arrive in the same flush as their result. Keep those events available to the program while excluding them from ATA invalidation, without losing real package-owned inputs or full invalidations. --- tsc/internal/project/ata/ata_test.go | 305 +++++++++++++++++- tsc/internal/project/filechange.go | 23 ++ tsc/internal/project/project.go | 12 +- .../project/projectcollectionbuilder.go | 31 +- tsc/internal/project/snapshot.go | 22 +- 5 files changed, 371 insertions(+), 22 deletions(-) diff --git a/tsc/internal/project/ata/ata_test.go b/tsc/internal/project/ata/ata_test.go index e1ab59d972d37..277b0b06f8256 100644 --- a/tsc/internal/project/ata/ata_test.go +++ b/tsc/internal/project/ata/ata_test.go @@ -2,6 +2,7 @@ package ata_test import ( "context" + "fmt" "slices" "strings" "sync" @@ -441,14 +442,314 @@ func TestATA(t *testing.T) { release() session.WaitForBackgroundTasks() + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil, "the ATA result should be delivered while no inferred project exists") + snapshot.Deref() + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + + inferred := session.Snapshot().ProjectCollection.InferredProject() + assert.Assert(t, inferred != nil && inferred.GetProgram() != nil) + typingsFile := inferred.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") + assert.Assert(t, typingsFile != nil, "jquery types should be available immediately after reopening") + }) + + t.Run("ATA install writes delivered with the result do not discard it", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/a/app.js": ``, + "/user/username/projects/a/tsconfig.json": `{"compilerOptions":{"allowJs":true},"typeAcquisition":{"enable":true}}`, + "/user/username/projects/a/package.json": `{"name":"a","dependencies":{"jquery":"^3.1.0"}}`, + "/user/username/projects/b/app.js": ``, + "/user/username/projects/b/tsconfig.json": `{"compilerOptions":{"allowJs":true},"typeAcquisition":{"enable":true}}`, + "/user/username/projects/b/package.json": `{"name":"b","dependencies":{"commander":"^14.0.0"}}`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "commander": `declare const commander: { x: number }`, + "jquery": `declare const $: { x: number }`, + }, + }) + ctx := context.Background() + aURI := lsproto.DocumentUri("file:///user/username/projects/a/app.js") + bURI := lsproto.DocumentUri("file:///user/username/projects/b/app.js") + commanderTypings := projecttestutil.TestTypingsLocation + "/node_modules/@types/commander/index.d.ts" + session.DidOpenFile(ctx, aURI, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, aURI) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + assert.Assert(t, utils.WatchesFile(strings.ToLower(commanderTypings)), "the typings location should be watched") + + session.DidOpenFile(ctx, bURI, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: lsproto.DocumentUri("file://" + commanderTypings), + Type: lsproto.FileChangeTypeCreated, + }}) + ls, err := session.GetLanguageService(ctx, bURI) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(tspath.RootedFilePathFromNormalized(commanderTypings)) != nil, "the install's own writes should not discard its result") + }) + + t.Run("source edit delivered with an ATA result rejects obsolete typings", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": `import "jquery";`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "commander": `declare const commander: { x: number }`, + "jquery": `declare const $: { x: number }`, + }, + }) + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + jqueryInstallStarted := make(chan struct{}, 1) + releaseJqueryInstall := make(chan struct{}) + releaseCommanderInstall := make(chan struct{}) + var releaseOnce sync.Once + releaseCommander := func() { releaseOnce.Do(func() { close(releaseCommanderInstall) }) } + defer releaseCommander() + utils.NpmExecutor().NpmInstallFunc = func(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + switch { + case slices.Contains(args, "@types/jquery@latest"): + jqueryInstallStarted <- struct{}{} + <-releaseJqueryInstall + case slices.Contains(args, "@types/commander@latest"): + <-releaseCommanderInstall + } + return originalNpmInstall(ctx, cwd, args) + } + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + waitForInstall(t, jqueryInstallStarted) + close(releaseJqueryInstall) + session.WaitForBackgroundTasks() + session.DidChangeFile(ctx, uri, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{{ + WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: `import "commander";`}, + }}) ls, err := session.GetLanguageService(ctx, uri) assert.NilError(t, err) - typingsFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") - assert.Assert(t, typingsFile != nil, "jquery types should be available immediately after reopening") + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") == nil) + }) + + t.Run("package-owned typings deleted with the result invalidate discovery", func(t *testing.T) { + t.Parallel() + + const bundledTypes = "/user/username/projects/typings/foo.d.ts" + files := map[string]any{ + "/user/username/projects/project/app.js": `import "foo";`, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"foo":"^1.0.0"}}`, + "/user/username/projects/project/node_modules/foo/package.json": `{"name":"foo","types":"../../../typings/foo.d.ts"}`, + bundledTypes: `declare const bundledFoo: { x: number }`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"foo": `declare const foo: { x: number }`}, + }) + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + + assert.NilError(t, utils.FS().Remove(bundledTypes)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: lsproto.DocumentUri("file://" + bundledTypes), + Type: lsproto.FileChangeTypeDeleted, + }}) + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, !slices.Contains(ls.GetProgram().CommandLine().FileNames(), bundledTypes)) + session.WaitForBackgroundTasks() + ls, err = session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/foo/index.d.ts") != nil) + }) + + t.Run("ATA install for another project does not discard cached inferred typings", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + "/user/username/projects/c/app.js": ``, + "/user/username/projects/c/tsconfig.json": `{"compilerOptions":{"allowJs":true},"typeAcquisition":{"enable":true}}`, + "/user/username/projects/c/package.json": `{"name":"c","dependencies":{"commander":"^14.0.0"}}`, + } + session, _ := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "commander": `declare const commander: { x: number }`, + "jquery": `declare const $: { x: number }`, + }, + }) + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + cURI := lsproto.DocumentUri("file:///user/username/projects/c/app.js") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() + session.WaitForBackgroundTasks() + + session.DidOpenFile(ctx, cURI, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: lsproto.DocumentUri("file://" + projecttestutil.TestTypingsLocation + "/node_modules/@types/commander/index.d.ts"), + Type: lsproto.FileChangeTypeCreated, + }}) + _, err = session.GetLanguageService(ctx, cURI) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + inferred := session.Snapshot().ProjectCollection.InferredProject() + assert.Assert(t, inferred != nil && inferred.GetProgram() != nil) + assert.Assert(t, inferred.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + }) + + t.Run("excessive typings cache writes do not discard cached inferred typings", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + } + session, _ := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"jquery": `declare const $: { x: number }`}, + }) + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() + + events := make([]*lsproto.FileEvent, 1001) + for i := range events { + events[i] = &lsproto.FileEvent{ + Uri: lsproto.DocumentUri(fmt.Sprintf("file://%s/node_modules/@types/generated/%d.d.ts", projecttestutil.TestTypingsLocation, i)), + Type: lsproto.FileChangeTypeCreated, + } + } + session.DidChangeWatchedFiles(ctx, events) + session.WaitForBackgroundTasks() + + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + inferred := session.Snapshot().ProjectCollection.InferredProject() + assert.Assert(t, inferred != nil && inferred.GetProgram() != nil) + assert.Assert(t, inferred.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) }) + t.Run("excessive cache writes do not hide filesystem replacement invalidation", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"jquery": `declare const $: { x: number }`}, + }) + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() + + summary := project.FileChangeSummary{} + for i := range 1001 { + summary.Created.Add(lsproto.DocumentUri(fmt.Sprintf("file://%s/node_modules/@types/generated/%d.d.ts", projecttestutil.TestTypingsLocation, i))) + } + replacement := bundled.WrapFS(vfstest.FromMap(map[string]string{ + "/user/username/projects/project/app.js": "", + "/user/username/projects/project/package.json": `{"name":"test"}`, + projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts": `declare const $: { x: number }`, + }, tspath.CaseInsensitive)) + snapshot, err = session.APIUpdate(ctx, summary, &project.APISnapshotRequest{ + FileSystem: replacement, + ReplaceFileSystem: true, + }) + assert.NilError(t, err) + snapshot.Deref() + + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, &project.APISnapshotRequest{ + FileSystem: replacement, + OpenFiles: map[tspath.PathKey]tspath.RootedFilePath{ + utils.FS().CaseSensitivity().PathKey(uri.FileName().AsPath()): uri.FileName(), + }, + }) + assert.NilError(t, err) + defer snapshot.Deref() + inferred := snapshot.ProjectCollection.InferredProject() + assert.Assert(t, inferred != nil && inferred.GetProgram() != nil) + assert.Assert(t, inferred.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") == nil) + }) + + for _, configured := range []bool{false, true} { + name := "inferred" + if configured { + name = "configured" + } + t.Run("typings added by a manifest change reach the "+name+" program", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + } + if configured { + files["/user/username/projects/project/tsconfig.json"] = `{"compilerOptions":{"allowJs":true},"typeAcquisition":{"enable":true}}` + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "commander": `declare const commander: { x: number }`, + "jquery": `declare const $: { x: number }`, + }, + }) + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + packageJSONURI := lsproto.DocumentUri("file:///user/username/projects/project/package.json") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + + assert.NilError(t, utils.FS().WriteFile(packageJSONURI.FileName(), `{"name":"test","dependencies":{"commander":"^14.0.0","jquery":"^3.1.0"}}`)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{Uri: packageJSONURI, Type: lsproto.FileChangeTypeChanged}}) + _, err = session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + program := ls.GetProgram() + assert.Assert(t, program.GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + assert.Assert(t, program.GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/commander/index.d.ts") != nil) + }) + } + t.Run("inferred project rejects ATA result invalidated after closing last file", func(t *testing.T) { t.Parallel() diff --git a/tsc/internal/project/filechange.go b/tsc/internal/project/filechange.go index 251b63fcf335d..37b484fd6b57b 100644 --- a/tsc/internal/project/filechange.go +++ b/tsc/internal/project/filechange.go @@ -3,6 +3,7 @@ package project import ( "github.com/microsoft/TypeScript/tsc/internal/collections" "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" + "github.com/microsoft/TypeScript/tsc/internal/tspath" ) const excessiveChangeThreshold = 1000 @@ -96,3 +97,25 @@ func mergeFileChangeSummary(dst *FileChangeSummary, src FileChangeSummary) { dst.IncludesWatchChangeOutsideNodeModules = true } } + +func (f FileChangeSummary) withoutChangesWithin(directory tspath.RootedPath, caseSensitivity tspath.CaseSensitivity) FileChangeSummary { + if directory == "" { + return f + } + filter := func(uris collections.Set[lsproto.DocumentUri]) collections.Set[lsproto.DocumentUri] { + var result collections.Set[lsproto.DocumentUri] + for uri := range uris.Keys() { + fileName := uri.FileName().AsPath() + directoryKey := caseSensitivity.PathKey(directory) + fileNameKey := caseSensitivity.PathKey(fileName) + if directoryKey != fileNameKey && !directoryKey.ContainsPath(fileNameKey) { + result.Add(uri) + } + } + return result + } + f.Changed = filter(f.Changed) + f.Created = filter(f.Created) + f.Deleted = filter(f.Deleted) + return f +} diff --git a/tsc/internal/project/project.go b/tsc/internal/project/project.go index 481dd06c38c7b..4a6c1f1cabd23 100644 --- a/tsc/internal/project/project.go +++ b/tsc/internal/project/project.go @@ -256,7 +256,7 @@ func (s *inferredProjectATAState) apply(project *Project) { project.installedTypingsInfo = s.installedTypingsInfo project.installedTypingsFileNames = slices.Clone(s.installedTypingsFileNames) project.installedTypingsFilesToWatch = slices.Clone(s.installedTypingsFilesToWatch) - project.typingsFiles = slices.Clone(s.typingsFiles) + project.setTypingsFiles(slices.Clone(s.typingsFiles)) project.typingsWatch = s.typingsWatch project.installedTypingsSnapshotID = s.snapshotID if typingsFilesChanged { @@ -380,7 +380,7 @@ func NewProject( logger *logging.LogTree, ) *Project { if logger != nil { - logger.Log(fmt.Sprintf("Creating %sProject: %s, projectDirectory: %s", kind.String(), id, projectDirectory)) + logger.Log(fmt.Sprintf("Creating %sProject: %s, currentDirectory: %s", kind.String(), id, projectDirectory)) } project := &Project{ Kind: kind, @@ -558,6 +558,14 @@ func (p *Project) SetCommandLine(commandLine *tsoptions.ParsedCommandLine) { p.dirtyFilePath = "" } +func (p *Project) setTypingsFiles(typingsFiles []tspath.RootedFilePath) { + if !slices.Equal(p.typingsFiles, typingsFiles) { + p.commandLineWithTypingsFiles = nil + p.commandLineWithTypingsFilesOnce = sync.Once{} + } + p.typingsFiles = typingsFiles +} + // getCommandLineWithTypingsFiles returns the command line augmented with typing files if ATA is enabled. func (p *Project) getCommandLineWithTypingsFiles() *tsoptions.ParsedCommandLine { if len(p.typingsFiles) == 0 { diff --git a/tsc/internal/project/projectcollectionbuilder.go b/tsc/internal/project/projectcollectionbuilder.go index 2304aaf0731f0..03c8a406b366e 100644 --- a/tsc/internal/project/projectcollectionbuilder.go +++ b/tsc/internal/project/projectcollectionbuilder.go @@ -480,6 +480,16 @@ func (b *ProjectCollectionBuilder) DidChangeFiles(summary FileChangeSummary, log return true }) + // Handle opened file + if summary.Opened != "" || summary.Reopened != "" { + fileName := core.FirstNonZero(summary.Opened, summary.Reopened).FileName() + path := b.toPathKey(fileName) + openFileResult := b.ensureConfiguredProjectAndAncestorsForFile(fileName, path, logger) + b.cleanupConfiguredProjects(&openFileResult.retain, logger) + } +} + +func (b *ProjectCollectionBuilder) DidChangeTypingsWatchInputs(summary FileChangeSummary, logger *logging.LogTree) { b.forEachProject(func(entry dirty.Value[*Project]) bool { projectID := entry.Value().ID() if entry.ChangeIf( @@ -488,7 +498,6 @@ func (b *ProjectCollectionBuilder) DidChangeFiles(summary FileChangeSummary, log summary, project.installedTypingsFilesToWatch, project.typingsFiles, - b.sessionOptions.TypingsLocation, b.fs.fs.CaseSensitivity(), ) }, @@ -508,26 +517,16 @@ func (b *ProjectCollectionBuilder) DidChangeFiles(summary FileChangeSummary, log summary, b.inferredProjectATAState.installedTypingsFilesToWatch, b.inferredProjectATAState.typingsFiles, - b.sessionOptions.TypingsLocation, b.fs.fs.CaseSensitivity(), ) { b.invalidateInferredProjectATAState("typings watch changes", logger) } - - // Handle opened file - if summary.Opened != "" || summary.Reopened != "" { - fileName := core.FirstNonZero(summary.Opened, summary.Reopened).FileName() - path := b.toPathKey(fileName) - openFileResult := b.ensureConfiguredProjectAndAncestorsForFile(fileName, path, logger) - b.cleanupConfiguredProjects(&openFileResult.retain, logger) - } } func fileChangeSummaryAffectsTypingsWatch( summary FileChangeSummary, filesToWatch []tspath.RootedPath, typingsFiles []tspath.RootedFilePath, - typingsLocation tspath.RootedDirectoryPath, caseSensitivity tspath.CaseSensitivity, ) bool { if summary.InvalidateAll { @@ -539,8 +538,9 @@ func fileChangeSummaryAffectsTypingsWatch( affectsWatch := func(uri lsproto.DocumentUri) bool { fileName := uri.FileName().AsPath() return slices.ContainsFunc(slices.Concat(filesToWatch, core.Map(typingsFiles, func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() })), func(watchedPath tspath.RootedPath) bool { - if caseSensitivity.PathKey(watchedPath) == caseSensitivity.PathKey(fileName) || - caseSensitivity.PathKey(watchedPath).ContainsPath(caseSensitivity.PathKey(fileName)) { + watchedPathKey := caseSensitivity.PathKey(watchedPath) + fileNameKey := caseSensitivity.PathKey(fileName) + if watchedPathKey == fileNameKey || watchedPathKey.ContainsPath(fileNameKey) { return true } switch watchedPath.BaseName() { @@ -550,7 +550,7 @@ func fileChangeSummaryAffectsTypingsWatch( return caseSensitivity.ComparePaths(watchedPath.Directory().ResolveFile("bower.json").AsPath(), fileName) == 0 } return false - }) || typingsLocation != "" && caseSensitivity.ContainsPath(typingsLocation, fileName) + }) } for uri := range summary.Changed.Keys() { if affectsWatch(uri) { @@ -1039,7 +1039,7 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState p.installedTypingsSnapshotID = ataChange.SnapshotID p.installedTypingsFileNames = slices.Clone(ataChange.FileNames) p.installedTypingsFilesToWatch = slices.Clone(ataChange.TypingsFilesToWatch) - p.typingsFiles = ataChange.TypingsFiles + p.setTypingsFiles(ataChange.TypingsFiles) typingsWatchGlobs := getTypingsLocationsGlobs( slices.Concat(ataChange.TypingsFilesToWatch, core.Map(ataChange.TypingsFiles, func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() })), b.sessionOptions.TypingsLocation, @@ -1065,7 +1065,6 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState fileChanges, slices.Concat(ataChange.TypingsFilesToWatch, core.Map(ataChange.FileNames, func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() })), ataChange.TypingsFiles, - b.sessionOptions.TypingsLocation, b.fs.fs.CaseSensitivity(), ) { b.invalidateProjectATAState(projectID) diff --git a/tsc/internal/project/snapshot.go b/tsc/internal/project/snapshot.go index 9a1f20d668ee9..2634d362e4efb 100644 --- a/tsc/internal/project/snapshot.go +++ b/tsc/internal/project/snapshot.go @@ -499,6 +499,10 @@ func (s *Snapshot) Clone( start := time.Now() hadExcessiveWatchEvents := change.fileChanges.HasExcessiveWatchEvents() + var unfilteredFileChanges FileChangeSummary + if hadExcessiveWatchEvents { + unfilteredFileChanges = change.fileChanges.Clone() + } inferredContentMappers := s.inferredProjectContentMappers inferredContentMapperExtensions := s.inferredProjectContentMapperExtensions if change.contentMapperContributions != nil { @@ -519,6 +523,17 @@ func (s *Snapshot) Clone( overlays = layeredFS.Overlays() fs := newSnapshotFSBuilderFromSource(layeredFS, s.fs.cacheFiles, s.fs.cacheDirectories, s.fs.nodeModulesRealpathAliases) change.fileChanges = s.processFileChanges(fs, change.fileChanges, logger, change.contentMapperContributions, s.overlays(), overlays) + typingsWatchChanges := change.fileChanges + if hadExcessiveWatchEvents { + typingsWatchChanges = unfilteredFileChanges + typingsWatchChanges.InvalidateAll = typingsWatchChanges.InvalidateAll || change.fileChanges.InvalidateAll + } + if typingsLocation := store.options.TypingsLocation; typingsLocation != "" { + typingsWatchChanges = typingsWatchChanges.withoutChangesWithin(typingsLocation.AsPath(), fs.fs.CaseSensitivity()) + if realTypingsLocation := fs.fs.Realpath(typingsLocation.AsPath()); realTypingsLocation != typingsLocation.AsPath() { + typingsWatchChanges = typingsWatchChanges.withoutChangesWithin(realTypingsLocation, fs.fs.CaseSensitivity()) + } + } compilerOptionsForInferredProjects := s.compilerOptionsForInferredProjects if change.compilerOptionsForInferredProjects != nil { @@ -552,7 +567,7 @@ func (s *Snapshot) Clone( client, ) - if hadExcessiveWatchEvents { + if typingsWatchChanges.HasExcessiveWatchEvents() { projectCollectionBuilder.DidInvalidateTypingsWatchState(logger.Fork("DidInvalidateTypingsWatchState")) } @@ -581,8 +596,11 @@ func (s *Snapshot) Clone( if !change.fileChanges.IsEmpty() { projectCollectionBuilder.DidChangeFiles(change.fileChanges, logger.Fork("DidChangeFiles")) } + if !typingsWatchChanges.IsEmpty() { + projectCollectionBuilder.DidChangeTypingsWatchInputs(typingsWatchChanges, logger.Fork("DidChangeTypingsWatchInputs")) + } if len(change.ataChanges) != 0 { - projectCollectionBuilder.DidUpdateATAState(change.ataChanges, change.fileChanges, logger.Fork("DidUpdateATAState")) + projectCollectionBuilder.DidUpdateATAState(change.ataChanges, typingsWatchChanges, logger.Fork("DidUpdateATAState")) } var apiError error From 79634eb38cd1ed3858d5524cf97ef408d3794af5 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:58:23 -0700 Subject: [PATCH 06/12] Refresh cached ATA entry points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installed packages can change their declaration entry points without a version change or deletion of the old file. Preserve package identities so active and dormant projects reject obsolete typings without treating an install’s own cache writes as stale discovery inputs. --- tsc/internal/project/ata/ata.go | 90 +++++++++++++--- tsc/internal/project/ata/ata_test.go | 101 ++++++++++++++++-- tsc/internal/project/project.go | 48 +++++---- .../project/projectcollectionbuilder.go | 80 +++++++++++--- tsc/internal/project/session.go | 16 +-- tsc/internal/project/snapshot.go | 9 +- tsc/internal/project/snapshot_test.go | 22 ++++ 7 files changed, 301 insertions(+), 65 deletions(-) diff --git a/tsc/internal/project/ata/ata.go b/tsc/internal/project/ata/ata.go index 08daefbf128ea..6ca17a93c4053 100644 --- a/tsc/internal/project/ata/ata.go +++ b/tsc/internal/project/ata/ata.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "slices" + "strings" "sync" "sync/atomic" @@ -113,8 +114,14 @@ type TypingsInstallRequest struct { } type TypingsInstallResult struct { - TypingsFiles []tspath.RootedFilePath - FilesToWatch []tspath.RootedPath + TypingsFiles []tspath.RootedFilePath + FilesToWatch []tspath.RootedPath + CacheEntryPoints []CachedTypingEntryPoint +} + +type CachedTypingEntryPoint struct { + PackageName string + FileName tspath.RootedFilePath } func (ti *TypingsInstaller) InstallTypings(ctx context.Context, request *TypingsInstallRequest) (*TypingsInstallResult, error) { @@ -130,48 +137,101 @@ func (ti *TypingsInstaller) InstallTypings(ctx context.Context, request *Typings func (ti *TypingsInstaller) discoverAndInstallTypings(ctx context.Context, request *TypingsInstallRequest) (*TypingsInstallResult, error) { ti.init(ctx, request.FS, request.Logger) + cachedTypings := ti.resolveCachedTypings(request.FS) + makeResult := func(files []tspath.RootedFilePath, filesToWatch []tspath.RootedPath) *TypingsInstallResult { + result := &TypingsInstallResult{TypingsFiles: files, FilesToWatch: filesToWatch} + cachedTypings.Range(func(name string, typing *CachedTyping) bool { + if slices.Contains(files, typing.TypingsLocation) { + result.CacheEntryPoints = append(result.CacheEntryPoints, CachedTypingEntryPoint{ + PackageName: name, FileName: typing.TypingsLocation, + }) + } + return true + }) + slices.SortFunc(result.CacheEntryPoints, func(a, b CachedTypingEntryPoint) int { + return strings.Compare(a.PackageName, b.PackageName) + }) + return result + } cachedTypingPaths, newTypingNames, filesToWatch := DiscoverTypings( request.FS, request.Logger, request.TypingsInfo, request.FileNames, request.ProjectRootPath, - &ti.packageNameToTypingLocation, + cachedTypings, ti.typesRegistry, ) requestId := ti.installRunCount.Add(1) // install typings if len(newTypingNames) > 0 { - filteredTypings := ti.filterTypings(request.Logger, newTypingNames) + filteredTypings := ti.filterTypings(request.Logger, newTypingNames, cachedTypings) if len(filteredTypings) != 0 { - typingsFiles, err := ti.installTypings(ctx, requestId, cachedTypingPaths, filteredTypings, request.Logger) + typingsFiles, err := ti.installTypings(ctx, requestId, cachedTypingPaths, filteredTypings, cachedTypings, request.Logger) if err != nil { return nil, err } - return &TypingsInstallResult{ - TypingsFiles: typingsFiles, - FilesToWatch: filesToWatch, - }, nil + return makeResult(typingsFiles, filesToWatch), nil } request.Logger.Log("ATA:: All typings are known to be missing or invalid - no need to install more typings") } else { request.Logger.Log("ATA:: No new typings were requested as a result of typings discovery") } - return &TypingsInstallResult{ - TypingsFiles: cachedTypingPaths, - FilesToWatch: filesToWatch, - }, nil + return makeResult(cachedTypingPaths, filesToWatch), nil // !!! sheetal events to send // this.event(response, "setTypings"); } +// Resolve cached entry points afresh: a package can change its types field +// without changing its version or deleting the previously resolved file. +func (ti *TypingsInstaller) resolveCachedTypings(fs vfs.FS) *collections.SyncMap[string, *CachedTyping] { + resolver := module.NewResolver(module.ResolverOptions{ + Host: &resolutionHost{fs: fs, currentDirectory: ti.typingsLocation}, + CompilerOptions: &core.CompilerOptions{ModuleResolution: core.ModuleResolutionKindNodeNext}, + }) + result := &collections.SyncMap[string, *CachedTyping]{} + ti.packageNameToTypingLocation.Range(func(name string, typing *CachedTyping) bool { + if fileName := ti.typingToFileName(resolver, name); fileName != "" { + result.Store(name, &CachedTyping{TypingsLocation: fileName, Version: typing.Version}) + } + return true + }) + return result +} + +func NewCachedTypingEntryPointValidator(fs vfs.FS, location tspath.RootedDirectoryPath) func([]CachedTypingEntryPoint) bool { + var resolver module.Resolver + resolved := map[string]tspath.RootedFilePath{} + return func(entries []CachedTypingEntryPoint) bool { + for _, entry := range entries { + fileName, ok := resolved[entry.PackageName] + if !ok { + if resolver == nil { + resolver = module.NewResolver(module.ResolverOptions{ + Host: &resolutionHost{fs: fs, currentDirectory: location}, + CompilerOptions: &core.CompilerOptions{ModuleResolution: core.ModuleResolutionKindNodeNext}, + }) + } + result, _, _ := resolver.ResolveModuleName(entry.PackageName, location.ResolveFile("index.d.ts"), core.ModuleKindNone, nil) + fileName = result.ResolvedFileName + resolved[entry.PackageName] = fileName + } + if fileName == "" || fs.CaseSensitivity().ComparePaths(fileName.AsPath(), entry.FileName.AsPath()) != 0 { + return false + } + } + return true + } +} + func (ti *TypingsInstaller) installTypings( ctx context.Context, requestID int32, currentlyCachedTypings []tspath.RootedFilePath, filteredTypings []string, + cachedTypings *collections.SyncMap[string, *CachedTyping], logger logging.Logger, ) ([]tspath.RootedFilePath, error) { // !!! sheetal events to send @@ -219,6 +279,7 @@ func (ti *TypingsInstaller) installTypings( newVersion := semver.MustParse(useVersion) newTyping := &CachedTyping{TypingsLocation: typingFile, Version: &newVersion} ti.packageNameToTypingLocation.Store(packageName, newTyping) + cachedTypings.Store(packageName, newTyping) installedTypingFiles = append(installedTypingFiles, typingFile) } logger.Log(fmt.Sprintf("ATA:: Installed typing files %v", installedTypingFiles)) @@ -331,6 +392,7 @@ func installNpmPackages( func (ti *TypingsInstaller) filterTypings( logger logging.Logger, typingsToInstall []string, + cachedTypings *collections.SyncMap[string, *CachedTyping], ) []string { var result []string for _, typing := range typingsToInstall { @@ -351,7 +413,7 @@ func (ti *TypingsInstaller) filterTypings( logger.Log(fmt.Sprintf("ATA:: '%s':: Entry for package '%s' does not exist in local types registry - skipping...", typing, typingKey)) continue } - if typingLocation, ok := ti.packageNameToTypingLocation.Load(typingKey); ok && isTypingUpToDate(typingLocation, typesRegistryEntry) { + if typingLocation, ok := cachedTypings.Load(typingKey); ok && isTypingUpToDate(typingLocation, typesRegistryEntry) { logger.Log(fmt.Sprintf("ATA:: '%s':: '%s' already has an up-to-date typing - skipping...", typing, typingKey)) continue } diff --git a/tsc/internal/project/ata/ata_test.go b/tsc/internal/project/ata/ata_test.go index 277b0b06f8256..e7a62de96df42 100644 --- a/tsc/internal/project/ata/ata_test.go +++ b/tsc/internal/project/ata/ata_test.go @@ -986,6 +986,88 @@ func TestATA(t *testing.T) { assert.Assert(t, typingsFile == nil, "jquery types should not be reused after the manifest changes") }) + for _, variant := range []string{"regular", "symlinked cache", "symlinked package"} { + t.Run("cached typings entry point changes without manifest discovery "+variant, func(t *testing.T) { + t.Parallel() + + packageDirectory := projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery" + switch variant { + case "symlinked cache": + packageDirectory = "/real/cache/node_modules/@types/jquery" + case "symlinked package": + packageDirectory = "/real/jquery" + } + manifest := packageDirectory + "/package.json" + indexFile := tspath.RootedFilePathFromNormalized(packageDirectory + "/index.d.ts") + newFile := tspath.RootedFilePathFromNormalized(packageDirectory + "/new.d.ts") + files := map[string]any{ + "/user/username/projects/project/jquery.js": ``, + manifest: `{"name":"@types/jquery","types":"index.d.ts"}`, + packageDirectory + "/index.d.ts": `declare const oldEntry: number;`, + packageDirectory + "/new.d.ts": `declare const newEntry: number;`, + projecttestutil.TestTypingsLocation + "/package.json": `{"devDependencies":{"@types/jquery":"^1.3.0"}}`, + projecttestutil.TestTypingsLocation + "/package-lock.json": `{"dependencies":{"@types/jquery":{"version":"1.3.0"}}}`, + } + switch variant { + case "symlinked cache": + files[projecttestutil.TestTypingsLocation] = vfstest.Symlink("/real/cache") + for _, name := range []string{"package.json", "package-lock.json"} { + files["/real/cache/"+name] = files[projecttestutil.TestTypingsLocation+"/"+name] + delete(files, projecttestutil.TestTypingsLocation+"/"+name) + } + case "symlinked package": + files[projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery"] = vfstest.Symlink(packageDirectory) + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + TypesRegistry: []string{"jquery"}, + }) + ctx := context.Background() + session.DidChangeCompilerOptionsForInferredProjects(ctx, &core.CompilerOptions{ + AllowJs: core.TSTrue, + Types: []string{}, + }) + uri := lsproto.DocumentUri("file:///user/username/projects/project/jquery.js") + // Filename discovery acquires jquery without scanning manifests. + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(indexFile) != nil) + session.WaitForBackgroundTasks() + assert.Assert(t, utils.WatchesFile(strings.ToLower(manifest))) + + assert.NilError(t, utils.FS().WriteFile(tspath.RootedFilePathFromNormalized(manifest), `{"name":"@types/jquery","types":"new.d.ts"}`)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: lsproto.DocumentUri("file://" + manifest), Type: lsproto.FileChangeTypeChanged, + }}) + _, err = session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + ls, err = session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, slices.Contains(ls.GetProgram().CommandLine().FileNames(), newFile), "ATA should rediscover the current entry point") + assert.Assert(t, !slices.Contains(ls.GetProgram().CommandLine().FileNames(), indexFile), "ATA must remove the old entry point") + assert.Equal(t, len(utils.NpmExecutor().NpmInstallCalls()), 1, "changing a cached entry point should not reinstall an up-to-date package") + + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() + assert.NilError(t, utils.FS().WriteFile(tspath.RootedFilePathFromNormalized(manifest), `{"name":"@types/jquery","types":"index.d.ts"}`)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: lsproto.DocumentUri("file://" + manifest), Type: lsproto.FileChangeTypeChanged, + }}) + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + inferred := session.Snapshot().ProjectCollection.InferredProject() + assert.Assert(t, !slices.Contains(inferred.GetProgram().CommandLine().FileNames(), newFile), "dormant state must not restore the obsolete entry point") + session.WaitForBackgroundTasks() + ls, err = session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, slices.Contains(ls.GetProgram().CommandLine().FileNames(), indexFile)) + }) + } + t.Run("cached inferred typings survive an unbuilt replacement project", func(t *testing.T) { t.Parallel() @@ -1433,8 +1515,9 @@ func TestATA(t *testing.T) { session.DidCloseFile(ctx, uri) session.WaitForBackgroundTasks() replacement := bundled.WrapFS(vfstest.FromMap(map[string]string{ - "/user/username/projects/project/app.js": "", - "/user/username/projects/project/package.json": `{"name":"test"}`, + "/user/username/projects/project/app.js": "", + "/user/username/projects/project/package.json": `{"name":"test"}`, + projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts": `declare const $: { x: number }`, }, tspath.CaseInsensitive)) snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, &project.APISnapshotRequest{ FileSystem: replacement, @@ -1442,11 +1525,17 @@ func TestATA(t *testing.T) { }) assert.NilError(t, err) snapshot.Deref() - session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) - - ls, err := session.GetLanguageService(ctx, uri) + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, &project.APISnapshotRequest{ + FileSystem: replacement, + OpenFiles: map[tspath.PathKey]tspath.RootedFilePath{ + replacement.CaseSensitivity().PathKey(uri.FileName().AsPath()): uri.FileName(), + }, + }) assert.NilError(t, err) - typingsFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") + defer snapshot.Deref() + inferred := snapshot.ProjectCollection.InferredProject() + assert.Assert(t, inferred != nil && inferred.GetProgram() != nil) + typingsFile := inferred.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") assert.Assert(t, typingsFile == nil, "jquery types should not be reused after replacing the filesystem") }) diff --git a/tsc/internal/project/project.go b/tsc/internal/project/project.go index 4a6c1f1cabd23..a98664769cf51 100644 --- a/tsc/internal/project/project.go +++ b/tsc/internal/project/project.go @@ -193,7 +193,8 @@ type Project struct { installedTypingsFileNames []tspath.RootedFilePath // installedTypingsFilesToWatch are discovery inputs whose changes require // typings discovery to run again. - installedTypingsFilesToWatch []tspath.RootedPath + installedTypingsFilesToWatch []tspath.RootedPath + installedTypingCacheEntryPoints []ata.CachedTypingEntryPoint // ataInvalidationSnapshotID is the latest snapshot that invalidated this // project's ATA discovery inputs. ataInvalidationSnapshotID uint64 @@ -203,12 +204,13 @@ type Project struct { } type inferredProjectATAState struct { - installedTypingsInfo *ata.TypingsInfo - installedTypingsFileNames []tspath.RootedFilePath - installedTypingsFilesToWatch []tspath.RootedPath - typingsFiles []tspath.RootedFilePath - typingsWatch *WatchedFiles[PatternsAndIgnored] - snapshotID uint64 + installedTypingsInfo *ata.TypingsInfo + installedTypingsFileNames []tspath.RootedFilePath + installedTypingsFilesToWatch []tspath.RootedPath + installedTypingCacheEntryPoints []ata.CachedTypingEntryPoint + typingsFiles []tspath.RootedFilePath + typingsWatch *WatchedFiles[PatternsAndIgnored] + snapshotID uint64 } func (p *Project) inferredProjectATAState() *inferredProjectATAState { @@ -223,12 +225,13 @@ func (p *Project) inferredProjectATAState() *inferredProjectATAState { snapshotID = p.ProgramLastUpdate } return &inferredProjectATAState{ - installedTypingsInfo: p.installedTypingsInfo, - installedTypingsFileNames: slices.Clone(p.installedTypingsFileNames), - installedTypingsFilesToWatch: slices.Clone(p.installedTypingsFilesToWatch), - typingsFiles: slices.Clone(p.typingsFiles), - typingsWatch: p.typingsWatch, - snapshotID: snapshotID, + installedTypingsInfo: p.installedTypingsInfo, + installedTypingsFileNames: slices.Clone(p.installedTypingsFileNames), + installedTypingsFilesToWatch: slices.Clone(p.installedTypingsFilesToWatch), + installedTypingCacheEntryPoints: slices.Clone(p.installedTypingCacheEntryPoints), + typingsFiles: slices.Clone(p.typingsFiles), + typingsWatch: p.typingsWatch, + snapshotID: snapshotID, } } @@ -236,7 +239,7 @@ func (s *inferredProjectATAState) canApply(project *Project, fs *snapshotFSBuild if s == nil || s.installedTypingsInfo == nil { return false } - if !watchEnabled && len(s.installedTypingsFilesToWatch) > 0 { + if !watchEnabled && (len(s.installedTypingsFilesToWatch) > 0 || len(s.typingsFiles) > 0) { return false } if !s.installedTypingsInfo.Equals(project.ComputeTypingsInfo()) || @@ -256,6 +259,7 @@ func (s *inferredProjectATAState) apply(project *Project) { project.installedTypingsInfo = s.installedTypingsInfo project.installedTypingsFileNames = slices.Clone(s.installedTypingsFileNames) project.installedTypingsFilesToWatch = slices.Clone(s.installedTypingsFilesToWatch) + project.installedTypingCacheEntryPoints = slices.Clone(s.installedTypingCacheEntryPoints) project.setTypingsFiles(slices.Clone(s.typingsFiles)) project.typingsWatch = s.typingsWatch project.installedTypingsSnapshotID = s.snapshotID @@ -276,8 +280,9 @@ func (s *inferredProjectATAState) applyWatchState(project *Project) { project.installedTypingsFileNames = slices.Clone(s.installedTypingsFileNames) project.installedTypingsFilesToWatch = slices.Concat( slices.Clone(s.installedTypingsFilesToWatch), - core.Map(s.typingsFiles, func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() }), + core.Map(typingDiscoveryFiles(s.typingsFiles, s.installedTypingCacheEntryPoints), func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() }), ) + project.installedTypingCacheEntryPoints = slices.Clone(s.installedTypingCacheEntryPoints) project.typingsWatch = s.typingsWatch project.installedTypingsSnapshotID = s.snapshotID } @@ -533,12 +538,13 @@ func (p *Project) Clone() *Project { moduleResolverFactory: p.moduleResolverFactory, moduleResolverID: p.moduleResolverID, - installedTypingsInfo: p.installedTypingsInfo, - installedTypingsFileNames: p.installedTypingsFileNames, - installedTypingsFilesToWatch: p.installedTypingsFilesToWatch, - typingsFiles: p.typingsFiles, - ataInvalidationSnapshotID: p.ataInvalidationSnapshotID, - installedTypingsSnapshotID: p.installedTypingsSnapshotID, + installedTypingsInfo: p.installedTypingsInfo, + installedTypingsFileNames: p.installedTypingsFileNames, + installedTypingsFilesToWatch: p.installedTypingsFilesToWatch, + installedTypingCacheEntryPoints: p.installedTypingCacheEntryPoints, + typingsFiles: p.typingsFiles, + ataInvalidationSnapshotID: p.ataInvalidationSnapshotID, + installedTypingsSnapshotID: p.installedTypingsSnapshotID, } } diff --git a/tsc/internal/project/projectcollectionbuilder.go b/tsc/internal/project/projectcollectionbuilder.go index 03c8a406b366e..441db309e01a6 100644 --- a/tsc/internal/project/projectcollectionbuilder.go +++ b/tsc/internal/project/projectcollectionbuilder.go @@ -18,6 +18,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/diagnostics" "github.com/microsoft/TypeScript/tsc/internal/ls/lsutil" "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" + "github.com/microsoft/TypeScript/tsc/internal/project/ata" "github.com/microsoft/TypeScript/tsc/internal/project/dirty" "github.com/microsoft/TypeScript/tsc/internal/project/logging" "github.com/microsoft/TypeScript/tsc/internal/tsoptions" @@ -34,11 +35,12 @@ const ( ) type ProjectCollectionBuilder struct { - sessionOptions *SessionOptions - parseCache *ParseCache - contentMappedParseCache *ContentMappedParseCache - extendedConfigCache *ExtendedConfigCache - contentMapperHost contentmapper.Host + sessionOptions *SessionOptions + cachedTypingEntryPointsAreCurrent func([]ata.CachedTypingEntryPoint) bool + parseCache *ParseCache + contentMappedParseCache *ContentMappedParseCache + extendedConfigCache *ExtendedConfigCache + contentMapperHost contentmapper.Host ctx context.Context fs *snapshotFSBuilder @@ -95,6 +97,7 @@ func newProjectCollectionBuilder( inferredContentMappers: inferredContentMappers, inferredContentMapperExtensions: inferredContentMapperExtensions, sessionOptions: sessionOptions, + cachedTypingEntryPointsAreCurrent: ata.NewCachedTypingEntryPointValidator(fs.fs, sessionOptions.TypingsLocation), parseCache: parseCache, contentMappedParseCache: contentMappedParseCache, extendedConfigCache: extendedConfigCache, @@ -497,7 +500,7 @@ func (b *ProjectCollectionBuilder) DidChangeTypingsWatchInputs(summary FileChang return fileChangeSummaryAffectsTypingsWatch( summary, project.installedTypingsFilesToWatch, - project.typingsFiles, + typingDiscoveryFiles(project.typingsFiles, project.installedTypingCacheEntryPoints), b.fs.fs.CaseSensitivity(), ) }, @@ -516,7 +519,7 @@ func (b *ProjectCollectionBuilder) DidChangeTypingsWatchInputs(summary FileChang if b.inferredProjectATAState != nil && fileChangeSummaryAffectsTypingsWatch( summary, b.inferredProjectATAState.installedTypingsFilesToWatch, - b.inferredProjectATAState.typingsFiles, + typingDiscoveryFiles(b.inferredProjectATAState.typingsFiles, b.inferredProjectATAState.installedTypingCacheEntryPoints), b.fs.fs.CaseSensitivity(), ) { b.invalidateInferredProjectATAState("typings watch changes", logger) @@ -532,7 +535,7 @@ func fileChangeSummaryAffectsTypingsWatch( if summary.InvalidateAll { return true } - if len(filesToWatch) == 0 { + if len(filesToWatch) == 0 && len(typingsFiles) == 0 { return false } affectsWatch := func(uri lsproto.DocumentUri) bool { @@ -570,6 +573,45 @@ func fileChangeSummaryAffectsTypingsWatch( return false } +func typingDiscoveryFiles(files []tspath.RootedFilePath, entries []ata.CachedTypingEntryPoint) []tspath.RootedFilePath { + return core.Filter(files, func(file tspath.RootedFilePath) bool { + return !slices.ContainsFunc(entries, func(entry ata.CachedTypingEntryPoint) bool { return entry.FileName == file }) + }) +} + +func (b *ProjectCollectionBuilder) DidChangeCachedTypingEntryPoints(summary FileChangeSummary, logger *logging.LogTree) { + // Cache outputs do not advance the discovery invalidation generation: a + // result delivered with an install's writes can already contain the new entry points. + affected := func(entries []ata.CachedTypingEntryPoint) bool { + var paths []tspath.RootedPath + for _, entry := range entries { + packageDirectory := b.sessionOptions.TypingsLocation.ResolveDirectory("node_modules/@types/" + entry.PackageName) + paths = append(paths, packageDirectory.AsPath(), b.fs.fs.Realpath(packageDirectory.AsPath()), entry.FileName.AsPath()) + } + return fileChangeSummaryAffectsTypingsWatch(summary, paths, nil, b.fs.fs.CaseSensitivity()) && + !b.cachedTypingEntryPointsAreCurrent(entries) + } + b.forEachProject(func(entry dirty.Value[*Project]) bool { + entry.ChangeIf( + func(project *Project) bool { + return project.installedTypingsInfo != nil && + affected(project.installedTypingCacheEntryPoints) + }, + func(project *Project) { + project.installedTypingsInfo = nil + project.setTypingsFiles(nil) + project.dirty = true + project.dirtyFilePath = "" + }, + ) + return true + }) + if b.inferredProjectATAState != nil && + affected(b.inferredProjectATAState.installedTypingCacheEntryPoints) { + b.clearInferredProjectATAState("cached typing entry points changed", logger) + } +} + func (b *ProjectCollectionBuilder) DidInvalidateTypingsWatchState(logger *logging.LogTree) { b.forEachProject(func(entry dirty.Value[*Project]) bool { b.invalidateProjectATAState(entry.Value().ID()) @@ -1039,6 +1081,7 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState p.installedTypingsSnapshotID = ataChange.SnapshotID p.installedTypingsFileNames = slices.Clone(ataChange.FileNames) p.installedTypingsFilesToWatch = slices.Clone(ataChange.TypingsFilesToWatch) + p.installedTypingCacheEntryPoints = slices.Clone(ataChange.TypingCacheEntryPoints) p.setTypingsFiles(ataChange.TypingsFiles) typingsWatchGlobs := getTypingsLocationsGlobs( slices.Concat(ataChange.TypingsFilesToWatch, core.Map(ataChange.TypingsFiles, func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() })), @@ -1055,6 +1098,12 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState for projectID, ataChange := range ataChanges { logger.Embed(ataChange.Logs) + if !b.cachedTypingEntryPointsAreCurrent(ataChange.TypingCacheEntryPoints) { + if logger != nil { + logger.Logf("Ignoring ATA state with obsolete cached typing entry points for project %s", projectID) + } + continue + } if ataChange.SnapshotID < installedTypingsSnapshotID(projectID) { if logger != nil { logger.Logf("Ignoring ATA state older than installed state for project %s", projectID) @@ -1064,7 +1113,7 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState if fileChangeSummaryAffectsTypingsWatch( fileChanges, slices.Concat(ataChange.TypingsFilesToWatch, core.Map(ataChange.FileNames, func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() })), - ataChange.TypingsFiles, + typingDiscoveryFiles(ataChange.TypingsFiles, ataChange.TypingCacheEntryPoints), b.fs.fs.CaseSensitivity(), ) { b.invalidateProjectATAState(projectID) @@ -1093,12 +1142,13 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState b.fs.fs.CaseSensitivity(), ) b.inferredProjectATAState = &inferredProjectATAState{ - installedTypingsInfo: ataChange.TypingsInfo, - installedTypingsFileNames: slices.Clone(ataChange.FileNames), - installedTypingsFilesToWatch: slices.Clone(ataChange.TypingsFilesToWatch), - typingsFiles: slices.Clone(ataChange.TypingsFiles), - typingsWatch: typingsWatch.Clone(typingsWatchGlobs), - snapshotID: ataChange.SnapshotID, + installedTypingsInfo: ataChange.TypingsInfo, + installedTypingsFileNames: slices.Clone(ataChange.FileNames), + installedTypingsFilesToWatch: slices.Clone(ataChange.TypingsFilesToWatch), + installedTypingCacheEntryPoints: slices.Clone(ataChange.TypingCacheEntryPoints), + typingsFiles: slices.Clone(ataChange.TypingsFiles), + typingsWatch: typingsWatch.Clone(typingsWatchGlobs), + snapshotID: ataChange.SnapshotID, } } } else if syntheticProjectID, ok := projectID.Synthetic(); ok { diff --git a/tsc/internal/project/session.go b/tsc/internal/project/session.go index ea87db5a079fe..d408aa0aaed9d 100644 --- a/tsc/internal/project/session.go +++ b/tsc/internal/project/session.go @@ -2075,19 +2075,21 @@ func (s *Session) triggerATAForUpdatedProjects(newSnapshot *Snapshot) { project.installedTypingsInfo == nil || !typingsInfo.Equals(*project.installedTypingsInfo) || !slices.Equal(fileNames, project.installedTypingsFileNames) || - !slices.Equal(result.FilesToWatch, project.installedTypingsFilesToWatch) { + !slices.Equal(result.FilesToWatch, project.installedTypingsFilesToWatch) || + !slices.Equal(result.CacheEntryPoints, project.installedTypingCacheEntryPoints) { s.pendingATAChangesMu.Lock() projectID := project.ID() pendingChange := s.pendingATAChanges[projectID] updatedPendingChange := pendingChange == nil || pendingChange.SnapshotID <= newSnapshot.ID() if updatedPendingChange { s.pendingATAChanges[projectID] = &ATAStateChange{ - SnapshotID: newSnapshot.ID(), - TypingsInfo: &typingsInfo, - FileNames: fileNames, - TypingsFiles: result.TypingsFiles, - TypingsFilesToWatch: result.FilesToWatch, - Logs: logTree, + SnapshotID: newSnapshot.ID(), + TypingsInfo: &typingsInfo, + FileNames: fileNames, + TypingsFiles: result.TypingsFiles, + TypingsFilesToWatch: result.FilesToWatch, + TypingCacheEntryPoints: result.CacheEntryPoints, + Logs: logTree, } } s.pendingATAChangesMu.Unlock() diff --git a/tsc/internal/project/snapshot.go b/tsc/internal/project/snapshot.go index 2634d362e4efb..0941b452bfc6d 100644 --- a/tsc/internal/project/snapshot.go +++ b/tsc/internal/project/snapshot.go @@ -426,8 +426,9 @@ type ATAStateChange struct { // TypingsFiles is the new list of typing files for the project. TypingsFiles []tspath.RootedFilePath // TypingsFilesToWatch is the new list of typing files to watch for changes. - TypingsFilesToWatch []tspath.RootedPath - Logs *logging.LogTree + TypingsFilesToWatch []tspath.RootedPath + TypingCacheEntryPoints []ata.CachedTypingEntryPoint + Logs *logging.LogTree } func (s *Snapshot) Clone( @@ -528,6 +529,7 @@ func (s *Snapshot) Clone( typingsWatchChanges = unfilteredFileChanges typingsWatchChanges.InvalidateAll = typingsWatchChanges.InvalidateAll || change.fileChanges.InvalidateAll } + typingCacheChanges := typingsWatchChanges if typingsLocation := store.options.TypingsLocation; typingsLocation != "" { typingsWatchChanges = typingsWatchChanges.withoutChangesWithin(typingsLocation.AsPath(), fs.fs.CaseSensitivity()) if realTypingsLocation := fs.fs.Realpath(typingsLocation.AsPath()); realTypingsLocation != typingsLocation.AsPath() { @@ -599,6 +601,9 @@ func (s *Snapshot) Clone( if !typingsWatchChanges.IsEmpty() { projectCollectionBuilder.DidChangeTypingsWatchInputs(typingsWatchChanges, logger.Fork("DidChangeTypingsWatchInputs")) } + if !typingCacheChanges.IsEmpty() { + projectCollectionBuilder.DidChangeCachedTypingEntryPoints(typingCacheChanges, logger.Fork("DidChangeCachedTypingEntryPoints")) + } if len(change.ataChanges) != 0 { projectCollectionBuilder.DidUpdateATAState(change.ataChanges, typingsWatchChanges, logger.Fork("DidUpdateATAState")) } diff --git a/tsc/internal/project/snapshot_test.go b/tsc/internal/project/snapshot_test.go index 76a1b07d8cf7a..6584a2d89390f 100644 --- a/tsc/internal/project/snapshot_test.go +++ b/tsc/internal/project/snapshot_test.go @@ -14,6 +14,28 @@ import ( "gotest.tools/v3/assert" ) +func TestTypingsWatchWithoutDiscoveryDirectories(t *testing.T) { + t.Parallel() + + const file = "/workspace/typings/jquery.d.ts" + for _, changeType := range []lsproto.FileChangeType{ + lsproto.FileChangeTypeCreated, lsproto.FileChangeTypeChanged, lsproto.FileChangeTypeDeleted, + } { + summary := FileChangeSummary{} + uri := lsproto.DocumentUri("file://" + file) + switch changeType { + case lsproto.FileChangeTypeCreated: + summary.Created.Add(uri) + case lsproto.FileChangeTypeChanged: + summary.Changed.Add(uri) + case lsproto.FileChangeTypeDeleted: + summary.Deleted.Add(uri) + } + assert.Assert(t, fileChangeSummaryAffectsTypingsWatch(summary, nil, []tspath.RootedFilePath{file}, tspath.CaseInsensitive)) + assert.Assert(t, !fileChangeSummaryAffectsTypingsWatch(summary, nil, nil, tspath.CaseInsensitive)) + } +} + func TestSnapshot(t *testing.T) { t.Parallel() if !bundled.Embedded { From 3c2b91517361a89645e960295d91211cb7cc4ad0 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:17:00 -0700 Subject: [PATCH 07/12] Validate effective ATA discovery inputs Equivalent loose files should retain acquired typings without restoring results made obsolete while no inferred project exists. Compare effective discovery inputs and revalidate installation demand before acceptance. Directory deletion notifications must also invalidate ATA-only manifests that are not represented in the compiler file cache. --- tsc/internal/project/ata/ata.go | 17 ++-- tsc/internal/project/ata/ata_test.go | 99 +++++++++++++++++++ tsc/internal/project/ata/discovertypings.go | 83 +++++++++++++--- .../project/ata/discovertypings_test.go | 33 +++++++ tsc/internal/project/project.go | 13 ++- .../project/projectcollectionbuilder.go | 15 ++- tsc/internal/project/session.go | 4 +- tsc/internal/project/snapshot.go | 9 +- tsc/internal/project/snapshot_test.go | 12 +++ 9 files changed, 250 insertions(+), 35 deletions(-) diff --git a/tsc/internal/project/ata/ata.go b/tsc/internal/project/ata/ata.go index 6ca17a93c4053..7783d3f55ae71 100644 --- a/tsc/internal/project/ata/ata.go +++ b/tsc/internal/project/ata/ata.go @@ -117,6 +117,7 @@ type TypingsInstallResult struct { TypingsFiles []tspath.RootedFilePath FilesToWatch []tspath.RootedPath CacheEntryPoints []CachedTypingEntryPoint + Discovery *TypingsDiscovery } type CachedTypingEntryPoint struct { @@ -138,8 +139,12 @@ func (ti *TypingsInstaller) discoverAndInstallTypings(ctx context.Context, reque ti.init(ctx, request.FS, request.Logger) cachedTypings := ti.resolveCachedTypings(request.FS) + cachedTypingPaths, newTypingNames, filesToWatch, discovery := discoverTypings( + request.FS, request.Logger, request.TypingsInfo, request.FileNames, + request.ProjectRootPath, cachedTypings, ti.typesRegistry, + ) makeResult := func(files []tspath.RootedFilePath, filesToWatch []tspath.RootedPath) *TypingsInstallResult { - result := &TypingsInstallResult{TypingsFiles: files, FilesToWatch: filesToWatch} + result := &TypingsInstallResult{TypingsFiles: files, FilesToWatch: filesToWatch, Discovery: discovery} cachedTypings.Range(func(name string, typing *CachedTyping) bool { if slices.Contains(files, typing.TypingsLocation) { result.CacheEntryPoints = append(result.CacheEntryPoints, CachedTypingEntryPoint{ @@ -153,16 +158,6 @@ func (ti *TypingsInstaller) discoverAndInstallTypings(ctx context.Context, reque }) return result } - cachedTypingPaths, newTypingNames, filesToWatch := DiscoverTypings( - request.FS, - request.Logger, - request.TypingsInfo, - request.FileNames, - request.ProjectRootPath, - cachedTypings, - ti.typesRegistry, - ) - requestId := ti.installRunCount.Add(1) // install typings if len(newTypingNames) > 0 { diff --git a/tsc/internal/project/ata/ata_test.go b/tsc/internal/project/ata/ata_test.go index e7a62de96df42..71f1282a3bc02 100644 --- a/tsc/internal/project/ata/ata_test.go +++ b/tsc/internal/project/ata/ata_test.go @@ -396,6 +396,105 @@ func TestATA(t *testing.T) { assert.Assert(t, typingsFile != nil, "jquery types should be available immediately after reopening") }) + for _, scenario := range []string{"equivalent roots", "equivalent roots after pending install", "first install manifest change", "first install bower change", "directory deletion"} { + t.Run("inferred project discovery reuse "+scenario, func(t *testing.T) { + t.Parallel() + const directory = "/user/username/projects/project" + const app = directory + "/app.js" + const other = directory + "/other.js" + manifest := directory + "/package.json" + if scenario == "directory deletion" || scenario == "first install bower change" { + manifest = directory + "/bower.json" + } + files := map[string]any{ + app: "", other: "", manifest: `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"jquery": `declare const $: { x: number }`}, + }) + ctx := context.Background() + uri := lsproto.DocumentUri("file://" + app) + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var once sync.Once + release := func() { once.Do(func() { close(releaseInstall) }) } + defer release() + pendingInstall := scenario == "first install manifest change" || scenario == "first install bower change" || scenario == "equivalent roots after pending install" + if pendingInstall { + install := utils.NpmExecutor().NpmInstallFunc + utils.NpmExecutor().NpmInstallFunc = func(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + if slices.Contains(args, "@types/jquery@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return install(ctx, cwd, args) + } + } + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + if pendingInstall { + waitForInstall(t, installStarted) + } else { + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + } + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() + + switch scenario { + case "equivalent roots": + uri = lsproto.DocumentUri("file://" + other) + case "equivalent roots after pending install": + release() + session.WaitForBackgroundTasks() + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + uri = lsproto.DocumentUri("file://" + other) + case "first install manifest change", "first install bower change": + assert.NilError(t, utils.FS().WriteFile(tspath.RootedFilePathFromNormalized(manifest), `{"name":"test"}`)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: lsproto.DocumentUri("file://" + manifest), Type: lsproto.FileChangeTypeChanged, + }}) + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + release() + session.WaitForBackgroundTasks() + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + case "directory deletion": + assert.NilError(t, utils.FS().Remove(tspath.RootedFilePathFromNormalized(manifest).AsPath())) + assert.NilError(t, utils.FS().Remove(app)) + assert.NilError(t, utils.FS().Remove(other)) + assert.NilError(t, utils.FS().Remove(directory)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: "file://" + directory, Type: lsproto.FileChangeTypeDeleted, + }}) + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + assert.NilError(t, utils.FS().WriteFile(app, "")) + assert.NilError(t, utils.FS().WriteFile(tspath.RootedFilePathFromNormalized(manifest), `{"name":"test"}`)) + } + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + inferred := session.Snapshot().ProjectCollection.InferredProject() + assert.Assert(t, inferred != nil && inferred.GetProgram() != nil) + hasTypings := inferred.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil + assert.Equal(t, hasTypings, strings.HasPrefix(scenario, "equivalent roots"), "initial reopened program must use only current discovery inputs") + session.WaitForBackgroundTasks() + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + hasTypings = ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil + assert.Equal(t, hasTypings, strings.HasPrefix(scenario, "equivalent roots"), "fresh ATA must preserve the corrected discovery demand") + }) + } + t.Run("inferred project retains ATA result completed after closing last file", func(t *testing.T) { t.Parallel() diff --git a/tsc/internal/project/ata/discovertypings.go b/tsc/internal/project/ata/discovertypings.go index 0fcc5d8a40dd3..d21eb35de25ef 100644 --- a/tsc/internal/project/ata/discovertypings.go +++ b/tsc/internal/project/ata/discovertypings.go @@ -35,6 +35,29 @@ func DiscoverTypings( packageNameToTypingLocation *collections.SyncMap[string, *CachedTyping], typesRegistry map[string]map[string]string, ) (cachedTypingPaths []tspath.RootedFilePath, newTypingNames []string, filesToWatch []tspath.RootedPath) { + cachedTypingPaths, newTypingNames, filesToWatch, _ = discoverTypings(fs, logger, typingsInfo, fileNames, projectRootPath, packageNameToTypingLocation, typesRegistry) + return +} + +type TypingsDiscovery struct { + inferredTypings map[string]tspath.RootedFilePath +} + +func (d *TypingsDiscovery) IsCurrent(fs vfs.FS, info *TypingsInfo, fileNames []tspath.RootedFilePath, projectDirectory tspath.RootedDirectoryPath) bool { + var logger *logging.LogTree + _, _, _, current := discoverTypings(fs, logger, info, fileNames, projectDirectory, &collections.SyncMap[string, *CachedTyping]{}, nil) + return maps.Equal(d.inferredTypings, current.inferredTypings) +} + +func discoverTypings( + fs vfs.FS, + logger logging.Logger, + typingsInfo *TypingsInfo, + fileNames []tspath.RootedFilePath, + projectRootPath tspath.RootedDirectoryPath, + packageNameToTypingLocation *collections.SyncMap[string, *CachedTyping], + typesRegistry map[string]map[string]string, +) (cachedTypingPaths []tspath.RootedFilePath, newTypingNames []string, filesToWatch []tspath.RootedPath, discovery *TypingsDiscovery) { // A typing name to typing file path mapping inferredTypings := map[string]tspath.RootedFilePath{} @@ -50,12 +73,7 @@ func DiscoverTypings( // Directories to search for package.json, bower.json and other typing information if typingsInfo.CompilerOptions.Types == nil { - possibleSearchDirs := map[tspath.RootedDirectoryPath]bool{} - for _, fileName := range fileNames { - possibleSearchDirs[fileName.Directory()] = true - } - possibleSearchDirs[projectRootPath] = true - for searchDir := range possibleSearchDirs { + for _, searchDir := range discoveryDirectories(typingsInfo, fileNames, projectRootPath) { filesToWatch = addTypingNamesAndGetFilesToWatch(fs, logger, inferredTypings, filesToWatch, searchDir, "bower.json", "bower_components") filesToWatch = addTypingNamesAndGetFilesToWatch(fs, logger, inferredTypings, filesToWatch, searchDir, "package.json", "node_modules") } @@ -82,6 +100,8 @@ func DiscoverTypings( delete(inferredTypings, excludeTypingName) logger.Log(fmt.Sprintf("ATA:: Typing for %s is in exclude list, will be ignored.", excludeTypingName)) } + // Keep discovery demand independent of cache paths added by this or another install. + discovery = &TypingsDiscovery{inferredTypings: maps.Clone(inferredTypings)} // Add the cached typing locations for inferred typings that are already installed packageNameToTypingLocation.Range(func(name string, typing *CachedTyping) bool { @@ -100,7 +120,35 @@ func DiscoverTypings( } } logger.Log(fmt.Sprintf("ATA:: Finished typings discovery: cachedTypingsPaths: %v newTypingNames: %v, filesToWatch %v", cachedTypingPaths, newTypingNames, filesToWatch)) - return cachedTypingPaths, newTypingNames, filesToWatch + return cachedTypingPaths, newTypingNames, filesToWatch, discovery +} + +func discoveryDirectories(info *TypingsInfo, files []tspath.RootedFilePath, projectDirectory tspath.RootedDirectoryPath) []tspath.RootedDirectoryPath { + if info.CompilerOptions.Types != nil { + return nil + } + directories := collections.NewSetFromItems(projectDirectory) + for _, file := range files { + if file.HasJSFileExtension() { + directories.Add(file.Directory()) + } + } + result := slices.Collect(maps.Keys(directories.Keys())) + slices.Sort(result) + return result +} + +func DiscoveryInputsEqual(info *TypingsInfo, files, otherFiles []tspath.RootedFilePath, projectDirectory tspath.RootedDirectoryPath, caseSensitivity tspath.CaseSensitivity) bool { + directoryKeys := func(files []tspath.RootedFilePath) []tspath.PathKey { + keys := core.Map(discoveryDirectories(info, files, projectDirectory), func(dir tspath.RootedDirectoryPath) tspath.PathKey { + return caseSensitivity.PathKey(dir.AsPath()) + }) + slices.Sort(keys) + return slices.Compact(keys) + } + return slices.Equal(directoryKeys(files), directoryKeys(otherFiles)) && + (info.TypeAcquisition.DisableFilenameBasedTypeAcquisition.IsTrue() || + slices.Equal(typingNamesFromSourceFileNames(files), typingNamesFromSourceFileNames(otherFiles))) } func addInferredTyping(inferredTypings map[string]tspath.RootedFilePath, typingName string) { @@ -133,23 +181,26 @@ func getTypingNamesFromSourceFileNames( inferredTypings map[string]tspath.RootedFilePath, fileNames []tspath.RootedFilePath, ) { - hasJsxFile := false + addInferredTypings(fs, logger, inferredTypings, typingNamesFromSourceFileNames(fileNames), "Inferred typings from file names") +} + +func typingNamesFromSourceFileNames(fileNames []tspath.RootedFilePath) []string { var fromFileNames []string for _, fileName := range fileNames { - hasJsxFile = hasJsxFile || fileName.ExtensionIs(tspath.ExtensionJsx) + if !fileName.HasJSFileExtension() { + continue + } + if fileName.ExtensionIs(tspath.ExtensionJsx) { + fromFileNames = append(fromFileNames, "react") + } inferredTypingName := tspath.RemoveFileExtension(tspath.ToFileNameLowerCase(fileName.BaseName())) cleanedTypingName := removeMinAndVersionNumbers(inferredTypingName) if typeName, ok := safeFileNameToTypeName[cleanedTypingName]; ok { fromFileNames = append(fromFileNames, typeName) } } - if len(fromFileNames) > 0 { - addInferredTypings(fs, logger, inferredTypings, fromFileNames, "Inferred typings from file names") - } - if hasJsxFile { - logger.Log("ATA:: Inferred 'react' typings due to presence of '.jsx' extension") - addInferredTyping(inferredTypings, "react") - } + slices.Sort(fromFileNames) + return slices.Compact(fromFileNames) } /** diff --git a/tsc/internal/project/ata/discovertypings_test.go b/tsc/internal/project/ata/discovertypings_test.go index de43c7fb235ad..cfc686a95bbfc 100644 --- a/tsc/internal/project/ata/discovertypings_test.go +++ b/tsc/internal/project/ata/discovertypings_test.go @@ -15,6 +15,39 @@ import ( "gotest.tools/v3/assert" ) +func TestDiscoveryInputsEqual(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + files []tspath.RootedFilePath + otherFiles []tspath.RootedFilePath + types []string + disableNames bool + caseSensitivity tspath.CaseSensitivity + equal bool + }{ + {name: "same directory", files: []tspath.RootedFilePath{"/project/a.js"}, otherFiles: []tspath.RootedFilePath{"/project/b.js"}, equal: true}, + {name: "different directories", files: []tspath.RootedFilePath{"/project/a/a.js"}, otherFiles: []tspath.RootedFilePath{"/project/b/b.js"}}, + {name: "filename typing", files: []tspath.RootedFilePath{"/project/a.js"}, otherFiles: []tspath.RootedFilePath{"/project/jquery.js"}}, + {name: "filename typing suffix", files: []tspath.RootedFilePath{"/project/jquery.js"}, otherFiles: []tspath.RootedFilePath{"/project/jquery.min.1.2.js"}, equal: true}, + {name: "JSX typing", files: []tspath.RootedFilePath{"/project/a.js"}, otherFiles: []tspath.RootedFilePath{"/project/a.jsx"}}, + {name: "disabled filename typing", files: []tspath.RootedFilePath{"/project/a.js"}, otherFiles: []tspath.RootedFilePath{"/project/jquery.jsx"}, disableNames: true, equal: true}, + {name: "disabled manifest discovery", files: []tspath.RootedFilePath{"/project/a/a.js"}, otherFiles: []tspath.RootedFilePath{"/project/b/b.js"}, types: []string{}, equal: true}, + {name: "case insensitive directories", files: []tspath.RootedFilePath{"/project/A/a.js"}, otherFiles: []tspath.RootedFilePath{"/project/a/b.js"}, equal: true}, + {name: "case sensitive directories", files: []tspath.RootedFilePath{"/project/A/a.js"}, otherFiles: []tspath.RootedFilePath{"/project/a/b.js"}, caseSensitivity: tspath.CaseSensitive}, + {name: "duplicate directory", files: []tspath.RootedFilePath{"/project/a.js", "/project/b.js"}, otherFiles: []tspath.RootedFilePath{"/project/c.js"}, equal: true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + info := &ata.TypingsInfo{ + CompilerOptions: &core.CompilerOptions{Types: test.types}, + TypeAcquisition: &core.TypeAcquisition{DisableFilenameBasedTypeAcquisition: core.BoolToTristate(test.disableNames)}, + } + assert.Equal(t, ata.DiscoveryInputsEqual(info, test.files, test.otherFiles, "/project", test.caseSensitivity), test.equal) + }) + } +} + func TestDiscoverTypings(t *testing.T) { t.Parallel() t.Run("should use mappings from safe list", func(t *testing.T) { diff --git a/tsc/internal/project/project.go b/tsc/internal/project/project.go index a98664769cf51..b09d2611dd5a4 100644 --- a/tsc/internal/project/project.go +++ b/tsc/internal/project/project.go @@ -220,7 +220,7 @@ func (p *Project) inferredProjectATAState() *inferredProjectATAState { snapshotID := p.installedTypingsSnapshotID if p.installedTypingsInfo != nil && p.installedTypingsInfo.Equals(p.ComputeTypingsInfo()) && - slices.Equal(p.installedTypingsFileNames, p.ComputeTypingsFileNames()) && + p.typingsDiscoveryInputsEqual(p.installedTypingsFileNames) && p.ProgramLastUpdate > snapshotID { snapshotID = p.ProgramLastUpdate } @@ -243,7 +243,7 @@ func (s *inferredProjectATAState) canApply(project *Project, fs *snapshotFSBuild return false } if !s.installedTypingsInfo.Equals(project.ComputeTypingsInfo()) || - !slices.Equal(s.installedTypingsFileNames, project.ComputeTypingsFileNames()) { + !project.typingsDiscoveryInputsEqual(s.installedTypingsFileNames) { return false } for _, fileName := range s.typingsFiles { @@ -273,7 +273,7 @@ func (s *inferredProjectATAState) canApplyWatchState(project *Project, watchEnab return s != nil && s.installedTypingsInfo == nil && watchEnabled && - slices.Equal(s.installedTypingsFileNames, project.ComputeTypingsFileNames()) + project.typingsDiscoveryInputsEqual(s.installedTypingsFileNames) } func (s *inferredProjectATAState) applyWatchState(project *Project) { @@ -792,7 +792,12 @@ func (p *Project) ShouldTriggerATA(snapshotID uint64) bool { } return !p.installedTypingsInfo.Equals(p.ComputeTypingsInfo()) || - !slices.Equal(p.installedTypingsFileNames, p.ComputeTypingsFileNames()) + !p.typingsDiscoveryInputsEqual(p.installedTypingsFileNames) +} + +func (p *Project) typingsDiscoveryInputsEqual(fileNames []tspath.RootedFilePath) bool { + info := p.ComputeTypingsInfo() + return ata.DiscoveryInputsEqual(&info, fileNames, p.ComputeTypingsFileNames(), p.projectDirectory, p.host.FS().CaseSensitivity()) } func (p *Project) ComputeTypingsInfo() ata.TypingsInfo { diff --git a/tsc/internal/project/projectcollectionbuilder.go b/tsc/internal/project/projectcollectionbuilder.go index 441db309e01a6..32e5f24fef556 100644 --- a/tsc/internal/project/projectcollectionbuilder.go +++ b/tsc/internal/project/projectcollectionbuilder.go @@ -566,7 +566,12 @@ func fileChangeSummaryAffectsTypingsWatch( } } for uri := range summary.Deleted.Keys() { - if affectsWatch(uri) { + deletedPath := caseSensitivity.PathKey(uri.FileName().AsPath()) + if affectsWatch(uri) || slices.ContainsFunc(filesToWatch, func(path tspath.RootedPath) bool { + return deletedPath.ContainsPath(caseSensitivity.PathKey(path)) + }) || slices.ContainsFunc(typingsFiles, func(path tspath.RootedFilePath) bool { + return deletedPath.ContainsPath(caseSensitivity.PathKey(path.AsPath())) + }) { return true } } @@ -1074,7 +1079,7 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState // has not changed since the time the ATA request was dispatched; the change can still be // applied to this project in its current state. return ataChange.TypingsInfo.Equals(p.ComputeTypingsInfo()) && - slices.Equal(ataChange.FileNames, p.ComputeTypingsFileNames()) + p.typingsDiscoveryInputsEqual(ataChange.FileNames) }, func(p *Project) { p.installedTypingsInfo = ataChange.TypingsInfo @@ -1127,6 +1132,12 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState } continue } + if ataChange.Discovery != nil && !ataChange.Discovery.IsCurrent(b.fs.fs, ataChange.TypingsInfo, ataChange.FileNames, ataChange.ProjectDirectory) { + if logger != nil { + logger.Logf("Ignoring ATA state with obsolete discovery inputs for project %s", projectID) + } + continue + } if _, ok := projectID.Inferred(); ok { if inferred := b.inferredProject.Value(); inferred != nil && inferred.Program != nil { updateProject(b.inferredProject, ataChange) diff --git a/tsc/internal/project/session.go b/tsc/internal/project/session.go index d408aa0aaed9d..dd3164d589ea6 100644 --- a/tsc/internal/project/session.go +++ b/tsc/internal/project/session.go @@ -2074,7 +2074,7 @@ func (s *Session) triggerATAForUpdatedProjects(newSnapshot *Snapshot) { if !slices.Equal(result.TypingsFiles, project.typingsFiles) || project.installedTypingsInfo == nil || !typingsInfo.Equals(*project.installedTypingsInfo) || - !slices.Equal(fileNames, project.installedTypingsFileNames) || + !project.typingsDiscoveryInputsEqual(project.installedTypingsFileNames) || !slices.Equal(result.FilesToWatch, project.installedTypingsFilesToWatch) || !slices.Equal(result.CacheEntryPoints, project.installedTypingCacheEntryPoints) { s.pendingATAChangesMu.Lock() @@ -2089,6 +2089,8 @@ func (s *Session) triggerATAForUpdatedProjects(newSnapshot *Snapshot) { TypingsFiles: result.TypingsFiles, TypingsFilesToWatch: result.FilesToWatch, TypingCacheEntryPoints: result.CacheEntryPoints, + Discovery: result.Discovery, + ProjectDirectory: request.ProjectRootPath, Logs: logTree, } } diff --git a/tsc/internal/project/snapshot.go b/tsc/internal/project/snapshot.go index 0941b452bfc6d..85c701d398e90 100644 --- a/tsc/internal/project/snapshot.go +++ b/tsc/internal/project/snapshot.go @@ -428,6 +428,8 @@ type ATAStateChange struct { // TypingsFilesToWatch is the new list of typing files to watch for changes. TypingsFilesToWatch []tspath.RootedPath TypingCacheEntryPoints []ata.CachedTypingEntryPoint + Discovery *ata.TypingsDiscovery + ProjectDirectory tspath.RootedDirectoryPath Logs *logging.LogTree } @@ -501,7 +503,7 @@ func (s *Snapshot) Clone( start := time.Now() hadExcessiveWatchEvents := change.fileChanges.HasExcessiveWatchEvents() var unfilteredFileChanges FileChangeSummary - if hadExcessiveWatchEvents { + if hadExcessiveWatchEvents || change.fileChanges.Deleted.Len() > 0 { unfilteredFileChanges = change.fileChanges.Clone() } inferredContentMappers := s.inferredProjectContentMappers @@ -528,6 +530,11 @@ func (s *Snapshot) Clone( if hadExcessiveWatchEvents { typingsWatchChanges = unfilteredFileChanges typingsWatchChanges.InvalidateAll = typingsWatchChanges.InvalidateAll || change.fileChanges.InvalidateAll + } else if unfilteredFileChanges.Deleted.Len() > 0 { + typingsWatchChanges = typingsWatchChanges.Clone() + for uri := range unfilteredFileChanges.Deleted.Keys() { + typingsWatchChanges.Deleted.Add(uri) + } } typingCacheChanges := typingsWatchChanges if typingsLocation := store.options.TypingsLocation; typingsLocation != "" { diff --git a/tsc/internal/project/snapshot_test.go b/tsc/internal/project/snapshot_test.go index 6584a2d89390f..7fdaf75808c5e 100644 --- a/tsc/internal/project/snapshot_test.go +++ b/tsc/internal/project/snapshot_test.go @@ -36,6 +36,18 @@ func TestTypingsWatchWithoutDiscoveryDirectories(t *testing.T) { } } +func TestTypingsWatchAncestorDeletion(t *testing.T) { + t.Parallel() + for _, path := range []tspath.RootedPath{"/workspace/project/bower.json", "/workspace/project/node_modules"} { + for _, deleted := range []string{"/workspace", "/workspace/project", "/workspace/project-other", "/workspace/other"} { + summary := FileChangeSummary{} + summary.Deleted.Add(lsproto.DocumentUri("file://" + deleted)) + assert.Equal(t, fileChangeSummaryAffectsTypingsWatch(summary, []tspath.RootedPath{path}, nil, tspath.CaseInsensitive), + deleted == "/workspace" || deleted == "/workspace/project") + } + } +} + func TestSnapshot(t *testing.T) { t.Parallel() if !bundled.Embedded { From 510d5050746940dc547968b0fb24f06d76a1e4de Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:40:57 -0700 Subject: [PATCH 08/12] Avoid repeated ATA watch-list allocations Unrelated event batches should not rebuild the same discovery watch list for every notification. Bound watch-list allocations per batch while preserving ancestor deletion handling. --- .../project/projectcollectionbuilder.go | 21 +++++------- tsc/internal/project/snapshot_test.go | 33 +++++++++++++++++++ 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/tsc/internal/project/projectcollectionbuilder.go b/tsc/internal/project/projectcollectionbuilder.go index 32e5f24fef556..4a57e3f6065a6 100644 --- a/tsc/internal/project/projectcollectionbuilder.go +++ b/tsc/internal/project/projectcollectionbuilder.go @@ -538,12 +538,14 @@ func fileChangeSummaryAffectsTypingsWatch( if len(filesToWatch) == 0 && len(typingsFiles) == 0 { return false } - affectsWatch := func(uri lsproto.DocumentUri) bool { + watchedPaths := slices.Concat(filesToWatch, core.Map(typingsFiles, func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() })) + affectsWatch := func(uri lsproto.DocumentUri, deleted bool) bool { fileName := uri.FileName().AsPath() - return slices.ContainsFunc(slices.Concat(filesToWatch, core.Map(typingsFiles, func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() })), func(watchedPath tspath.RootedPath) bool { + fileNameKey := caseSensitivity.PathKey(fileName) + return slices.ContainsFunc(watchedPaths, func(watchedPath tspath.RootedPath) bool { watchedPathKey := caseSensitivity.PathKey(watchedPath) - fileNameKey := caseSensitivity.PathKey(fileName) - if watchedPathKey == fileNameKey || watchedPathKey.ContainsPath(fileNameKey) { + if watchedPathKey == fileNameKey || watchedPathKey.ContainsPath(fileNameKey) || + deleted && fileNameKey.ContainsPath(watchedPathKey) { return true } switch watchedPath.BaseName() { @@ -556,22 +558,17 @@ func fileChangeSummaryAffectsTypingsWatch( }) } for uri := range summary.Changed.Keys() { - if affectsWatch(uri) { + if affectsWatch(uri, false) { return true } } for uri := range summary.Created.Keys() { - if affectsWatch(uri) { + if affectsWatch(uri, false) { return true } } for uri := range summary.Deleted.Keys() { - deletedPath := caseSensitivity.PathKey(uri.FileName().AsPath()) - if affectsWatch(uri) || slices.ContainsFunc(filesToWatch, func(path tspath.RootedPath) bool { - return deletedPath.ContainsPath(caseSensitivity.PathKey(path)) - }) || slices.ContainsFunc(typingsFiles, func(path tspath.RootedFilePath) bool { - return deletedPath.ContainsPath(caseSensitivity.PathKey(path.AsPath())) - }) { + if affectsWatch(uri, true) { return true } } diff --git a/tsc/internal/project/snapshot_test.go b/tsc/internal/project/snapshot_test.go index 7fdaf75808c5e..9fdbd5ec862e3 100644 --- a/tsc/internal/project/snapshot_test.go +++ b/tsc/internal/project/snapshot_test.go @@ -48,6 +48,39 @@ func TestTypingsWatchAncestorDeletion(t *testing.T) { } } +func TestTypingsWatchBatchAllocations(t *testing.T) { //nolint:paralleltest // allocation counting requires sequential execution + var events collections.Set[lsproto.DocumentUri] + for i := range 500 { + events.Add(lsproto.DocumentUri(fmt.Sprintf("file:///unrelated/file%d.js", i))) + } + var filesToWatch []tspath.RootedPath + var typingsFiles []tspath.RootedFilePath + for i := range 16 { + filesToWatch = append(filesToWatch, tspath.RootedFilePathFromNormalized(fmt.Sprintf("/workspace/package%d/package.json", i)).AsPath()) + typingsFiles = append(typingsFiles, tspath.RootedFilePathFromNormalized(fmt.Sprintf("/workspace/package%d/index.d.ts", i))) + } + for _, kind := range []string{"changed", "created", "deleted"} { //nolint:paralleltest // allocation counting requires sequential execution + t.Run(kind, func(t *testing.T) { + summary := FileChangeSummary{} + switch kind { + case "changed": + summary.Changed = events + case "created": + summary.Created = events + case "deleted": + summary.Deleted = events + } + allocations := testing.AllocsPerRun(10, func() { + if fileChangeSummaryAffectsTypingsWatch(summary, filesToWatch, typingsFiles, tspath.CaseSensitive) { + t.Fatal("unrelated events must not affect the typings watch") + } + }) + // URI decoding allocates once per event; building the watch list should not. + assert.Assert(t, allocations <= float64(events.Len()+4), "watch-list allocations must not scale with the event count: %v", allocations) + }) + } +} + func TestSnapshot(t *testing.T) { t.Parallel() if !bundled.Embedded { From 76d6b2508869e336bf04fb09a982147ab4a302d7 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:27:37 -0700 Subject: [PATCH 09/12] Avoid repeated ATA dependency discovery ATA requests should scale with the project's demand, not the size of the shared typings cache or dependency trees traversed again under the snapshot lock. Scoped dependencies must retain their cached typings across subsequent acquisitions. Keep discovery in the background and establish provisional watch coverage before dispatch. Reject results whose coverage was discarded or whose package-owned declaration availability changed during installation. --- tsc/internal/project/ata/ata.go | 35 ++--- tsc/internal/project/ata/ata_test.go | 120 ++++++++++++++++++ tsc/internal/project/ata/discovertypings.go | 68 ++++++---- .../project/ata/discovertypings_test.go | 19 +++ .../project/ata/installnpmpackages_test.go | 52 ++++++++ .../project/projectcollectionbuilder.go | 42 +++++- tsc/internal/project/session.go | 3 +- tsc/internal/project/snapshot.go | 4 +- tsc/internal/project/snapshot_test.go | 41 ++++++ tsc/internal/project/snapshothost.go | 2 + 10 files changed, 337 insertions(+), 49 deletions(-) diff --git a/tsc/internal/project/ata/ata.go b/tsc/internal/project/ata/ata.go index 7783d3f55ae71..61c2959b63ed1 100644 --- a/tsc/internal/project/ata/ata.go +++ b/tsc/internal/project/ata/ata.go @@ -114,10 +114,10 @@ type TypingsInstallRequest struct { } type TypingsInstallResult struct { - TypingsFiles []tspath.RootedFilePath - FilesToWatch []tspath.RootedPath - CacheEntryPoints []CachedTypingEntryPoint - Discovery *TypingsDiscovery + TypingsFiles []tspath.RootedFilePath + FilesToWatch []tspath.RootedPath + CacheEntryPoints []CachedTypingEntryPoint + MissingTypingFiles []tspath.RootedFilePath } type CachedTypingEntryPoint struct { @@ -138,13 +138,13 @@ func (ti *TypingsInstaller) InstallTypings(ctx context.Context, request *Typings func (ti *TypingsInstaller) discoverAndInstallTypings(ctx context.Context, request *TypingsInstallRequest) (*TypingsInstallResult, error) { ti.init(ctx, request.FS, request.Logger) - cachedTypings := ti.resolveCachedTypings(request.FS) - cachedTypingPaths, newTypingNames, filesToWatch, discovery := discoverTypings( - request.FS, request.Logger, request.TypingsInfo, request.FileNames, - request.ProjectRootPath, cachedTypings, ti.typesRegistry, + inferredTypings, filesToWatch, missingTypingFiles := discoverTypingNames( + request.FS, request.Logger, request.TypingsInfo, request.FileNames, request.ProjectRootPath, ) + cachedTypings := ti.resolveCachedTypings(request.FS, inferredTypings) + cachedTypingPaths, newTypingNames := getCachedTypingPaths(inferredTypings, cachedTypings, ti.typesRegistry, request.Logger) makeResult := func(files []tspath.RootedFilePath, filesToWatch []tspath.RootedPath) *TypingsInstallResult { - result := &TypingsInstallResult{TypingsFiles: files, FilesToWatch: filesToWatch, Discovery: discovery} + result := &TypingsInstallResult{TypingsFiles: files, FilesToWatch: filesToWatch, MissingTypingFiles: missingTypingFiles} cachedTypings.Range(func(name string, typing *CachedTyping) bool { if slices.Contains(files, typing.TypingsLocation) { result.CacheEntryPoints = append(result.CacheEntryPoints, CachedTypingEntryPoint{ @@ -181,18 +181,23 @@ func (ti *TypingsInstaller) discoverAndInstallTypings(ctx context.Context, reque // Resolve cached entry points afresh: a package can change its types field // without changing its version or deleting the previously resolved file. -func (ti *TypingsInstaller) resolveCachedTypings(fs vfs.FS) *collections.SyncMap[string, *CachedTyping] { +func (ti *TypingsInstaller) resolveCachedTypings(fs vfs.FS, inferredTypings map[string]tspath.RootedFilePath) *collections.SyncMap[string, *CachedTyping] { resolver := module.NewResolver(module.ResolverOptions{ Host: &resolutionHost{fs: fs, currentDirectory: ti.typingsLocation}, CompilerOptions: &core.CompilerOptions{ModuleResolution: core.ModuleResolutionKindNodeNext}, }) result := &collections.SyncMap[string, *CachedTyping]{} - ti.packageNameToTypingLocation.Range(func(name string, typing *CachedTyping) bool { - if fileName := ti.typingToFileName(resolver, name); fileName != "" { - result.Store(name, &CachedTyping{TypingsLocation: fileName, Version: typing.Version}) + for name, inferred := range inferredTypings { + if inferred != "" { + continue } - return true - }) + typingKey := module.MangleScopedPackageName(name) + if typing, ok := ti.packageNameToTypingLocation.Load(typingKey); ok { + if fileName := ti.typingToFileName(resolver, typingKey); fileName != "" { + result.Store(typingKey, &CachedTyping{TypingsLocation: fileName, Version: typing.Version}) + } + } + } return result } diff --git a/tsc/internal/project/ata/ata_test.go b/tsc/internal/project/ata/ata_test.go index 71f1282a3bc02..443e8713802be 100644 --- a/tsc/internal/project/ata/ata_test.go +++ b/tsc/internal/project/ata/ata_test.go @@ -6,6 +6,7 @@ import ( "slices" "strings" "sync" + "sync/atomic" "testing" "time" @@ -18,10 +19,23 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/project" "github.com/microsoft/TypeScript/tsc/internal/testutil/projecttestutil" "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs" "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" "gotest.tools/v3/assert" ) +type discoveryReadCountingFS struct { + vfs.FS + discoveryReads atomic.Int32 +} + +func (fs *discoveryReadCountingFS) ReadFile(path tspath.RootedFilePath) (string, bool) { + if path == "/user/username/projects/project/bower_components/jquery/bower.json" { + fs.discoveryReads.Add(1) + } + return fs.FS.ReadFile(path) +} + func waitForInstall(t *testing.T, installStarted <-chan struct{}) { t.Helper() select { @@ -92,6 +106,32 @@ func TestATA(t *testing.T) { t.Skip("bundled files are not embedded") } + t.Run("applying ATA results does not repeat dependency discovery", func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/user/username/projects/project/app.js": "", + "/user/username/projects/project/bower_components/jquery/bower.json": `{"name":"jquery"}`, + } + init, _ := projecttestutil.GetSessionInitOptions(files, nil, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"jquery": `declare const $: number;`}, + }) + fs := &discoveryReadCountingFS{FS: init.FS} + init.FS = fs + session := project.NewSession(init) + defer session.Close() + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + assert.Assert(t, fs.discoveryReads.Load() > 0, "the background installer must discover the Bower dependency") + fs.discoveryReads.Store(0) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + defer snapshot.Deref() + assert.Assert(t, !snapshot.ProjectCollection.InferredProject().ShouldTriggerATA(snapshot.ID()), "the background result must be accepted") + assert.Equal(t, fs.discoveryReads.Load(), int32(0), "applying the background result must not rediscover dependency manifests") + }) + t.Run("local module should not be picked up", func(t *testing.T) { t.Parallel() files := map[string]any{ @@ -128,6 +168,86 @@ func TestATA(t *testing.T) { assert.Equal(t, npmCalls[0].Args[2], "types-registry@latest") }) + t.Run("external package declaration created during installation invalidates the result", func(t *testing.T) { + t.Parallel() + const ownedTypes = "/user/username/projects/shared/foo.d.ts" + files := map[string]any{ + "/user/username/projects/project/app.js": "", + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"foo":"^1.0.0","jquery":"^3.1.0"}}`, + "/user/username/projects/project/node_modules/foo/package.json": `{"name":"foo","types":"../../../shared/foo.d.ts"}`, + projecttestutil.TestTypingsLocation + "/node_modules/@types/foo/index.d.ts": `declare const fallback: number;`, + projecttestutil.TestTypingsLocation + "/package.json": `{"devDependencies":{"@types/foo":"^1.3.0"}}`, + projecttestutil.TestTypingsLocation + "/package-lock.json": `{"dependencies":{"@types/foo":{"version":"1.3.0"}}}`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"foo": `declare const fallback: number;`, "jquery": `declare const $: number;`}, + }) + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var once sync.Once + release := func() { once.Do(func() { close(releaseInstall) }) } + defer release() + install := utils.NpmExecutor().NpmInstallFunc + utils.NpmExecutor().NpmInstallFunc = func(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + if slices.Contains(args, "@types/jquery@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return install(ctx, cwd, args) + } + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + waitForInstall(t, installStarted) + assert.NilError(t, utils.FS().WriteFile(ownedTypes, `declare const ownTypes: number;`)) + release() + session.WaitForBackgroundTasks() + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject().ShouldTriggerATA(snapshot.ID()), "the obsolete cached fallback must be rejected") + snapshot.Deref() + session.WaitForBackgroundTasks() + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, slices.Contains(ls.GetProgram().CommandLine().FileNames(), ownedTypes)) + assert.Assert(t, !slices.Contains(ls.GetProgram().CommandLine().FileNames(), projecttestutil.TestTypingsLocation+"/node_modules/@types/foo/index.d.ts")) + }) + + t.Run("cached scoped dependency survives subsequent ATA requests", func(t *testing.T) { + t.Parallel() + const manifest = "/user/username/projects/project/package.json" + files := map[string]any{ + "/user/username/projects/project/app.js": "", + manifest: `{"name":"test","dependencies":{"@a/b":"^1.0.0"}}`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "a__b": `declare const scoped: number;`, + "jquery": `declare const $: number;`, + }, + }) + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, slices.Contains(ls.GetProgram().CommandLine().FileNames(), projecttestutil.TestTypingsLocation+"/node_modules/@types/a__b/index.d.ts")) + session.WaitForBackgroundTasks() + assert.NilError(t, utils.FS().WriteFile(manifest, `{"name":"test","dependencies":{"@a/b":"^1.0.0","jquery":"^3.1.0"}}`)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{Uri: "file://" + manifest, Type: lsproto.FileChangeTypeChanged}}) + _, err = session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + ls, err = session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, slices.Contains(ls.GetProgram().CommandLine().FileNames(), projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts")) + assert.Assert(t, slices.Contains(ls.GetProgram().CommandLine().FileNames(), projecttestutil.TestTypingsLocation+"/node_modules/@types/a__b/index.d.ts"), "an up-to-date scoped dependency must remain an acquired root") + calls := utils.NpmExecutor().NpmInstallCalls() + assert.Equal(t, len(calls), 3, "registry initialization and one install per new dependency") + assert.Assert(t, !slices.Contains(calls[2].Args, "@types/a__b@latest"), "the cached scoped dependency must not be reinstalled") + }) + t.Run("configured projects", func(t *testing.T) { t.Parallel() diff --git a/tsc/internal/project/ata/discovertypings.go b/tsc/internal/project/ata/discovertypings.go index d21eb35de25ef..07291c1107734 100644 --- a/tsc/internal/project/ata/discovertypings.go +++ b/tsc/internal/project/ata/discovertypings.go @@ -9,6 +9,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/collections" "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/json" + "github.com/microsoft/TypeScript/tsc/internal/module" "github.com/microsoft/TypeScript/tsc/internal/packagejson" "github.com/microsoft/TypeScript/tsc/internal/project/logging" "github.com/microsoft/TypeScript/tsc/internal/semver" @@ -35,31 +36,20 @@ func DiscoverTypings( packageNameToTypingLocation *collections.SyncMap[string, *CachedTyping], typesRegistry map[string]map[string]string, ) (cachedTypingPaths []tspath.RootedFilePath, newTypingNames []string, filesToWatch []tspath.RootedPath) { - cachedTypingPaths, newTypingNames, filesToWatch, _ = discoverTypings(fs, logger, typingsInfo, fileNames, projectRootPath, packageNameToTypingLocation, typesRegistry) - return + inferredTypings, filesToWatch, _ := discoverTypingNames(fs, logger, typingsInfo, fileNames, projectRootPath) + cachedTypingPaths, newTypingNames = getCachedTypingPaths(inferredTypings, packageNameToTypingLocation, typesRegistry, logger) + return cachedTypingPaths, newTypingNames, filesToWatch } -type TypingsDiscovery struct { - inferredTypings map[string]tspath.RootedFilePath -} - -func (d *TypingsDiscovery) IsCurrent(fs vfs.FS, info *TypingsInfo, fileNames []tspath.RootedFilePath, projectDirectory tspath.RootedDirectoryPath) bool { - var logger *logging.LogTree - _, _, _, current := discoverTypings(fs, logger, info, fileNames, projectDirectory, &collections.SyncMap[string, *CachedTyping]{}, nil) - return maps.Equal(d.inferredTypings, current.inferredTypings) -} - -func discoverTypings( +func discoverTypingNames( fs vfs.FS, logger logging.Logger, typingsInfo *TypingsInfo, fileNames []tspath.RootedFilePath, projectRootPath tspath.RootedDirectoryPath, - packageNameToTypingLocation *collections.SyncMap[string, *CachedTyping], - typesRegistry map[string]map[string]string, -) (cachedTypingPaths []tspath.RootedFilePath, newTypingNames []string, filesToWatch []tspath.RootedPath, discovery *TypingsDiscovery) { +) (inferredTypings map[string]tspath.RootedFilePath, filesToWatch []tspath.RootedPath, missingTypingFiles []tspath.RootedFilePath) { // A typing name to typing file path mapping - inferredTypings := map[string]tspath.RootedFilePath{} + inferredTypings = map[string]tspath.RootedFilePath{} // Only infer typings for .js and .jsx files fileNames = core.Filter(fileNames, func(fileName tspath.RootedFilePath) bool { @@ -74,8 +64,8 @@ func discoverTypings( // Directories to search for package.json, bower.json and other typing information if typingsInfo.CompilerOptions.Types == nil { for _, searchDir := range discoveryDirectories(typingsInfo, fileNames, projectRootPath) { - filesToWatch = addTypingNamesAndGetFilesToWatch(fs, logger, inferredTypings, filesToWatch, searchDir, "bower.json", "bower_components") - filesToWatch = addTypingNamesAndGetFilesToWatch(fs, logger, inferredTypings, filesToWatch, searchDir, "package.json", "node_modules") + filesToWatch = addTypingNamesAndGetFilesToWatch(fs, logger, inferredTypings, filesToWatch, &missingTypingFiles, searchDir, "bower.json", "bower_components") + filesToWatch = addTypingNamesAndGetFilesToWatch(fs, logger, inferredTypings, filesToWatch, &missingTypingFiles, searchDir, "package.json", "node_modules") } } @@ -100,17 +90,23 @@ func discoverTypings( delete(inferredTypings, excludeTypingName) logger.Log(fmt.Sprintf("ATA:: Typing for %s is in exclude list, will be ignored.", excludeTypingName)) } - // Keep discovery demand independent of cache paths added by this or another install. - discovery = &TypingsDiscovery{inferredTypings: maps.Clone(inferredTypings)} + return inferredTypings, filesToWatch, missingTypingFiles +} +func getCachedTypingPaths( + inferredTypings map[string]tspath.RootedFilePath, + packageNameToTypingLocation *collections.SyncMap[string, *CachedTyping], + typesRegistry map[string]map[string]string, + logger logging.Logger, +) (cachedTypingPaths []tspath.RootedFilePath, newTypingNames []string) { // Add the cached typing locations for inferred typings that are already installed - packageNameToTypingLocation.Range(func(name string, typing *CachedTyping) bool { - registryEntry := typesRegistry[name] - if inferred, ok := inferredTypings[name]; ok && inferred == "" && registryEntry != nil && isTypingUpToDate(typing, registryEntry) { + for name, inferred := range inferredTypings { + typingKey := module.MangleScopedPackageName(name) + registryEntry := typesRegistry[typingKey] + if typing, ok := packageNameToTypingLocation.Load(typingKey); ok && inferred == "" && registryEntry != nil && isTypingUpToDate(typing, registryEntry) { inferredTypings[name] = typing.TypingsLocation } - return true - }) + } for typing, inferred := range inferredTypings { if inferred != "" { @@ -119,8 +115,21 @@ func discoverTypings( newTypingNames = append(newTypingNames, typing) } } - logger.Log(fmt.Sprintf("ATA:: Finished typings discovery: cachedTypingsPaths: %v newTypingNames: %v, filesToWatch %v", cachedTypingPaths, newTypingNames, filesToWatch)) - return cachedTypingPaths, newTypingNames, filesToWatch, discovery + logger.Log(fmt.Sprintf("ATA:: Finished typings discovery: cachedTypingsPaths: %v newTypingNames: %v", cachedTypingPaths, newTypingNames)) + return cachedTypingPaths, newTypingNames +} + +func DiscoveryWatchInputs(info *TypingsInfo, files []tspath.RootedFilePath, projectDirectory tspath.RootedDirectoryPath) []tspath.RootedPath { + var paths []tspath.RootedPath + for _, directory := range discoveryDirectories(info, files, projectDirectory) { + paths = append(paths, directory.ResolveFile("package.json").AsPath(), directory.ResolveFile("bower.json").AsPath(), + directory.ResolveDirectory("node_modules").AsPath(), directory.ResolveDirectory("bower_components").AsPath()) + } + for _, file := range files { + paths = append(paths, file.AsPath()) + } + slices.Sort(paths) + return slices.Compact(paths) } func discoveryDirectories(info *TypingsInfo, files []tspath.RootedFilePath, projectDirectory tspath.RootedDirectoryPath) []tspath.RootedDirectoryPath { @@ -216,6 +225,7 @@ func addTypingNamesAndGetFilesToWatch( logger logging.Logger, inferredTypings map[string]tspath.RootedFilePath, filesToWatch []tspath.RootedPath, + missingTypingFiles *[]tspath.RootedFilePath, projectRootPath tspath.RootedDirectoryPath, manifestName string, modulesDirName string, @@ -320,10 +330,12 @@ func addTypingNamesAndGetFilesToWatch( } if len(ownTypes) != 0 { absolutePath := manifestPath.Directory().ResolveFile(ownTypes) + filesToWatch = append(filesToWatch, absolutePath.AsPath()) if fs.FileExists(absolutePath) { logger.Log(fmt.Sprintf("ATA:: Package '%s' provides its own types.", manifest.Name.Value)) inferredTypings[manifest.Name.Value] = absolutePath } else { + *missingTypingFiles = append(*missingTypingFiles, absolutePath) logger.Log(fmt.Sprintf("ATA:: Package '%s' provides its own types but they are missing.", manifest.Name.Value)) } } else { diff --git a/tsc/internal/project/ata/discovertypings_test.go b/tsc/internal/project/ata/discovertypings_test.go index cfc686a95bbfc..be60f2f9d8cfa 100644 --- a/tsc/internal/project/ata/discovertypings_test.go +++ b/tsc/internal/project/ata/discovertypings_test.go @@ -50,6 +50,25 @@ func TestDiscoveryInputsEqual(t *testing.T) { func TestDiscoverTypings(t *testing.T) { t.Parallel() + t.Run("should reuse cached scoped typings", func(t *testing.T) { + t.Parallel() + const typingFile = "/cache/node_modules/@types/a__b/index.d.ts" + fs := vfstest.FromMap(map[string]string{typingFile: ""}, tspath.CaseSensitive) + cache := &collections.SyncMap[string, *ata.CachedTyping]{} + version := semver.MustParse("1.3.0") + cache.Store("a__b", &ata.CachedTyping{TypingsLocation: typingFile, Version: &version}) + var logger *logging.LogTree + paths, names, _ := ata.DiscoverTypings( + fs, logger, &ata.TypingsInfo{ + CompilerOptions: &core.CompilerOptions{Types: []string{}}, + TypeAcquisition: &core.TypeAcquisition{Enable: core.TSTrue, Include: []string{"@a/b"}}, + UnresolvedImports: &collections.Set[string]{}, + }, nil, "/project", cache, map[string]map[string]string{"a__b": {"latest": "1.3.0"}}, + ) + assert.DeepEqual(t, paths, []tspath.RootedFilePath{typingFile}) + assert.Equal(t, len(names), 0) + }) + t.Run("should use mappings from safe list", func(t *testing.T) { t.Parallel() logger := logging.NewLogTree("DiscoverTypings") diff --git a/tsc/internal/project/ata/installnpmpackages_test.go b/tsc/internal/project/ata/installnpmpackages_test.go index 8f98b96156fd8..624ce7b6a22f6 100644 --- a/tsc/internal/project/ata/installnpmpackages_test.go +++ b/tsc/internal/project/ata/installnpmpackages_test.go @@ -1,13 +1,65 @@ package ata import ( + "context" "fmt" "sync/atomic" "testing" + "github.com/microsoft/TypeScript/tsc/internal/collections" + "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/project/logging" + "github.com/microsoft/TypeScript/tsc/internal/semver" + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs" + "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" "gotest.tools/v3/assert" ) +type cacheReadCountingFS struct { + vfs.FS + unrelatedReads int +} + +func (fs *cacheReadCountingFS) ReadFile(path tspath.RootedFilePath) (string, bool) { + if path == "/cache/node_modules/@types/unrelated/package.json" { + fs.unrelatedReads++ + } + return fs.FS.ReadFile(path) +} + +func TestTypingsInstallerResolvesOnlyDemandedCacheEntries(t *testing.T) { + t.Parallel() + fs := &cacheReadCountingFS{FS: vfstest.FromMap(map[string]string{ + "/cache/node_modules/@types/node/package.json": `{"name":"@types/node","types":"new.d.ts"}`, + "/cache/node_modules/@types/node/index.d.ts": "", + "/cache/node_modules/@types/node/new.d.ts": "", + "/cache/node_modules/@types/unrelated/package.json": `{"name":"@types/unrelated","types":"index.d.ts"}`, + "/cache/node_modules/@types/unrelated/index.d.ts": "", + }, tspath.CaseSensitive)} + ti := NewTypingsInstaller(&TypingsInstallerOptions{TypingsLocation: "/cache", ThrottleLimit: 1}, fs, nil) + ti.initOnce.Do(func() {}) + version := semver.MustParse("1.3.0") + for _, name := range []string{"node", "unrelated"} { + ti.packageNameToTypingLocation.Store(name, &CachedTyping{ + TypingsLocation: tspath.RootedFilePathFromNormalized("/cache/node_modules/@types/" + name + "/index.d.ts"), Version: &version, + }) + } + ti.typesRegistry = map[string]map[string]string{"node": {"latest": "1.3.0"}, "unrelated": {"latest": "1.3.0"}} + var logger *logging.LogTree + result, err := ti.discoverAndInstallTypings(context.Background(), &TypingsInstallRequest{ + TypingsInfo: &TypingsInfo{ + CompilerOptions: &core.CompilerOptions{Types: []string{}}, + TypeAcquisition: &core.TypeAcquisition{Enable: core.TSTrue, Include: []string{"node"}}, + UnresolvedImports: &collections.Set[string]{}, + }, + ProjectRootPath: "/project", FS: fs, Logger: logger, + }) + assert.NilError(t, err) + assert.DeepEqual(t, result.TypingsFiles, []tspath.RootedFilePath{"/cache/node_modules/@types/node/new.d.ts"}) + assert.Equal(t, fs.unrelatedReads, 0, "resolving a demanded entry point must not resolve unrelated cached packages") +} + func TestInstallNpmPackages(t *testing.T) { t.Parallel() packageNames := []string{ diff --git a/tsc/internal/project/projectcollectionbuilder.go b/tsc/internal/project/projectcollectionbuilder.go index 4a57e3f6065a6..a099a3781e55e 100644 --- a/tsc/internal/project/projectcollectionbuilder.go +++ b/tsc/internal/project/projectcollectionbuilder.go @@ -36,6 +36,7 @@ const ( type ProjectCollectionBuilder struct { sessionOptions *SessionOptions + hasTypingsInstaller bool cachedTypingEntryPointsAreCurrent func([]ata.CachedTypingEntryPoint) bool parseCache *ParseCache contentMappedParseCache *ContentMappedParseCache @@ -81,6 +82,7 @@ func newProjectCollectionBuilder( inferredContentMappers []*contentmapper.Mapper, inferredContentMapperExtensions []string, sessionOptions *SessionOptions, + hasTypingsInstaller bool, customConfigFileName string, parseCache *ParseCache, contentMappedParseCache *ContentMappedParseCache, @@ -97,6 +99,7 @@ func newProjectCollectionBuilder( inferredContentMappers: inferredContentMappers, inferredContentMapperExtensions: inferredContentMapperExtensions, sessionOptions: sessionOptions, + hasTypingsInstaller: hasTypingsInstaller, cachedTypingEntryPointsAreCurrent: ata.NewCachedTypingEntryPointValidator(fs.fs, sessionOptions.TypingsLocation), parseCache: parseCache, contentMappedParseCache: contentMappedParseCache, @@ -1129,9 +1132,10 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState } continue } - if ataChange.Discovery != nil && !ataChange.Discovery.IsCurrent(b.fs.fs, ataChange.TypingsInfo, ataChange.FileNames, ataChange.ProjectDirectory) { + if slices.ContainsFunc(ataChange.TypingsFiles, func(file tspath.RootedFilePath) bool { return !b.fs.fs.FileExists(file) }) || + slices.ContainsFunc(ataChange.MissingTypingFiles, b.fs.fs.FileExists) { if logger != nil { - logger.Logf("Ignoring ATA state with obsolete discovery inputs for project %s", projectID) + logger.Logf("Ignoring ATA state with changed typing file availability for project %s", projectID) } continue } @@ -1851,6 +1855,7 @@ func (b *ProjectCollectionBuilder) updateProgram(entry dirty.Value[*Project], lo } project.dirty = false project.dirtyFilePath = "" + b.prepareForTypingsInstallation(project) b.releaseDroppedProjectReferences(oldProgram, result.Program, project.ID()) if oldCheckerPool != nil { oldCheckerPool.Discard() @@ -1881,6 +1886,11 @@ func (b *ProjectCollectionBuilder) updateProgram(entry dirty.Value[*Project], lo } filesChanged = b.updateProgram(entry, logger) || filesChanged } else { + project := entry.Value() + if !project.typingsDiscoveryInputsEqual(state.installedTypingsFileNames) || + (state.installedTypingsInfo != nil && !state.installedTypingsInfo.Equals(project.ComputeTypingsInfo())) { + b.invalidateProjectATAState(projectID) + } entry.ChangeIf( func(project *Project) bool { return state.canApplyWatchState(project, b.sessionOptions.WatchEnabled) @@ -1894,6 +1904,34 @@ func (b *ProjectCollectionBuilder) updateProgram(entry dirty.Value[*Project], lo return filesChanged } +func (b *ProjectCollectionBuilder) prepareForTypingsInstallation(project *Project) { + if !b.hasTypingsInstaller || !project.ShouldTriggerATA(b.newSnapshotID) { + return + } + info := project.ComputeTypingsInfo() + if project.installedTypingsInfo != nil && project.installedTypingsInfo.Equals(info) && + project.typingsDiscoveryInputsEqual(project.installedTypingsFileNames) { + return + } + // Watch discovery inputs before the first install can finish or the + // project can close, so intervening changes invalidate its generation. + if len(project.installedTypingsFilesToWatch) != 0 && + (!project.typingsDiscoveryInputsEqual(project.installedTypingsFileNames) || + (project.installedTypingsInfo != nil && !project.installedTypingsInfo.Equals(info))) { + project.ataInvalidationSnapshotID = b.newSnapshotID + if _, inferred := project.ID().Inferred(); inferred { + b.inferredProjectATAInvalidationSnapshotID = b.newSnapshotID + } + } + project.installedTypingsInfo = nil + project.installedTypingsFileNames = project.ComputeTypingsFileNames() + project.installedTypingsFilesToWatch = ata.DiscoveryWatchInputs(&info, project.installedTypingsFileNames, project.projectDirectory) + project.typingsWatch = project.typingsWatch.Clone(getTypingsLocationsGlobs( + slices.Concat(project.installedTypingsFilesToWatch, core.Map(project.typingsFiles, func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() })), + b.sessionOptions.TypingsLocation, b.sessionOptions.CurrentDirectory, b.fs.fs.CaseSensitivity(), + )) +} + func (b *ProjectCollectionBuilder) markFilesChanged(entry dirty.Value[*Project], paths []tspath.PathKey, changeType lsproto.FileChangeType, logger *logging.LogTree) { var dirty bool var dirtyFilePath tspath.PathKey diff --git a/tsc/internal/project/session.go b/tsc/internal/project/session.go index dd3164d589ea6..adfd9985ceb1a 100644 --- a/tsc/internal/project/session.go +++ b/tsc/internal/project/session.go @@ -2089,8 +2089,7 @@ func (s *Session) triggerATAForUpdatedProjects(newSnapshot *Snapshot) { TypingsFiles: result.TypingsFiles, TypingsFilesToWatch: result.FilesToWatch, TypingCacheEntryPoints: result.CacheEntryPoints, - Discovery: result.Discovery, - ProjectDirectory: request.ProjectRootPath, + MissingTypingFiles: result.MissingTypingFiles, Logs: logTree, } } diff --git a/tsc/internal/project/snapshot.go b/tsc/internal/project/snapshot.go index 85c701d398e90..2e01bbc354fa2 100644 --- a/tsc/internal/project/snapshot.go +++ b/tsc/internal/project/snapshot.go @@ -428,8 +428,7 @@ type ATAStateChange struct { // TypingsFilesToWatch is the new list of typing files to watch for changes. TypingsFilesToWatch []tspath.RootedPath TypingCacheEntryPoints []ata.CachedTypingEntryPoint - Discovery *ata.TypingsDiscovery - ProjectDirectory tspath.RootedDirectoryPath + MissingTypingFiles []tspath.RootedFilePath Logs *logging.LogTree } @@ -568,6 +567,7 @@ func (s *Snapshot) Clone( inferredContentMappers, inferredContentMapperExtensions, store.options, + store.hasTypingsInstaller, customConfigFileName, store.parseCache, store.contentMappedParseCache, diff --git a/tsc/internal/project/snapshot_test.go b/tsc/internal/project/snapshot_test.go index 9fdbd5ec862e3..81d33abc60896 100644 --- a/tsc/internal/project/snapshot_test.go +++ b/tsc/internal/project/snapshot_test.go @@ -104,6 +104,47 @@ func TestSnapshot(t *testing.T) { return session } + t.Run("discarding provisional discovery coverage invalidates older requests", func(t *testing.T) { + t.Parallel() + session := setup(map[string]any{ + "/x/app.js": "", + "/x/package.json": `{"dependencies":{"foo":"1.0.0"}}`, + "/y/app.js": "", + "/typings/foo.d.ts": "declare const foo: number;", + }) + defer session.Close() + session.hasTypingsInstaller = true + ctx := context.Background() + session.DidOpenFile(ctx, "file:///x/app.js", 1, "", lsproto.LanguageKindJavaScript) + firstSnapshotID := session.Snapshot().ID() + firstProject := session.Snapshot().ProjectCollection.InferredProject() + firstInfo := firstProject.ComputeTypingsInfo() + firstFiles := firstProject.ComputeTypingsFileNames() + session.DidCloseFile(ctx, "file:///x/app.js") + session.DidOpenFile(ctx, "file:///y/app.js", 1, "", lsproto.LanguageKindJavaScript) + assert.Assert(t, session.Snapshot().ProjectCollection.inferredProjectATAInvalidationSnapshotID > firstSnapshotID, + "discarding the x discovery watches must invalidate requests dispatched under them") + assert.NilError(t, session.fs.WriteFile("/x/package.json", `{}`)) + snapshot, err := session.APIUpdate(ctx, FileChangeSummary{ + Changed: *collections.NewSetFromItems(lsproto.DocumentUri("file:///x/package.json")), + }, nil) + assert.NilError(t, err) + snapshot.Deref() + session.DidCloseFile(ctx, "file:///y/app.js") + session.DidOpenFile(ctx, "file:///x/app.js", 1, "", lsproto.LanguageKindJavaScript) + session.pendingATAChanges[firstProject.ID()] = &ATAStateChange{ + SnapshotID: firstSnapshotID, + TypingsInfo: &firstInfo, + FileNames: firstFiles, + TypingsFiles: []tspath.RootedFilePath{"/typings/foo.d.ts"}, + } + snapshot, err = session.APIUpdate(ctx, FileChangeSummary{}, nil) + assert.NilError(t, err) + defer snapshot.Deref() + assert.Assert(t, snapshot.ProjectCollection.InferredProject().installedTypingsInfo == nil, + "returning to x must not make its obsolete result valid again") + }) + t.Run("creates and removes synthetic programs", func(t *testing.T) { t.Parallel() session := setup(map[string]any{ diff --git a/tsc/internal/project/snapshothost.go b/tsc/internal/project/snapshothost.go index 7ca6e02444c06..158809cc237b9 100644 --- a/tsc/internal/project/snapshothost.go +++ b/tsc/internal/project/snapshothost.go @@ -27,6 +27,7 @@ type SnapshotHost struct { extendedConfigCache *ExtendedConfigCache programCounter *programCounter contentMapperHost contentmapper.Host + hasTypingsInstaller bool snapshotID atomic.Uint64 } @@ -93,6 +94,7 @@ func NewSnapshotHost(init *SessionInit) *SnapshotHost { extendedConfigCache: NewExtendedConfigCache(), programCounter: &programCounter{}, contentMapperHost: newContentMapperHost(init), + hasTypingsInstaller: init.Options.TypingsLocation != "" && init.NpmExecutor != nil, } } From 9a62ce9195db730ac3e1427ba26dc58c78cf68eb Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:04:25 -0700 Subject: [PATCH 10/12] Track symlinked ATA inputs before installation Discovery can read symlinked dependency manifests that never enter the compiler host's alias cache. Real-path changes must invalidate ATA even when its project is closed or its first installation is still pending. Coverage must be established before discovery reads, not merely when installation finishes. Shared watch references are not proof of client registration, so watch deltas also need snapshot-ordered acknowledgement. --- tsc/internal/project/ata/ata.go | 16 ++- tsc/internal/project/ata/ata_test.go | 101 ++++++++++++++++++ tsc/internal/project/ata/discovertypings.go | 62 +++++++++-- .../project/ata/installnpmpackages_test.go | 69 ++++++++++++ .../project/projectcollectionbuilder.go | 59 ++++++---- tsc/internal/project/session.go | 70 +++++++++++- tsc/internal/project/snapshot.go | 13 ++- tsc/internal/project/watchtimeout_test.go | 71 ++++++++++++ 8 files changed, 425 insertions(+), 36 deletions(-) diff --git a/tsc/internal/project/ata/ata.go b/tsc/internal/project/ata/ata.go index 61c2959b63ed1..b11426afc85f6 100644 --- a/tsc/internal/project/ata/ata.go +++ b/tsc/internal/project/ata/ata.go @@ -111,6 +111,8 @@ type TypingsInstallRequest struct { ProjectRootPath tspath.RootedDirectoryPath FS vfs.FS Logger logging.Logger + // OnDiscovery publishes watch inputs before reading their manifests. + OnDiscovery func([]tspath.RootedPath) error } type TypingsInstallResult struct { @@ -138,9 +140,19 @@ func (ti *TypingsInstaller) InstallTypings(ctx context.Context, request *Typings func (ti *TypingsInstaller) discoverAndInstallTypings(ctx context.Context, request *TypingsInstallRequest) (*TypingsInstallResult, error) { ti.init(ctx, request.FS, request.Logger) - inferredTypings, filesToWatch, missingTypingFiles := discoverTypingNames( - request.FS, request.Logger, request.TypingsInfo, request.FileNames, request.ProjectRootPath, + inferredTypings, filesToWatch, missingTypingFiles, err := discoverTypingNames( + request.FS, request.Logger, request.TypingsInfo, request.FileNames, request.ProjectRootPath, request.OnDiscovery, ) + if err != nil { + return nil, err + } + slices.Sort(filesToWatch) + filesToWatch = slices.Compact(filesToWatch) + if request.OnDiscovery != nil { + if err := request.OnDiscovery(filesToWatch); err != nil { + return nil, err + } + } cachedTypings := ti.resolveCachedTypings(request.FS, inferredTypings) cachedTypingPaths, newTypingNames := getCachedTypingPaths(inferredTypings, cachedTypings, ti.typesRegistry, request.Logger) makeResult := func(files []tspath.RootedFilePath, filesToWatch []tspath.RootedPath) *TypingsInstallResult { diff --git a/tsc/internal/project/ata/ata_test.go b/tsc/internal/project/ata/ata_test.go index 443e8713802be..f246710337d7d 100644 --- a/tsc/internal/project/ata/ata_test.go +++ b/tsc/internal/project/ata/ata_test.go @@ -1205,6 +1205,107 @@ func TestATA(t *testing.T) { assert.Assert(t, typingsFile == nil, "jquery types should not be reused after the manifest changes") }) + for _, state := range []string{"installed", "install pending"} { + for _, variant := range []string{"package", "manifest", "dependency directory", "bower package"} { + t.Run("real-path manifest change invalidates dormant ATA "+variant+" "+state, func(t *testing.T) { + t.Parallel() + const directory = "/user/username/projects/project" + const realManifest = "/vendor/foo/package.json" + packageDirectory := directory + "/node_modules/foo" + rootManifest := directory + "/package.json" + dependencyManifest := packageDirectory + "/package.json" + if variant == "bower package" { + packageDirectory = directory + "/bower_components/foo" + rootManifest = directory + "/bower.json" + dependencyManifest = packageDirectory + "/bower.json" + } + files := map[string]any{ + directory + "/app.js": "", + rootManifest: `{"dependencies":{"foo":"1.0.0"}}`, + realManifest: `{"name":"foo"}`, + } + switch variant { + case "package": + files[packageDirectory] = vfstest.Symlink("/vendor/foo") + files["/vendor/foo/index.d.ts"] = "declare const ownTypes: number;" + case "manifest": + files[dependencyManifest] = vfstest.Symlink(realManifest) + files[packageDirectory+"/index.d.ts"] = "declare const ownTypes: number;" + case "dependency directory": + files[directory+"/node_modules"] = vfstest.Symlink("/vendor") + files["/vendor/foo/index.d.ts"] = "declare const ownTypes: number;" + case "bower package": + files[packageDirectory] = vfstest.Symlink("/vendor/foo") + files["/vendor/foo/bower.json"] = files[realManifest] + delete(files, realManifest) + files["/vendor/foo/index.d.ts"] = "declare const ownTypes: number;" + } + manifest := tspath.RootedFilePathFromNormalized(realManifest) + if variant == "bower package" { + manifest = "/vendor/foo/bower.json" + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"foo": "declare const fallback: number;"}, + }) + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var once sync.Once + release := func() { once.Do(func() { close(releaseInstall) }) } + defer release() + if state == "install pending" { + install := utils.NpmExecutor().NpmInstallFunc + utils.NpmExecutor().NpmInstallFunc = func(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + if slices.Contains(args, "@types/foo@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return install(ctx, cwd, args) + } + } + ctx := context.Background() + uri := lsproto.DocumentUri("file://" + directory + "/app.js") + fallback := tspath.RootedFilePathFromNormalized(projecttestutil.TestTypingsLocation + "/node_modules/@types/foo/index.d.ts") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + if state == "install pending" { + waitForInstall(t, installStarted) + assert.Assert(t, utils.WatchesFile(manifest.AsString()), "real-path watches must be registered before installation starts") + } else { + session.WaitForBackgroundTasks() + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, slices.Contains(ls.GetProgram().CommandLine().FileNames(), fallback)) + session.WaitForBackgroundTasks() + } + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() + assert.NilError(t, utils.FS().WriteFile(manifest, `{"name":"foo","types":"index.d.ts"}`)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: lsproto.DocumentUri("file://" + manifest.AsString()), Type: lsproto.FileChangeTypeChanged, + }}) + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + release() + session.WaitForBackgroundTasks() + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + inferred := session.Snapshot().ProjectCollection.InferredProject() + assert.Assert(t, !slices.Contains(inferred.GetProgram().CommandLine().FileNames(), fallback), + "a real-path manifest event must discard the obsolete fallback before reopening") + session.WaitForBackgroundTasks() + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, slices.Contains(ls.GetProgram().CommandLine().FileNames(), tspath.RootedFilePathFromNormalized(packageDirectory+"/index.d.ts"))) + assert.Assert(t, utils.WatchesFile(manifest.AsString()), "the real discovery manifest must be watched") + }) + } + } + for _, variant := range []string{"regular", "symlinked cache", "symlinked package"} { t.Run("cached typings entry point changes without manifest discovery "+variant, func(t *testing.T) { t.Parallel() diff --git a/tsc/internal/project/ata/discovertypings.go b/tsc/internal/project/ata/discovertypings.go index 07291c1107734..393b142d53f74 100644 --- a/tsc/internal/project/ata/discovertypings.go +++ b/tsc/internal/project/ata/discovertypings.go @@ -8,6 +8,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/collections" "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/debug" "github.com/microsoft/TypeScript/tsc/internal/json" "github.com/microsoft/TypeScript/tsc/internal/module" "github.com/microsoft/TypeScript/tsc/internal/packagejson" @@ -36,7 +37,8 @@ func DiscoverTypings( packageNameToTypingLocation *collections.SyncMap[string, *CachedTyping], typesRegistry map[string]map[string]string, ) (cachedTypingPaths []tspath.RootedFilePath, newTypingNames []string, filesToWatch []tspath.RootedPath) { - inferredTypings, filesToWatch, _ := discoverTypingNames(fs, logger, typingsInfo, fileNames, projectRootPath) + inferredTypings, filesToWatch, _, err := discoverTypingNames(fs, logger, typingsInfo, fileNames, projectRootPath, nil) + debug.Assert(err == nil, "discovery without a watch publisher cannot fail") cachedTypingPaths, newTypingNames = getCachedTypingPaths(inferredTypings, packageNameToTypingLocation, typesRegistry, logger) return cachedTypingPaths, newTypingNames, filesToWatch } @@ -47,7 +49,8 @@ func discoverTypingNames( typingsInfo *TypingsInfo, fileNames []tspath.RootedFilePath, projectRootPath tspath.RootedDirectoryPath, -) (inferredTypings map[string]tspath.RootedFilePath, filesToWatch []tspath.RootedPath, missingTypingFiles []tspath.RootedFilePath) { + onDiscovery func([]tspath.RootedPath) error, +) (inferredTypings map[string]tspath.RootedFilePath, filesToWatch []tspath.RootedPath, missingTypingFiles []tspath.RootedFilePath, err error) { // A typing name to typing file path mapping inferredTypings = map[string]tspath.RootedFilePath{} @@ -55,6 +58,13 @@ func discoverTypingNames( fileNames = core.Filter(fileNames, func(fileName tspath.RootedFilePath) bool { return fileName.HasJSFileExtension() }) + if onDiscovery != nil { + inputs := discoveryManifestWatchInputs(typingsInfo, fileNames, projectRootPath) + inputs = appendRealTypingWatchInputs(fs, inputs) + if err = onDiscovery(inputs); err != nil { + return nil, nil, nil, err + } + } if typingsInfo.TypeAcquisition.Include != nil { addInferredTypings(fs, logger, inferredTypings, typingsInfo.TypeAcquisition.Include, "Explicitly included types") @@ -64,8 +74,12 @@ func discoverTypingNames( // Directories to search for package.json, bower.json and other typing information if typingsInfo.CompilerOptions.Types == nil { for _, searchDir := range discoveryDirectories(typingsInfo, fileNames, projectRootPath) { - filesToWatch = addTypingNamesAndGetFilesToWatch(fs, logger, inferredTypings, filesToWatch, &missingTypingFiles, searchDir, "bower.json", "bower_components") - filesToWatch = addTypingNamesAndGetFilesToWatch(fs, logger, inferredTypings, filesToWatch, &missingTypingFiles, searchDir, "package.json", "node_modules") + for _, pair := range [][2]string{{"bower.json", "bower_components"}, {"package.json", "node_modules"}} { + filesToWatch, err = addTypingNamesAndGetFilesToWatch(fs, logger, inferredTypings, filesToWatch, &missingTypingFiles, searchDir, pair[0], pair[1], onDiscovery) + if err != nil { + return nil, nil, nil, err + } + } } } @@ -90,7 +104,17 @@ func discoverTypingNames( delete(inferredTypings, excludeTypingName) logger.Log(fmt.Sprintf("ATA:: Typing for %s is in exclude list, will be ignored.", excludeTypingName)) } - return inferredTypings, filesToWatch, missingTypingFiles + filesToWatch = appendRealTypingWatchInputs(fs, filesToWatch) + return inferredTypings, filesToWatch, missingTypingFiles, nil +} + +func appendRealTypingWatchInputs(fs vfs.FS, filesToWatch []tspath.RootedPath) []tspath.RootedPath { + for _, path := range filesToWatch { + if realPath := fs.Realpath(path); fs.CaseSensitivity().ComparePaths(path, realPath) != 0 { + filesToWatch = append(filesToWatch, realPath) + } + } + return filesToWatch } func getCachedTypingPaths( @@ -119,12 +143,17 @@ func getCachedTypingPaths( return cachedTypingPaths, newTypingNames } -func DiscoveryWatchInputs(info *TypingsInfo, files []tspath.RootedFilePath, projectDirectory tspath.RootedDirectoryPath) []tspath.RootedPath { +func discoveryManifestWatchInputs(info *TypingsInfo, files []tspath.RootedFilePath, projectDirectory tspath.RootedDirectoryPath) []tspath.RootedPath { var paths []tspath.RootedPath for _, directory := range discoveryDirectories(info, files, projectDirectory) { paths = append(paths, directory.ResolveFile("package.json").AsPath(), directory.ResolveFile("bower.json").AsPath(), directory.ResolveDirectory("node_modules").AsPath(), directory.ResolveDirectory("bower_components").AsPath()) } + return paths +} + +func DiscoveryWatchInputs(info *TypingsInfo, files []tspath.RootedFilePath, projectDirectory tspath.RootedDirectoryPath) []tspath.RootedPath { + paths := discoveryManifestWatchInputs(info, files, projectDirectory) for _, file := range files { paths = append(paths, file.AsPath()) } @@ -229,7 +258,8 @@ func addTypingNamesAndGetFilesToWatch( projectRootPath tspath.RootedDirectoryPath, manifestName string, modulesDirName string, -) []tspath.RootedPath { + onDiscovery func([]tspath.RootedPath) error, +) ([]tspath.RootedPath, error) { // First, we check the manifests themselves. They're not // _required_, but they allow us to do some filtering when dealing // with big flat dep directories. @@ -257,7 +287,7 @@ func addTypingNamesAndGetFilesToWatch( packagesFolderPath := projectRootPath.ResolveDirectory(modulesDirName) filesToWatch = append(filesToWatch, packagesFolderPath.AsPath()) if !fs.DirectoryExists(packagesFolderPath) { - return filesToWatch + return filesToWatch, nil } // There's two cases we have to take into account here: @@ -313,6 +343,20 @@ func addTypingNamesAndGetFilesToWatch( // Once we have the names of things to look up, we iterate over // and either collect their included typings, or add them to the // list of typings we need to look up separately. + watchCount := len(filesToWatch) + for _, manifestPath := range dependencyManifestNames { + // These reads bypass the compiler host's realpath alias cache. + for _, path := range []tspath.RootedPath{manifestPath.AsPath(), manifestPath.Directory().AsPath()} { + if realPath := fs.Realpath(path); fs.CaseSensitivity().ComparePaths(path, realPath) != 0 { + filesToWatch = append(filesToWatch, path, realPath) + } + } + } + if onDiscovery != nil && len(filesToWatch) != watchCount { + if err := onDiscovery(filesToWatch); err != nil { + return nil, err + } + } for _, manifestPath := range dependencyManifestNames { manifestContents, ok := fs.ReadFile(manifestPath) if !ok { @@ -343,7 +387,7 @@ func addTypingNamesAndGetFilesToWatch( } } addInferredTypings(fs, logger, inferredTypings, packageNames, " Found package names") - return filesToWatch + return filesToWatch, nil } /** diff --git a/tsc/internal/project/ata/installnpmpackages_test.go b/tsc/internal/project/ata/installnpmpackages_test.go index 624ce7b6a22f6..51880099d0b03 100644 --- a/tsc/internal/project/ata/installnpmpackages_test.go +++ b/tsc/internal/project/ata/installnpmpackages_test.go @@ -2,6 +2,7 @@ package ata import ( "context" + "errors" "fmt" "sync/atomic" "testing" @@ -21,6 +22,74 @@ type cacheReadCountingFS struct { unrelatedReads int } +type discoveryWatchCheckingFS struct { + vfs.FS + t *testing.T + watched collections.Set[tspath.PathKey] + checkedReads int +} + +func (fs *discoveryWatchCheckingFS) ReadFile(path tspath.RootedFilePath) (string, bool) { + realPath := fs.FS.Realpath(path.AsPath()) + if fs.CaseSensitivity().ComparePaths(path.AsPath(), realPath) != 0 { + assert.Assert(fs.t, fs.watched.Has(fs.CaseSensitivity().PathKey(realPath)), "real discovery inputs must be published before reading them") + fs.checkedReads++ + } + return fs.FS.ReadFile(path) +} + +func TestTypingsInstallerPublishesDiscoveryWatchesBeforeReadingManifests(t *testing.T) { + t.Parallel() + fs := &discoveryWatchCheckingFS{ + t: t, + FS: vfstest.FromMap(map[string]any{ + "/project/package.json": vfstest.Symlink("/vendor/project/package.json"), + "/vendor/project/package.json": `{"dependencies":{"foo":"1.0.0"}}`, + "/project/node_modules/foo": vfstest.Symlink("/vendor/foo"), + "/vendor/foo/package.json": `{"name":"foo","types":"index.d.ts"}`, + "/vendor/foo/index.d.ts": "", + }, tspath.CaseInsensitive), + } + ti := NewTypingsInstaller(&TypingsInstallerOptions{TypingsLocation: "/cache", ThrottleLimit: 1}, fs, nil) + ti.initOnce.Do(func() {}) + var logger *logging.LogTree + result, err := ti.discoverAndInstallTypings(context.Background(), &TypingsInstallRequest{ + TypingsInfo: &TypingsInfo{ + CompilerOptions: &core.CompilerOptions{}, + TypeAcquisition: &core.TypeAcquisition{Enable: core.TSTrue}, + }, + ProjectRootPath: "/project", FS: fs, Logger: logger, + OnDiscovery: func(inputs []tspath.RootedPath) error { + for _, input := range inputs { + fs.watched.Add(fs.CaseSensitivity().PathKey(input)) + } + return nil + }, + }) + assert.NilError(t, err) + assert.Equal(t, fs.checkedReads, 2) + assert.DeepEqual(t, result.TypingsFiles, []tspath.RootedFilePath{"/project/node_modules/foo/index.d.ts"}) +} + +func TestTypingsInstallerPropagatesDiscoveryPublicationErrors(t *testing.T) { + t.Parallel() + fs := vfstest.FromMap(map[string]string{}, tspath.CaseSensitive) + ti := NewTypingsInstaller(&TypingsInstallerOptions{TypingsLocation: "/cache", ThrottleLimit: 1}, fs, nil) + ti.initOnce.Do(func() {}) + var logger *logging.LogTree + publicationError := errors.New("watch publication failed") + result, err := ti.discoverAndInstallTypings(context.Background(), &TypingsInstallRequest{ + TypingsInfo: &TypingsInfo{ + CompilerOptions: &core.CompilerOptions{}, + TypeAcquisition: &core.TypeAcquisition{Enable: core.TSTrue}, + }, + ProjectRootPath: "/project", FS: fs, Logger: logger, + OnDiscovery: func([]tspath.RootedPath) error { return publicationError }, + }) + assert.ErrorIs(t, err, publicationError) + assert.Assert(t, result == nil) +} + func (fs *cacheReadCountingFS) ReadFile(path tspath.RootedFilePath) (string, bool) { if path == "/cache/node_modules/@types/unrelated/package.json" { fs.unrelatedReads++ diff --git a/tsc/internal/project/projectcollectionbuilder.go b/tsc/internal/project/projectcollectionbuilder.go index a099a3781e55e..c5e127ce2d2c3 100644 --- a/tsc/internal/project/projectcollectionbuilder.go +++ b/tsc/internal/project/projectcollectionbuilder.go @@ -1044,7 +1044,7 @@ func (b *ProjectCollectionBuilder) ensureProjectTree( } } -func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAStateChange, fileChanges FileChangeSummary, logger *logging.LogTree) { +func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAStateChange, fileChanges FileChangeSummary, watchOnly bool, logger *logging.LogTree) { installedTypingsSnapshotID := func(projectID ID) uint64 { if _, inferred := projectID.Inferred(); inferred { if state := b.inferredProjectATAState; state != nil { @@ -1082,27 +1082,37 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState p.typingsDiscoveryInputsEqual(ataChange.FileNames) }, func(p *Project) { - p.installedTypingsInfo = ataChange.TypingsInfo + if !watchOnly { + p.installedTypingsInfo = ataChange.TypingsInfo + p.installedTypingCacheEntryPoints = slices.Clone(ataChange.TypingCacheEntryPoints) + p.setTypingsFiles(ataChange.TypingsFiles) + p.dirty = true + p.dirtyFilePath = "" + } p.installedTypingsSnapshotID = ataChange.SnapshotID p.installedTypingsFileNames = slices.Clone(ataChange.FileNames) - p.installedTypingsFilesToWatch = slices.Clone(ataChange.TypingsFilesToWatch) - p.installedTypingCacheEntryPoints = slices.Clone(ataChange.TypingCacheEntryPoints) - p.setTypingsFiles(ataChange.TypingsFiles) + if watchOnly { + p.installedTypingsFilesToWatch = slices.Concat(p.installedTypingsFilesToWatch, ataChange.TypingsFilesToWatch) + slices.Sort(p.installedTypingsFilesToWatch) + p.installedTypingsFilesToWatch = slices.Compact(p.installedTypingsFilesToWatch) + } else { + p.installedTypingsFilesToWatch = slices.Clone(ataChange.TypingsFilesToWatch) + } typingsWatchGlobs := getTypingsLocationsGlobs( - slices.Concat(ataChange.TypingsFilesToWatch, core.Map(ataChange.TypingsFiles, func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() })), + slices.Concat(p.installedTypingsFilesToWatch, core.Map(p.typingsFiles, func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() })), b.sessionOptions.TypingsLocation, b.sessionOptions.CurrentDirectory, b.fs.fs.CaseSensitivity(), ) p.typingsWatch = p.typingsWatch.Clone(typingsWatchGlobs) - p.dirty = true - p.dirtyFilePath = "" }, ) } for projectID, ataChange := range ataChanges { - logger.Embed(ataChange.Logs) + if ataChange.Logs != nil { + logger.Embed(ataChange.Logs) + } if !b.cachedTypingEntryPointsAreCurrent(ataChange.TypingCacheEntryPoints) { if logger != nil { logger.Logf("Ignoring ATA state with obsolete cached typing entry points for project %s", projectID) @@ -1143,25 +1153,36 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState if inferred := b.inferredProject.Value(); inferred != nil && inferred.Program != nil { updateProject(b.inferredProject, ataChange) } else { + state := &inferredProjectATAState{} + if watchOnly && b.inferredProjectATAState != nil { + *state = *b.inferredProjectATAState + } + if !watchOnly { + state.installedTypingsInfo = ataChange.TypingsInfo + state.typingsFiles = slices.Clone(ataChange.TypingsFiles) + state.installedTypingCacheEntryPoints = slices.Clone(ataChange.TypingCacheEntryPoints) + } + state.installedTypingsFileNames = slices.Clone(ataChange.FileNames) + if watchOnly { + state.installedTypingsFilesToWatch = slices.Concat(state.installedTypingsFilesToWatch, ataChange.TypingsFilesToWatch) + slices.Sort(state.installedTypingsFilesToWatch) + state.installedTypingsFilesToWatch = slices.Compact(state.installedTypingsFilesToWatch) + } else { + state.installedTypingsFilesToWatch = slices.Clone(ataChange.TypingsFilesToWatch) + } + state.snapshotID = ataChange.SnapshotID typingsWatch := newTypingsWatch(b) if b.inferredProjectATAState != nil && b.inferredProjectATAState.typingsWatch != nil { typingsWatch = b.inferredProjectATAState.typingsWatch } typingsWatchGlobs := getTypingsLocationsGlobs( - slices.Concat(ataChange.TypingsFilesToWatch, core.Map(ataChange.TypingsFiles, func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() })), + slices.Concat(state.installedTypingsFilesToWatch, core.Map(state.typingsFiles, func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() })), b.sessionOptions.TypingsLocation, b.sessionOptions.CurrentDirectory, b.fs.fs.CaseSensitivity(), ) - b.inferredProjectATAState = &inferredProjectATAState{ - installedTypingsInfo: ataChange.TypingsInfo, - installedTypingsFileNames: slices.Clone(ataChange.FileNames), - installedTypingsFilesToWatch: slices.Clone(ataChange.TypingsFilesToWatch), - installedTypingCacheEntryPoints: slices.Clone(ataChange.TypingCacheEntryPoints), - typingsFiles: slices.Clone(ataChange.TypingsFiles), - typingsWatch: typingsWatch.Clone(typingsWatchGlobs), - snapshotID: ataChange.SnapshotID, - } + state.typingsWatch = typingsWatch.Clone(typingsWatchGlobs) + b.inferredProjectATAState = state } } else if syntheticProjectID, ok := projectID.Synthetic(); ok { if project, loaded := b.syntheticProjects.Load(syntheticProjectID); loaded { diff --git a/tsc/internal/project/session.go b/tsc/internal/project/session.go index adfd9985ceb1a..52f890bc2efea 100644 --- a/tsc/internal/project/session.go +++ b/tsc/internal/project/session.go @@ -44,6 +44,7 @@ const ( UpdateReasonDidCloseFile UpdateReasonDidChangeCompilerOptionsForInferredProjects UpdateReasonRequestedLanguageServicePendingChanges + UpdateReasonATADiscovery UpdateReasonRequestedLanguageServiceProjectNotLoaded UpdateReasonRequestedLanguageServiceForFileNotOpen UpdateReasonRequestedLanguageServiceProjectDirty @@ -136,6 +137,8 @@ type Session struct { snapshot *Snapshot snapshotMu sync.RWMutex snapshotUpdateMu sync.Mutex + // Protected by snapshotMu; watch deltas must register in snapshot order. + watchUpdatesDone <-chan struct{} // scheduledSnapshotUpdateCancel is the cancelation function for a scheduled // snapshot update. Snapshot updates are scheduled and debounced after file closes. @@ -1411,10 +1414,17 @@ func (s *Session) updateSnapshot(ctx context.Context, overlays map[tspath.PathKe oldSnapshot.Deref() contentMapperTimings = s.takeContentMapperTimingDelta() } + var previousWatchUpdatesDone <-chan struct{} + var watchUpdatesDone chan struct{} + if s.options.WatchEnabled { + previousWatchUpdatesDone = s.watchUpdatesDone + watchUpdatesDone = make(chan struct{}) + s.watchUpdatesDone = watchUpdatesDone + } s.snapshotMu.Unlock() // Enqueue ATA updates if needed - if s.typingsInstaller != nil && !s.Config().IsATADisabled() { + if len(change.ataDiscoveryChanges) == 0 && s.typingsInstaller != nil && !s.Config().IsATADisabled() { s.triggerATAForUpdatedProjects(newSnapshot) } @@ -1430,10 +1440,26 @@ func (s *Session) updateSnapshot(ctx context.Context, overlays map[tspath.PathKe s.logContentMapperTimings(contentMapperTimings) s.logger.Log("") } + var watchError error if s.options.WatchEnabled { - if err := s.updateWatches(oldSnapshot, newSnapshot); err != nil && s.options.LoggingEnabled { - s.logger.Log(err) + if previousWatchUpdatesDone != nil { + select { + case <-previousWatchUpdatesDone: + // The preceding snapshot's registrations are complete. + case <-ctx.Done(): + watchError = ctx.Err() + } + } + if watchError == nil { + watchError = s.updateWatches(oldSnapshot, newSnapshot) + } + if watchError != nil && s.options.LoggingEnabled { + s.logger.Log(watchError) } + close(watchUpdatesDone) + } + if change.watchUpdatesDone != nil { + change.watchUpdatesDone <- watchError } _ = s.updateContentMapperRegistrations(ctx, newSnapshot) s.publishProgramDiagnostics(oldSnapshot, newSnapshot) @@ -2037,6 +2063,33 @@ func (s *Session) publishGlobalDiagnostics(ctx context.Context) { } } +func (s *Session) publishATADiscovery(ctx context.Context, projectID ID, change *ATAStateChange) error { + s.snapshotUpdateMu.Lock() + if err := ctx.Err(); err != nil { + s.snapshotUpdateMu.Unlock() + return err + } + snapshot := s.Snapshot() + watchesDone := make(chan error, 1) + snapshotChange := SnapshotChange{ + reason: UpdateReasonATADiscovery, + ataDiscoveryChanges: map[ID]*ATAStateChange{projectID: change}, + watchUpdatesDone: watchesDone, + fileSystemOverride: snapshot.fileSystemOverride, + } + if snapshot.fileSystemOverride { + snapshotChange.fs = snapshot.fs.fs + } + s.updateSnapshot(ctx, snapshot.overlays(), snapshotChange, false) + s.snapshotUpdateMu.Unlock() + select { + case err := <-watchesDone: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + func (s *Session) triggerATAForUpdatedProjects(newSnapshot *Snapshot) { for _, project := range newSnapshot.ProjectCollection.Projects() { if project.ShouldTriggerATA(newSnapshot.ID()) { @@ -2055,6 +2108,17 @@ func (s *Session) triggerATAForUpdatedProjects(newSnapshot *Snapshot) { ProjectRootPath: project.projectDirectory, FS: s.fs, Logger: logTree, + OnDiscovery: func(filesToWatch []tspath.RootedPath) error { + filesToWatch = slices.Clone(filesToWatch) + slices.Sort(filesToWatch) + filesToWatch = slices.Compact(filesToWatch) + return s.publishATADiscovery(ctx, project.ID(), &ATAStateChange{ + SnapshotID: newSnapshot.ID(), + TypingsInfo: &typingsInfo, + FileNames: fileNames, + TypingsFilesToWatch: filesToWatch, + }) + }, } projectDisplayName := project.DisplayName(s.options.CurrentDirectory) diff --git a/tsc/internal/project/snapshot.go b/tsc/internal/project/snapshot.go index 2e01bbc354fa2..dbdaa43496684 100644 --- a/tsc/internal/project/snapshot.go +++ b/tsc/internal/project/snapshot.go @@ -409,8 +409,10 @@ type SnapshotChange struct { contentMapperContributions *ContentMapperContributions newConfig *lsutil.UserPreferences // ataChanges contains ATA-related changes to apply to projects in the new snapshot. - ataChanges map[ID]*ATAStateChange - apiRequest *APISnapshotRequest + ataChanges map[ID]*ATAStateChange + ataDiscoveryChanges map[ID]*ATAStateChange + watchUpdatesDone chan<- error + apiRequest *APISnapshotRequest // cleanFileCache triggers cleaning of cached files not referenced by any open project. cleanFileCache bool } @@ -496,6 +498,8 @@ func (s *Snapshot) Clone( logger.Logf("Reason: DidChangeConfigFile - %v", getDetails()) case UpdateReasonDidChangeContentMapperContributions: logger.Logf("Reason: DidChangeContentMapperContributions - %v", getDetails()) + case UpdateReasonATADiscovery: + logger.Log("Reason: ATADiscovery") } } @@ -605,6 +609,9 @@ func (s *Snapshot) Clone( if !change.fileChanges.IsEmpty() { projectCollectionBuilder.DidChangeFiles(change.fileChanges, logger.Fork("DidChangeFiles")) } + if len(change.ataDiscoveryChanges) != 0 { + projectCollectionBuilder.DidUpdateATAState(change.ataDiscoveryChanges, typingsWatchChanges, true, logger.Fork("DidDiscoverATAWatchInputs")) + } if !typingsWatchChanges.IsEmpty() { projectCollectionBuilder.DidChangeTypingsWatchInputs(typingsWatchChanges, logger.Fork("DidChangeTypingsWatchInputs")) } @@ -612,7 +619,7 @@ func (s *Snapshot) Clone( projectCollectionBuilder.DidChangeCachedTypingEntryPoints(typingCacheChanges, logger.Fork("DidChangeCachedTypingEntryPoints")) } if len(change.ataChanges) != 0 { - projectCollectionBuilder.DidUpdateATAState(change.ataChanges, typingsWatchChanges, logger.Fork("DidUpdateATAState")) + projectCollectionBuilder.DidUpdateATAState(change.ataChanges, typingsWatchChanges, false, logger.Fork("DidUpdateATAState")) } var apiError error diff --git a/tsc/internal/project/watchtimeout_test.go b/tsc/internal/project/watchtimeout_test.go index 129749dd0e1bb..f36a480f24f4e 100644 --- a/tsc/internal/project/watchtimeout_test.go +++ b/tsc/internal/project/watchtimeout_test.go @@ -2,6 +2,7 @@ package project_test import ( "context" + "strings" "sync" "sync/atomic" "testing" @@ -12,9 +13,79 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" "github.com/microsoft/TypeScript/tsc/internal/project" "github.com/microsoft/TypeScript/tsc/internal/testutil/projecttestutil" + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" "gotest.tools/v3/assert" ) +func TestATADiscoveryWaitsForSharedWatchRegistration(t *testing.T) { + t.Parallel() + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + synctest.Test(t, func(t *testing.T) { + files := map[string]any{ + "/user/username/projects/p1/app.js": "", + "/user/username/projects/p1/tsconfig.json": `{"compilerOptions":{"allowJs":true},"typeAcquisition":{"enable":true}}`, + "/user/username/projects/p1/package.json": `{"dependencies":{"foo":"1.0.0"}}`, + "/user/username/projects/p1/node_modules/foo": vfstest.Symlink("/vendor/foo"), + "/user/username/projects/p2/app.js": "", + "/user/username/projects/p2/tsconfig.json": `{"compilerOptions":{"allowJs":true},"typeAcquisition":{"enable":true}}`, + "/user/username/projects/p2/package.json": `{"dependencies":{"foo":"1.0.0"}}`, + "/user/username/projects/p2/node_modules/foo": vfstest.Symlink("/vendor/foo"), + "/vendor/foo/package.json": `{"name":"foo"}`, + } + init, utils := projecttestutil.GetSessionInitOptions(files, nil, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"foo": "declare const foo: number;"}, + }) + init.Options.CurrentDirectory = "/user/username/projects" + registered := make(chan struct{}) + var pending atomic.Bool + var once sync.Once + release := func() { once.Do(func() { close(registered) }) } + utils.Client().WatchFilesFunc = func(ctx context.Context, id project.WatcherID, watchers []*lsproto.FileSystemWatcher) error { + for _, watcher := range watchers { + external := watcher.GlobPattern.Pattern != nil && strings.HasPrefix(*watcher.GlobPattern.Pattern, "/vendor/") + if pattern := watcher.GlobPattern.RelativePattern; pattern != nil && pattern.BaseUri.URI != nil { + external = strings.HasPrefix(string(*pattern.BaseUri.URI), "file:///vendor") + } + if external && pending.CompareAndSwap(false, true) { + select { + case <-registered: + // Complete the simulated registration. + case <-ctx.Done(): + return ctx.Err() + } + } + } + return nil + } + var installs atomic.Int32 + install := utils.NpmExecutor().NpmInstallFunc + utils.NpmExecutor().NpmInstallFunc = func(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + for _, arg := range args { + if arg == "@types/foo@latest" { + installs.Add(1) + } + } + return install(ctx, cwd, args) + } + session := project.NewSession(init) + defer session.Close() + defer release() + ctx := context.Background() + session.DidOpenFile(ctx, "file:///user/username/projects/p1/app.js", 1, "", lsproto.LanguageKindJavaScript) + synctest.Wait() + assert.Assert(t, pending.Load(), "the first external watch registration must be pending") + session.DidOpenFile(ctx, "file:///user/username/projects/p2/app.js", 1, "", lsproto.LanguageKindJavaScript) + synctest.Wait() + assert.Equal(t, installs.Load(), int32(0), "sharing an in-flight registration must not release another discovery publisher") + release() + session.WaitForBackgroundTasks() + assert.Assert(t, installs.Load() > 0) + }) +} + func TestUpdateWatchTimeoutAndRollback(t *testing.T) { t.Parallel() if !bundled.Embedded { From a155c7c23534139129dbd5445877977fcad6f2ea Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:56:56 -0700 Subject: [PATCH 11/12] Check cached ATA entry points before reuse A declaration's continued existence does not prove it is still the typing entry point. A symlinked package manifest can change outside the package directory while the inferred project is closed, without invalidating its saved ATA state. Dormant restoration needs fresh entry-point validation, and cache watch coverage needs to include the real manifest target. --- tsc/internal/project/ata/ata_test.go | 27 +++++++++- .../project/projectcollectionbuilder.go | 49 ++++++++++--------- 2 files changed, 52 insertions(+), 24 deletions(-) diff --git a/tsc/internal/project/ata/ata_test.go b/tsc/internal/project/ata/ata_test.go index f246710337d7d..77c4f5e9589f6 100644 --- a/tsc/internal/project/ata/ata_test.go +++ b/tsc/internal/project/ata/ata_test.go @@ -1306,7 +1306,7 @@ func TestATA(t *testing.T) { } } - for _, variant := range []string{"regular", "symlinked cache", "symlinked package"} { + for _, variant := range []string{"regular", "symlinked cache", "symlinked package", "symlinked manifest"} { t.Run("cached typings entry point changes without manifest discovery "+variant, func(t *testing.T) { t.Parallel() @@ -1337,10 +1337,18 @@ func TestATA(t *testing.T) { } case "symlinked package": files[projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery"] = vfstest.Symlink(packageDirectory) + case "symlinked manifest": + target := "/real/jquery/package.json" + files[target] = files[manifest] + files[manifest] = vfstest.Symlink(target) + manifest = target } - session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + init, utils := projecttestutil.GetSessionInitOptions(files, nil, &projecttestutil.TypingsInstallerOptions{ TypesRegistry: []string{"jquery"}, }) + init.Options.CurrentDirectory = "/user/username/projects/project" + session := project.NewSession(init) + defer session.Close() ctx := context.Background() session.DidChangeCompilerOptionsForInferredProjects(ctx, &core.CompilerOptions{ AllowJs: core.TSTrue, @@ -1385,6 +1393,21 @@ func TestATA(t *testing.T) { ls, err = session.GetLanguageService(ctx, uri) assert.NilError(t, err) assert.Assert(t, slices.Contains(ls.GetProgram().CommandLine().FileNames(), indexFile)) + + session.DidCloseFile(ctx, uri) + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() + assert.NilError(t, utils.FS().WriteFile(tspath.RootedFilePathFromNormalized(manifest), `{"name":"@types/jquery","types":"new.d.ts"}`)) + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + inferred = session.Snapshot().ProjectCollection.InferredProject() + assert.Assert(t, !slices.Contains(inferred.GetProgram().CommandLine().FileNames(), indexFile), + "restoring dormant state must validate the entry point even without a delivered watch event") + session.WaitForBackgroundTasks() + ls, err = session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, slices.Contains(ls.GetProgram().CommandLine().FileNames(), newFile)) }) } diff --git a/tsc/internal/project/projectcollectionbuilder.go b/tsc/internal/project/projectcollectionbuilder.go index c5e127ce2d2c3..c946c738c2357 100644 --- a/tsc/internal/project/projectcollectionbuilder.go +++ b/tsc/internal/project/projectcollectionbuilder.go @@ -588,12 +588,7 @@ func (b *ProjectCollectionBuilder) DidChangeCachedTypingEntryPoints(summary File // Cache outputs do not advance the discovery invalidation generation: a // result delivered with an install's writes can already contain the new entry points. affected := func(entries []ata.CachedTypingEntryPoint) bool { - var paths []tspath.RootedPath - for _, entry := range entries { - packageDirectory := b.sessionOptions.TypingsLocation.ResolveDirectory("node_modules/@types/" + entry.PackageName) - paths = append(paths, packageDirectory.AsPath(), b.fs.fs.Realpath(packageDirectory.AsPath()), entry.FileName.AsPath()) - } - return fileChangeSummaryAffectsTypingsWatch(summary, paths, nil, b.fs.fs.CaseSensitivity()) && + return fileChangeSummaryAffectsTypingsWatch(summary, b.cachedTypingWatchInputs(entries), nil, b.fs.fs.CaseSensitivity()) && !b.cachedTypingEntryPointsAreCurrent(entries) } b.forEachProject(func(entry dirty.Value[*Project]) bool { @@ -617,6 +612,23 @@ func (b *ProjectCollectionBuilder) DidChangeCachedTypingEntryPoints(summary File } } +func (b *ProjectCollectionBuilder) cachedTypingWatchInputs(entries []ata.CachedTypingEntryPoint) []tspath.RootedPath { + var paths []tspath.RootedPath + for _, entry := range entries { + directory := b.sessionOptions.TypingsLocation.ResolveDirectory("node_modules/@types/" + entry.PackageName) + paths = append(paths, directory.AsPath(), b.fs.fs.Realpath(directory.AsPath()), + b.fs.fs.Realpath(directory.ResolveFile("package.json").AsPath()), entry.FileName.AsPath()) + } + return paths +} + +func (b *ProjectCollectionBuilder) typingsWatchGlobs(filesToWatch []tspath.RootedPath, typingsFiles []tspath.RootedFilePath, entries []ata.CachedTypingEntryPoint) PatternsAndIgnored { + return getTypingsLocationsGlobs( + slices.Concat(filesToWatch, core.Map(typingsFiles, func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() }), b.cachedTypingWatchInputs(entries)), + b.sessionOptions.TypingsLocation, b.sessionOptions.CurrentDirectory, b.fs.fs.CaseSensitivity(), + ) +} + func (b *ProjectCollectionBuilder) DidInvalidateTypingsWatchState(logger *logging.LogTree) { b.forEachProject(func(entry dirty.Value[*Project]) bool { b.invalidateProjectATAState(entry.Value().ID()) @@ -1098,12 +1110,7 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState } else { p.installedTypingsFilesToWatch = slices.Clone(ataChange.TypingsFilesToWatch) } - typingsWatchGlobs := getTypingsLocationsGlobs( - slices.Concat(p.installedTypingsFilesToWatch, core.Map(p.typingsFiles, func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() })), - b.sessionOptions.TypingsLocation, - b.sessionOptions.CurrentDirectory, - b.fs.fs.CaseSensitivity(), - ) + typingsWatchGlobs := b.typingsWatchGlobs(p.installedTypingsFilesToWatch, p.typingsFiles, p.installedTypingCacheEntryPoints) p.typingsWatch = p.typingsWatch.Clone(typingsWatchGlobs) }, ) @@ -1175,12 +1182,7 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState if b.inferredProjectATAState != nil && b.inferredProjectATAState.typingsWatch != nil { typingsWatch = b.inferredProjectATAState.typingsWatch } - typingsWatchGlobs := getTypingsLocationsGlobs( - slices.Concat(state.installedTypingsFilesToWatch, core.Map(state.typingsFiles, func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() })), - b.sessionOptions.TypingsLocation, - b.sessionOptions.CurrentDirectory, - b.fs.fs.CaseSensitivity(), - ) + typingsWatchGlobs := b.typingsWatchGlobs(state.installedTypingsFilesToWatch, state.typingsFiles, state.installedTypingCacheEntryPoints) state.typingsWatch = typingsWatch.Clone(typingsWatchGlobs) b.inferredProjectATAState = state } @@ -1896,10 +1898,14 @@ func (b *ProjectCollectionBuilder) updateProgram(entry dirty.Value[*Project], lo b.inferredProjectATAState = nil if entry.ChangeIf( func(project *Project) bool { - return state.canApply(project, b.fs, b.sessionOptions.WatchEnabled) + return state.canApply(project, b.fs, b.sessionOptions.WatchEnabled) && + b.cachedTypingEntryPointsAreCurrent(state.installedTypingCacheEntryPoints) }, func(project *Project) { state.apply(project) + project.typingsWatch = project.typingsWatch.Clone(b.typingsWatchGlobs( + project.installedTypingsFilesToWatch, project.typingsFiles, project.installedTypingCacheEntryPoints, + )) }, ) { if logger != nil { @@ -1947,9 +1953,8 @@ func (b *ProjectCollectionBuilder) prepareForTypingsInstallation(project *Projec project.installedTypingsInfo = nil project.installedTypingsFileNames = project.ComputeTypingsFileNames() project.installedTypingsFilesToWatch = ata.DiscoveryWatchInputs(&info, project.installedTypingsFileNames, project.projectDirectory) - project.typingsWatch = project.typingsWatch.Clone(getTypingsLocationsGlobs( - slices.Concat(project.installedTypingsFilesToWatch, core.Map(project.typingsFiles, func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() })), - b.sessionOptions.TypingsLocation, b.sessionOptions.CurrentDirectory, b.fs.fs.CaseSensitivity(), + project.typingsWatch = project.typingsWatch.Clone(b.typingsWatchGlobs( + project.installedTypingsFilesToWatch, project.typingsFiles, project.installedTypingCacheEntryPoints, )) } From bebd49c06bf41201a3bd66cf564fc6a2ae63ce0e Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:35:50 -0700 Subject: [PATCH 12/12] Reject stale ATA fallback after declarations appear Package-owned declarations can appear while an inferred project is closed, before their watch events arrive. Retain missing declaration paths in installed ATA state so reopening does not restore an obsolete cached fallback, including when installation finishes after closing. --- tsc/internal/project/ata/ata_test.go | 85 +++++++++++++++++++ tsc/internal/project/project.go | 8 +- .../project/projectcollectionbuilder.go | 2 + 3 files changed, 94 insertions(+), 1 deletion(-) diff --git a/tsc/internal/project/ata/ata_test.go b/tsc/internal/project/ata/ata_test.go index 77c4f5e9589f6..73073fd018b08 100644 --- a/tsc/internal/project/ata/ata_test.go +++ b/tsc/internal/project/ata/ata_test.go @@ -213,6 +213,91 @@ func TestATA(t *testing.T) { assert.Assert(t, !slices.Contains(ls.GetProgram().CommandLine().FileNames(), projecttestutil.TestTypingsLocation+"/node_modules/@types/foo/index.d.ts")) }) + for _, applyWhileClosed := range []bool{false, true} { + name := "live" + if applyWhileClosed { + name = "dormant" + } + t.Run("external package declaration created while closed invalidates "+name+" fallback", func(t *testing.T) { + t.Parallel() + const ownedTypes = "/user/username/projects/shared/foo.d.ts" + const fallback = projecttestutil.TestTypingsLocation + "/node_modules/@types/foo/index.d.ts" + files := map[string]any{ + "/user/username/projects/project/app.js": "", + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"foo":"^1.0.0","jquery":"^3.1.0"}}`, + "/user/username/projects/project/node_modules/foo/package.json": `{"name":"foo","types":"../../../shared/foo.d.ts"}`, + fallback: `declare const fallback: number;`, + projecttestutil.TestTypingsLocation + "/package.json": `{"devDependencies":{"@types/foo":"^1.3.0"}}`, + projecttestutil.TestTypingsLocation + "/package-lock.json": `{"dependencies":{"@types/foo":{"version":"1.3.0"}}}`, + } + init, utils := projecttestutil.GetSessionInitOptions(files, nil, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"foo": `declare const fallback: number;`, "jquery": `declare const $: number;`}, + }) + init.Options.CurrentDirectory = "/user/username/projects/project" + session := project.NewSession(init) + defer session.Close() + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var once sync.Once + release := func() { once.Do(func() { close(releaseInstall) }) } + defer release() + install := utils.NpmExecutor().NpmInstallFunc + utils.NpmExecutor().NpmInstallFunc = func(ctx context.Context, cwd tspath.RootedDirectoryPath, args []string) ([]byte, error) { + if slices.Contains(args, "@types/jquery@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return install(ctx, cwd, args) + } + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + closeFile := func() { + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() + } + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + waitForInstall(t, installStarted) + if applyWhileClosed { + closeFile() + } + release() + session.WaitForBackgroundTasks() + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + if applyWhileClosed { + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + } + snapshot.Deref() + assert.Assert(t, utils.WatchesFile(ownedTypes)) + if !applyWhileClosed { + ls, serviceErr := session.GetLanguageService(ctx, uri) + assert.NilError(t, serviceErr) + assert.Assert(t, slices.Contains(ls.GetProgram().CommandLine().FileNames(), fallback)) + session.WaitForBackgroundTasks() + closeFile() + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + assert.Assert(t, slices.Contains(session.Snapshot().ProjectCollection.InferredProject().GetProgram().CommandLine().FileNames(), fallback), + "an unchanged missing declaration must allow immediate fallback reuse") + session.WaitForBackgroundTasks() + closeFile() + } + + assert.NilError(t, utils.FS().WriteFile(ownedTypes, `declare const ownTypes: number;`)) + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + inferred := session.Snapshot().ProjectCollection.InferredProject() + assert.Assert(t, !slices.Contains(inferred.GetProgram().CommandLine().FileNames(), fallback), + "a newly available package declaration must prevent fallback reuse without a delivered watch event") + session.WaitForBackgroundTasks() + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, slices.Contains(ls.GetProgram().CommandLine().FileNames(), ownedTypes)) + assert.Assert(t, !slices.Contains(ls.GetProgram().CommandLine().FileNames(), fallback)) + }) + } + t.Run("cached scoped dependency survives subsequent ATA requests", func(t *testing.T) { t.Parallel() const manifest = "/user/username/projects/project/package.json" diff --git a/tsc/internal/project/project.go b/tsc/internal/project/project.go index b09d2611dd5a4..02d53280e855a 100644 --- a/tsc/internal/project/project.go +++ b/tsc/internal/project/project.go @@ -195,6 +195,7 @@ type Project struct { // typings discovery to run again. installedTypingsFilesToWatch []tspath.RootedPath installedTypingCacheEntryPoints []ata.CachedTypingEntryPoint + installedMissingTypingFiles []tspath.RootedFilePath // ataInvalidationSnapshotID is the latest snapshot that invalidated this // project's ATA discovery inputs. ataInvalidationSnapshotID uint64 @@ -208,6 +209,7 @@ type inferredProjectATAState struct { installedTypingsFileNames []tspath.RootedFilePath installedTypingsFilesToWatch []tspath.RootedPath installedTypingCacheEntryPoints []ata.CachedTypingEntryPoint + installedMissingTypingFiles []tspath.RootedFilePath typingsFiles []tspath.RootedFilePath typingsWatch *WatchedFiles[PatternsAndIgnored] snapshotID uint64 @@ -229,6 +231,7 @@ func (p *Project) inferredProjectATAState() *inferredProjectATAState { installedTypingsFileNames: slices.Clone(p.installedTypingsFileNames), installedTypingsFilesToWatch: slices.Clone(p.installedTypingsFilesToWatch), installedTypingCacheEntryPoints: slices.Clone(p.installedTypingCacheEntryPoints), + installedMissingTypingFiles: slices.Clone(p.installedMissingTypingFiles), typingsFiles: slices.Clone(p.typingsFiles), typingsWatch: p.typingsWatch, snapshotID: snapshotID, @@ -251,7 +254,7 @@ func (s *inferredProjectATAState) canApply(project *Project, fs *snapshotFSBuild return false } } - return true + return !slices.ContainsFunc(s.installedMissingTypingFiles, fs.fs.FileExists) } func (s *inferredProjectATAState) apply(project *Project) { @@ -260,6 +263,7 @@ func (s *inferredProjectATAState) apply(project *Project) { project.installedTypingsFileNames = slices.Clone(s.installedTypingsFileNames) project.installedTypingsFilesToWatch = slices.Clone(s.installedTypingsFilesToWatch) project.installedTypingCacheEntryPoints = slices.Clone(s.installedTypingCacheEntryPoints) + project.installedMissingTypingFiles = slices.Clone(s.installedMissingTypingFiles) project.setTypingsFiles(slices.Clone(s.typingsFiles)) project.typingsWatch = s.typingsWatch project.installedTypingsSnapshotID = s.snapshotID @@ -283,6 +287,7 @@ func (s *inferredProjectATAState) applyWatchState(project *Project) { core.Map(typingDiscoveryFiles(s.typingsFiles, s.installedTypingCacheEntryPoints), func(path tspath.RootedFilePath) tspath.RootedPath { return path.AsPath() }), ) project.installedTypingCacheEntryPoints = slices.Clone(s.installedTypingCacheEntryPoints) + project.installedMissingTypingFiles = slices.Clone(s.installedMissingTypingFiles) project.typingsWatch = s.typingsWatch project.installedTypingsSnapshotID = s.snapshotID } @@ -542,6 +547,7 @@ func (p *Project) Clone() *Project { installedTypingsFileNames: p.installedTypingsFileNames, installedTypingsFilesToWatch: p.installedTypingsFilesToWatch, installedTypingCacheEntryPoints: p.installedTypingCacheEntryPoints, + installedMissingTypingFiles: p.installedMissingTypingFiles, typingsFiles: p.typingsFiles, ataInvalidationSnapshotID: p.ataInvalidationSnapshotID, installedTypingsSnapshotID: p.installedTypingsSnapshotID, diff --git a/tsc/internal/project/projectcollectionbuilder.go b/tsc/internal/project/projectcollectionbuilder.go index c946c738c2357..cda54d1b161d9 100644 --- a/tsc/internal/project/projectcollectionbuilder.go +++ b/tsc/internal/project/projectcollectionbuilder.go @@ -1097,6 +1097,7 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState if !watchOnly { p.installedTypingsInfo = ataChange.TypingsInfo p.installedTypingCacheEntryPoints = slices.Clone(ataChange.TypingCacheEntryPoints) + p.installedMissingTypingFiles = slices.Clone(ataChange.MissingTypingFiles) p.setTypingsFiles(ataChange.TypingsFiles) p.dirty = true p.dirtyFilePath = "" @@ -1168,6 +1169,7 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState state.installedTypingsInfo = ataChange.TypingsInfo state.typingsFiles = slices.Clone(ataChange.TypingsFiles) state.installedTypingCacheEntryPoints = slices.Clone(ataChange.TypingCacheEntryPoints) + state.installedMissingTypingFiles = slices.Clone(ataChange.MissingTypingFiles) } state.installedTypingsFileNames = slices.Clone(ataChange.FileNames) if watchOnly {