From 9bd87cb57af400d4035e265dd625dbb85196df17 Mon Sep 17 00:00:00 2001 From: Sheetal Nandi Date: Fri, 30 May 2025 16:38:51 -0700 Subject: [PATCH 01/11] Add cleanup code for projects and script infos --- internal/project/ata.go | 2 +- internal/project/project.go | 156 ++++++++---- internal/project/projectlifetime_test.go | 169 +++++++++++++ internal/project/scriptinfo.go | 9 +- internal/project/service.go | 236 ++++++++++++++---- internal/project/watch.go | 24 +- .../projecttestutil/projecttestutil.go | 6 + 7 files changed, 487 insertions(+), 115 deletions(-) create mode 100644 internal/project/projectlifetime_test.go diff --git a/internal/project/ata.go b/internal/project/ata.go index fcea2b782c..d27f8f9711 100644 --- a/internal/project/ata.go +++ b/internal/project/ata.go @@ -145,7 +145,7 @@ func (ti *TypingsInstaller) EnqueueInstallTypingsRequest(p *Project, typingsInfo } func (ti *TypingsInstaller) discoverAndInstallTypings(p *Project, typingsInfo *TypingsInfo, fileNames []string, projectRootPath string) { - ti.init((p)) + ti.init(p) cachedTypingPaths, newTypingNames, filesToWatch := DiscoverTypings( p.FS(), diff --git a/internal/project/project.go b/internal/project/project.go index 1044d00a07..1ac4aedf4f 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -171,6 +171,7 @@ func NewConfiguredProject(configFileName string, configFilePath tspath.Path, hos project.configFileName = configFileName project.configFilePath = configFilePath project.initialLoadPending = true + project.pendingReload = PendingReloadFull client := host.Client() if host.IsWatchEnabled() && client != nil { project.rootFilesWatch = newWatchedFiles(project, lsproto.WatchKindChange|lsproto.WatchKindCreate|lsproto.WatchKindDelete, core.Identity, "root files") @@ -362,6 +363,16 @@ func (p *Project) updateWatchers(ctx context.Context) { p.affectingLocationsWatch.update(ctx, affectingLocationGlobs) } +func (p *Project) tryInvokeWildCardDirectories(fileName string, path tspath.Path) bool { + if p.kind == KindConfigured { + if p.rootFileNames.Has(path) || p.parsedCommandLine.MatchesFileName(fileName) { + p.SetPendingReload(PendingReloadFileNames) + return true + } + } + return false +} + // onWatchEventForNilScriptInfo is fired for watch events that are not the // project tsconfig, and do not have a ScriptInfo for the associated file. // This could be a case of one of the following: @@ -371,14 +382,9 @@ func (p *Project) updateWatchers(ctx context.Context) { // part of the project, e.g., a .js file in a project without --allowJs. func (p *Project) onWatchEventForNilScriptInfo(fileName string) { path := p.toPath(fileName) - if p.kind == KindConfigured { - if p.rootFileNames.Has(path) || p.parsedCommandLine.MatchesFileName(fileName) { - p.pendingReload = PendingReloadFileNames - p.markAsDirty() - return - } + if p.tryInvokeWildCardDirectories(fileName, path) { + return } - if _, ok := p.failedLookupsWatch.data[path]; ok { p.markAsDirty() } else if _, ok := p.affectingLocationsWatch.data[path]; ok { @@ -430,6 +436,15 @@ func (p *Project) MarkFileAsDirty(path tspath.Path) { } } +func (p *Project) SetPendingReload(level PendingReload) { + p.mu.Lock() + defer p.mu.Unlock() + if level > p.pendingReload { + p.pendingReload = level + p.markAsDirtyLocked() + } +} + func (p *Project) markAsDirty() { p.mu.Lock() defer p.mu.Unlock() @@ -473,12 +488,12 @@ func (p *Project) updateGraph() bool { p.parsedCommandLine = tsoptions.ReloadFileNamesOfParsedCommandLine(p.parsedCommandLine, p.host.FS()) writeFileNames = p.setRootFiles(p.parsedCommandLine.FileNames()) p.programConfig = nil + p.pendingReload = PendingReloadNone case PendingReloadFull: if err := p.loadConfig(); err != nil { panic(fmt.Sprintf("failed to reload config: %v", err)) } } - p.pendingReload = PendingReloadNone } oldProgramReused := p.updateProgram() @@ -707,7 +722,7 @@ func (p *Project) extractUnresolvedImportsFromSourceFile(file *ast.SourceFile, o func (p *Project) UpdateTypingFiles(typingsInfo *TypingsInfo, typingFiles []string) { p.mu.Lock() defer p.mu.Unlock() - if p.typingsInfo != typingsInfo { + if p.isClosed() || p.typingsInfo != typingsInfo { return } @@ -737,6 +752,12 @@ func (p *Project) UpdateTypingFiles(typingsInfo *TypingsInfo, typingFiles []stri } func (p *Project) WatchTypingLocations(files []string) { + p.mu.Lock() + defer p.mu.Unlock() + if p.isClosed() { + return + } + client := p.host.Client() if !p.host.IsWatchEnabled() || client == nil { return @@ -812,15 +833,10 @@ func (p *Project) RemoveFile(info *ScriptInfo, fileExists bool, detachFromProjec } func (p *Project) removeFile(info *ScriptInfo, fileExists bool, detachFromProject bool) { - if p.isRoot(info) { - switch p.kind { - case KindInferred: - p.rootFileNames.Delete(info.path) - p.typeAcquisition = nil - p.programConfig = nil - case KindConfigured: - p.pendingReload = PendingReloadFileNames - } + if p.isRoot(info) && p.kind == KindInferred { + p.rootFileNames.Delete(info.path) + p.typeAcquisition = nil + p.programConfig = nil } p.onFileAddedOrRemoved() @@ -837,28 +853,22 @@ func (p *Project) removeFile(info *ScriptInfo, fileExists bool, detachFromProjec } } -func (p *Project) AddRoot(info *ScriptInfo) { +func (p *Project) AddInferredProjectRoot(info *ScriptInfo) { p.mu.Lock() defer p.mu.Unlock() - p.addRoot(info) + if p.isRoot(info) { + panic("script info is already a root") + } + p.rootFileNames.Set(info.path, info.fileName) p.programConfig = nil - p.markAsDirtyLocked() -} - -func (p *Project) addRoot(info *ScriptInfo) { + p.typeAcquisition = nil // !!! // if p.kind == KindInferred { // p.host.startWatchingConfigFilesForInferredProjectRoot(info.path); // // handle JS toggling // } - if p.isRoot(info) { - panic("script info is already a root") - } - p.rootFileNames.Set(info.path, info.fileName) - if p.kind == KindInferred { - p.typeAcquisition = nil - } info.attachToProject(p) + p.markAsDirtyLocked() } func (p *Project) LoadConfig() error { @@ -875,6 +885,7 @@ func (p *Project) loadConfig() error { } p.programConfig = nil + p.pendingReload = PendingReloadNone if configFileContent, ok := p.host.FS().ReadFile(p.configFileName); ok { configDir := tspath.GetDirectoryPath(p.configFileName) tsConfigSourceFile := tsoptions.NewTsconfigSourceFileFromFilePath(p.configFileName, p.configFilePath, configFileContent) @@ -915,38 +926,31 @@ func (p *Project) setRootFiles(rootFileNames []string) bool { var hasChanged bool newRootScriptInfos := make(map[tspath.Path]struct{}, len(rootFileNames)) for _, file := range rootFileNames { - scriptKind := p.getScriptKind(file) path := p.toPath(file) // !!! updateNonInferredProjectFiles uses a fileExists check, which I guess // could be needed if a watcher fails? - scriptInfo := p.host.GetOrCreateScriptInfoForFile(file, path, scriptKind) newRootScriptInfos[path] = struct{}{} isAlreadyRoot := p.rootFileNames.Has(path) hasChanged = hasChanged || !isAlreadyRoot - - if !isAlreadyRoot && scriptInfo != nil { - p.addRoot(scriptInfo) - if scriptInfo.isOpen { - // !!! - // s.removeRootOfInferredProjectIfNowPartOfOtherProject(scriptInfo) - } - } else if !isAlreadyRoot { - p.rootFileNames.Set(path, file) - } + p.rootFileNames.Set(path, file) + // if !isAlreadyRoot { + // if scriptInfo.isOpen { + // !!!s.removeRootOfInferredProjectIfNowPartOfOtherProject(scriptInfo) + // } + // } } if p.rootFileNames.Size() > len(rootFileNames) { hasChanged = true for root := range p.rootFileNames.Keys() { if _, ok := newRootScriptInfos[root]; !ok { - if info := p.host.GetScriptInfoByPath(root); info != nil { - p.removeFile(info, true /*fileExists*/, true /*detachFromProject*/) - } else { - p.rootFileNames.Delete(root) - } + p.rootFileNames.Delete(root) } } } + if hasChanged { + p.onFileAddedOrRemoved() + } return hasChanged } @@ -994,21 +998,20 @@ func (p *Project) GetFileNames(excludeFilesFromExternalLibraries bool, excludeCo } func (p *Project) print(writeFileNames bool, writeFileExplanation bool, writeFileVersionAndText bool, builder *strings.Builder) string { - builder.WriteString(fmt.Sprintf("Project '%s' (%s)\n", p.name, p.kind.String())) + builder.WriteString(fmt.Sprintf("\nProject '%s' (%s)\n", p.name, p.kind.String())) if p.initialLoadPending { - builder.WriteString("\tFiles (0) InitialLoadPending\n") + builder.WriteString("\n\tFiles (0) InitialLoadPending\n") } else if p.program == nil { - builder.WriteString("\tFiles (0) NoProgram\n") + builder.WriteString("\n\tFiles (0) NoProgram\n") } else { sourceFiles := p.program.GetSourceFiles() - builder.WriteString(fmt.Sprintf("\tFiles (%d)\n", len(sourceFiles))) + builder.WriteString(fmt.Sprintf("\n\tFiles (%d)\n", len(sourceFiles))) if writeFileNames { for _, sourceFile := range sourceFiles { - builder.WriteString("\t\t" + sourceFile.FileName()) + builder.WriteString("\n\t\t" + sourceFile.FileName()) if writeFileVersionAndText { builder.WriteString(fmt.Sprintf(" %d %s", sourceFile.Version, sourceFile.Text())) } - builder.WriteRune('\n') } // !!! // if writeFileExplanation {} @@ -1027,7 +1030,50 @@ func (p *Project) Logf(format string, args ...interface{}) { } func (p *Project) Close() { - // !!! + p.mu.Lock() + defer p.mu.Unlock() + + if p.program != nil { + for _, sourceFile := range p.program.GetSourceFiles() { + p.host.DocumentRegistry().ReleaseDocument(sourceFile, p.program.GetCompilerOptions()) + if scriptInfo := p.host.GetScriptInfoByPath(sourceFile.Path()); scriptInfo != nil { + scriptInfo.detachFromProject(p) + } + } + p.program = nil + } else if p.kind == KindInferred { + // Release root script infos for inferred projects. + for path := range p.rootFileNames.Keys() { + if info := p.host.GetScriptInfoByPath(path); info != nil { + info.detachFromProject(p) + } + } + } + p.rootFileNames = nil + p.parsedCommandLine = nil + p.programConfig = nil + p.checkerPool = nil + p.unresolvedImportsPerFile = nil + p.typingsInfo = nil + p.typingFiles = nil + + // Clean up file watchers waiting for missing files + client := p.host.Client() + if p.host.IsWatchEnabled() && client != nil { + ctx := context.Background() + if p.rootFilesWatch != nil { + p.rootFilesWatch.update(ctx, nil) + } + + p.failedLookupsWatch.update(ctx, nil) + p.affectingLocationsWatch.update(ctx, nil) + p.typingsFilesWatch.update(ctx, nil) + p.typingsDirectoryWatch.update(ctx, nil) + } +} + +func (p *Project) isClosed() bool { + return p.rootFileNames == nil } func formatFileList(files []string, linePrefix string, groupSuffix string) string { diff --git a/internal/project/projectlifetime_test.go b/internal/project/projectlifetime_test.go new file mode 100644 index 0000000000..b637a72b66 --- /dev/null +++ b/internal/project/projectlifetime_test.go @@ -0,0 +1,169 @@ +package project_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/bundled" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/testutil/projecttestutil" + "github.com/microsoft/typescript-go/internal/tspath" + "gotest.tools/v3/assert" +) + +func TestProjectLifetime(t *testing.T) { + t.Parallel() + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + t.Run("configured project", func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/home/projects/TS/p1/tsconfig.json": `{ + "compilerOptions": { + "noLib": true, + "module": "nodenext", + "strict": true + }, + "include": ["src"] + }`, + "/home/projects/TS/p1/src/index.ts": `import { x } from "./x";`, + "/home/projects/TS/p1/src/x.ts": `export const x = 1;`, + "/home/projects/TS/p1/config.ts": `let x = 1, y = 2;`, + "/home/projects/TS/p2/tsconfig.json": `{ + "compilerOptions": { + "noLib": true, + "module": "nodenext", + "strict": true + }, + "include": ["src"] + }`, + "/home/projects/TS/p2/src/index.ts": `import { x } from "./x";`, + "/home/projects/TS/p2/src/x.ts": `export const x = 1;`, + "/home/projects/TS/p2/config.ts": `let x = 1, y = 2;`, + "/home/projects/TS/p3/tsconfig.json": `{ + "compilerOptions": { + "noLib": true, + "module": "nodenext", + "strict": true + }, + "include": ["src"] + }`, + "/home/projects/TS/p3/src/index.ts": `import { x } from "./x";`, + "/home/projects/TS/p3/src/x.ts": `export const x = 1;`, + "/home/projects/TS/p3/config.ts": `let x = 1, y = 2;`, + } + service, host := projecttestutil.Setup(files, nil) + assert.Equal(t, len(service.Projects()), 0) + service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"].(string), core.ScriptKindTS, "") + service.OpenFile("/home/projects/TS/p2/src/index.ts", files["/home/projects/TS/p2/src/index.ts"].(string), core.ScriptKindTS, "") + assert.Equal(t, len(service.Projects()), 2) + assert.Assert(t, service.ConfiguredProject(tspath.ToPath("/home/projects/TS/p1/tsconfig.json", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) != nil) + assert.Assert(t, service.ConfiguredProject(tspath.ToPath("/home/projects/TS/p2/tsconfig.json", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) != nil) + assert.Equal(t, len(host.ClientMock.WatchFilesCalls()), 2) + + service.CloseFile("/home/projects/TS/p1/src/index.ts") + service.OpenFile("/home/projects/TS/p3/src/index.ts", files["/home/projects/TS/p3/src/index.ts"].(string), core.ScriptKindTS, "") + assert.Equal(t, len(service.Projects()), 2) + assert.Assert(t, service.ConfiguredProject(tspath.ToPath("/home/projects/TS/p1/tsconfig.json", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Assert(t, service.ConfiguredProject(tspath.ToPath("/home/projects/TS/p2/tsconfig.json", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) != nil) + assert.Assert(t, service.ConfiguredProject(tspath.ToPath("/home/projects/TS/p3/tsconfig.json", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) != nil) + assert.Assert(t, service.GetScriptInfoByPath(tspath.ToPath("/home/projects/TS/p1/src/index.ts", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Assert(t, service.GetScriptInfoByPath(tspath.ToPath("/home/projects/TS/p1/src/x.ts", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Equal(t, len(host.ClientMock.WatchFilesCalls()), 3) + assert.Equal(t, len(host.ClientMock.UnwatchFilesCalls()), 1) + + service.CloseFile("/home/projects/TS/p2/src/index.ts") + service.CloseFile("/home/projects/TS/p3/src/index.ts") + service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"].(string), core.ScriptKindTS, "") + assert.Assert(t, service.ConfiguredProject(tspath.ToPath("/home/projects/TS/p1/tsconfig.json", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) != nil) + assert.Assert(t, service.ConfiguredProject(tspath.ToPath("/home/projects/TS/p2/tsconfig.json", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Assert(t, service.ConfiguredProject(tspath.ToPath("/home/projects/TS/p3/tsconfig.json", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Assert(t, service.GetScriptInfoByPath(tspath.ToPath("/home/projects/TS/p2/src/index.ts", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Assert(t, service.GetScriptInfoByPath(tspath.ToPath("/home/projects/TS/p2/src/x.ts", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Assert(t, service.GetScriptInfoByPath(tspath.ToPath("/home/projects/TS/p3/src/index.ts", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Assert(t, service.GetScriptInfoByPath(tspath.ToPath("/home/projects/TS/p3/src/x.ts", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Equal(t, len(host.ClientMock.WatchFilesCalls()), 4) + assert.Equal(t, len(host.ClientMock.UnwatchFilesCalls()), 3) + }) + + t.Run("inferred projects", func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/home/projects/TS/p1/src/index.ts": `import { x } from "./x";`, + "/home/projects/TS/p1/src/x.ts": `export const x = 1;`, + "/home/projects/TS/p1/config.ts": `let x = 1, y = 2;`, + "/home/projects/TS/p2/src/index.ts": `import { x } from "./x";`, + "/home/projects/TS/p2/src/x.ts": `export const x = 1;`, + "/home/projects/TS/p2/config.ts": `let x = 1, y = 2;`, + "/home/projects/TS/p3/src/index.ts": `import { x } from "./x";`, + "/home/projects/TS/p3/src/x.ts": `export const x = 1;`, + "/home/projects/TS/p3/config.ts": `let x = 1, y = 2;`, + } + service, host := projecttestutil.Setup(files, nil) + assert.Equal(t, len(service.Projects()), 0) + service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"].(string), core.ScriptKindTS, "/home/projects/TS/p1") + service.OpenFile("/home/projects/TS/p2/src/index.ts", files["/home/projects/TS/p2/src/index.ts"].(string), core.ScriptKindTS, "/home/projects/TS/p2") + assert.Equal(t, len(service.Projects()), 2) + assert.Assert(t, service.InferredProject(tspath.ToPath("/home/projects/TS/p1", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) != nil) + assert.Assert(t, service.InferredProject(tspath.ToPath("/home/projects/TS/p2", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) != nil) + + service.CloseFile("/home/projects/TS/p1/src/index.ts") + service.OpenFile("/home/projects/TS/p3/src/index.ts", files["/home/projects/TS/p3/src/index.ts"].(string), core.ScriptKindTS, "/home/projects/TS/p3") + assert.Equal(t, len(service.Projects()), 2) + assert.Assert(t, service.InferredProject(tspath.ToPath("/home/projects/TS/p1", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Assert(t, service.InferredProject(tspath.ToPath("/home/projects/TS/p2", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) != nil) + assert.Assert(t, service.InferredProject(tspath.ToPath("/home/projects/TS/p3", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) != nil) + assert.Assert(t, service.GetScriptInfoByPath(tspath.ToPath("/home/projects/TS/p1/src/index.ts", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + + service.CloseFile("/home/projects/TS/p2/src/index.ts") + service.CloseFile("/home/projects/TS/p3/src/index.ts") + service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"].(string), core.ScriptKindTS, "/home/projects/TS/p1") + assert.Assert(t, service.InferredProject(tspath.ToPath("/home/projects/TS/p1", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) != nil) + assert.Assert(t, service.InferredProject(tspath.ToPath("/home/projects/TS/p2", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Assert(t, service.InferredProject(tspath.ToPath("/home/projects/TS/p3", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Assert(t, service.GetScriptInfoByPath(tspath.ToPath("/home/projects/TS/p2/src/index.ts", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Assert(t, service.GetScriptInfoByPath(tspath.ToPath("/home/projects/TS/p3/src/index.ts", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + }) + + t.Run("unrooted inferred projects", func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/home/projects/TS/p1/src/index.ts": `import { x } from "./x";`, + "/home/projects/TS/p1/src/x.ts": `export const x = 1;`, + "/home/projects/TS/p1/config.ts": `let x = 1, y = 2;`, + "/home/projects/TS/p2/src/index.ts": `import { x } from "./x";`, + "/home/projects/TS/p2/src/x.ts": `export const x = 1;`, + "/home/projects/TS/p2/config.ts": `let x = 1, y = 2;`, + "/home/projects/TS/p3/src/index.ts": `import { x } from "./x";`, + "/home/projects/TS/p3/src/x.ts": `export const x = 1;`, + "/home/projects/TS/p3/config.ts": `let x = 1, y = 2;`, + } + service, host := projecttestutil.Setup(files, nil) + assert.Equal(t, len(service.Projects()), 0) + service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"].(string), core.ScriptKindTS, "") + service.OpenFile("/home/projects/TS/p2/src/index.ts", files["/home/projects/TS/p2/src/index.ts"].(string), core.ScriptKindTS, "") + assert.Equal(t, len(service.Projects()), 1) + assert.Assert(t, service.InferredProject(tspath.Path("")) != nil) + + service.CloseFile("/home/projects/TS/p1/src/index.ts") + service.OpenFile("/home/projects/TS/p3/src/index.ts", files["/home/projects/TS/p3/src/index.ts"].(string), core.ScriptKindTS, "") + assert.Equal(t, len(service.Projects()), 1) + assert.Assert(t, service.InferredProject(tspath.Path("")) != nil) + assert.Assert(t, service.GetScriptInfoByPath(tspath.ToPath("/home/projects/TS/p1/src/index.ts", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + + service.CloseFile("/home/projects/TS/p2/src/index.ts") + service.CloseFile("/home/projects/TS/p3/src/index.ts") + service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"].(string), core.ScriptKindTS, "") + assert.Assert(t, service.InferredProject(tspath.Path("")) != nil) + assert.Assert(t, service.GetScriptInfoByPath(tspath.ToPath("/home/projects/TS/p2/src/index.ts", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Assert(t, service.GetScriptInfoByPath(tspath.ToPath("/home/projects/TS/p3/src/index.ts", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + + service.CloseFile("/home/projects/TS/p1/src/index.ts") + service.OpenFile("/home/projects/TS/p2/src/index.ts", files["/home/projects/TS/p2/src/index.ts"].(string), core.ScriptKindTS, "/home/projects/TS/p2") + assert.Equal(t, len(service.Projects()), 1) + assert.Assert(t, service.InferredProject(tspath.Path("")) == nil) + assert.Assert(t, service.UnrootedInferredProject() == nil) + assert.Assert(t, service.GetScriptInfoByPath(tspath.ToPath("/home/projects/TS/p1/src/index.ts", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Assert(t, service.InferredProject(tspath.ToPath("/home/projects/TS/p2", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) != nil) + }) +} diff --git a/internal/project/scriptinfo.go b/internal/project/scriptinfo.go index 2e6206c3db..8fc59a5ef3 100644 --- a/internal/project/scriptinfo.go +++ b/internal/project/scriptinfo.go @@ -21,7 +21,6 @@ type ScriptInfo struct { version int lineMap *ls.LineMap - isOpen bool pendingReloadFromDisk bool matchesDiskText bool deferredDelete bool @@ -78,7 +77,6 @@ func (s *ScriptInfo) reloadIfNeeded() { } func (s *ScriptInfo) open(newText string) { - s.isOpen = true s.pendingReloadFromDisk = false if newText != s.text { s.setText(newText) @@ -95,7 +93,6 @@ func (s *ScriptInfo) SetTextFromDisk(newText string) { } func (s *ScriptInfo) close(fileExists bool) { - s.isOpen = false if fileExists && !s.pendingReloadFromDisk && !s.matchesDiskText { s.pendingReloadFromDisk = true s.markContainingProjectsAsDirty() @@ -194,3 +191,9 @@ func (s *ScriptInfo) delayReloadNonMixedContentFile() { s.pendingReloadFromDisk = true s.markContainingProjectsAsDirty() } + +func (s *ScriptInfo) containedByDeferredClosedProject() bool { + return slices.IndexFunc(s.containingProjects, func(project *Project) bool { + return project.deferredClose + }) != -1 +} diff --git a/internal/project/service.go b/internal/project/service.go index 3ec6f02f94..b0bcbc79eb 100644 --- a/internal/project/service.go +++ b/internal/project/service.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "maps" + "slices" "strings" "sync" @@ -23,12 +25,6 @@ const ( projectLoadKindReload ) -type assignProjectResult struct { - configFileName string - retainProjects map[*Project]projectLoadKind - // configFileErrors []*ast.Diagnostic -} - type ServiceOptions struct { TypingsInstallerOptions Logger *Logger @@ -44,6 +40,10 @@ type Service struct { comparePathsOptions tspath.ComparePathsOptions converters *ls.Converters + // !!! sheetal we probably need concurrency handling for project structure and scriptInfo.containingProjects to ensure its not updated across threads + // eg EnsureProjectForOpenFiles - can be called on any thread and may create inferred project through go routine + // We check isOrphan on scriptInfo and that can change any time project structure is updated + projectsMu sync.RWMutex configuredProjects map[tspath.Path]*Project // unrootedInferredProject is the inferred project for files opened without a projectRootDirectory // (e.g. dynamic files) @@ -52,10 +52,12 @@ type Service struct { // if it exists inferredProjects []*Project - documentRegistry *DocumentRegistry - scriptInfosMu sync.RWMutex - scriptInfos map[tspath.Path]*ScriptInfo - openFiles map[tspath.Path]string // values are projectRootPath, if provided + documentRegistry *DocumentRegistry + scriptInfosMu sync.RWMutex + scriptInfos map[tspath.Path]*ScriptInfo + openFiles map[tspath.Path]string // values are projectRootPath, if provided + configFileForOpenFiles map[tspath.Path]string // default config project for open files !!! todo solution and project reference handling + // Contains all the deleted script info's version information so that // it does not reset when creating script info again filenameToScriptInfoVersion map[tspath.Path]int @@ -87,6 +89,7 @@ func NewService(host ServiceHost, options ServiceOptions) *Service { }, scriptInfos: make(map[tspath.Path]*ScriptInfo), openFiles: make(map[tspath.Path]string), + configFileForOpenFiles: make(map[tspath.Path]string), filenameToScriptInfoVersion: make(map[tspath.Path]int), realpathToScriptInfos: make(map[tspath.Path]map[*ScriptInfo]struct{}), } @@ -176,6 +179,26 @@ func (s *Service) Projects() []*Project { return projects } +func (s *Service) ConfiguredProject(path tspath.Path) *Project { + if project, ok := s.configuredProjects[path]; ok { + return project + } + return nil +} + +func (s *Service) InferredProject(rootPath tspath.Path) *Project { + for _, project := range s.inferredProjects { + if project.rootPath == rootPath { + return project + } + } + return nil +} + +func (s *Service) UnrootedInferredProject() *Project { + return s.unrootedInferredProject +} + func (s *Service) GetScriptInfo(fileName string) *ScriptInfo { return s.GetScriptInfoByPath(s.toPath(fileName)) } @@ -189,16 +212,23 @@ func (s *Service) GetScriptInfoByPath(path tspath.Path) *ScriptInfo { return nil } +func (s *Service) isOpenFile(info *ScriptInfo) bool { + _, ok := s.openFiles[info.path] + return ok +} + func (s *Service) OpenFile(fileName string, fileContent string, scriptKind core.ScriptKind, projectRootPath string) { path := s.toPath(fileName) existing := s.GetScriptInfoByPath(path) info := s.getOrCreateOpenScriptInfo(fileName, path, fileContent, scriptKind, projectRootPath) if existing == nil && info != nil && !info.isDynamic { - // !!! - // s.tryInvokeWildcardDirectories(info) + // Invoke wild card directory watcher to ensure that the file presence is reflected + for _, project := range s.configuredProjects { + project.tryInvokeWildCardDirectories(fileName, info.path) + } } result := s.assignProjectToOpenedScriptInfo(info) - s.cleanupProjectsAndScriptInfos(result.retainProjects, []tspath.Path{info.path}) + s.cleanupProjectsAndScriptInfos(info, result) s.printProjects() } @@ -238,6 +268,7 @@ func (s *Service) CloseFile(fileName string) { } } delete(s.openFiles, info.path) + delete(s.configFileForOpenFiles, info.path) if !fileExists { s.handleDeletedFile(info, false /*deferredDelete*/) } @@ -332,8 +363,7 @@ func (s *Service) onConfigFileChanged(project *Project, changeKind lsproto.FileC } if !project.deferredClose { - project.pendingReload = PendingReloadFull - project.markAsDirty() + project.SetPendingReload(PendingReloadFull) } return nil } @@ -381,7 +411,7 @@ func (s *Service) applyChangesToFile(info *ScriptInfo, changes []ls.TextChange) } func (s *Service) handleDeletedFile(info *ScriptInfo, deferredDelete bool) { - if info.isOpen { + if s.isOpenFile(info) { panic("cannot delete an open file") } @@ -398,11 +428,15 @@ func (s *Service) handleDeletedFile(info *ScriptInfo, deferredDelete bool) { } func (s *Service) deleteScriptInfo(info *ScriptInfo) { - if info.isOpen { + if s.isOpenFile(info) { panic("cannot delete an open file") } s.scriptInfosMu.Lock() defer s.scriptInfosMu.Unlock() + s.deleteScriptInfoLocked(info) +} + +func (s *Service) deleteScriptInfoLocked(info *ScriptInfo) { delete(s.scriptInfos, info.path) s.filenameToScriptInfoVersion[info.path] = info.version // !!! @@ -501,13 +535,15 @@ func (s *Service) configFileExists(configFilename string) bool { } func (s *Service) getConfigFileNameForFile(info *ScriptInfo, findFromCacheOnly bool) string { - // !!! - // const fromCache = this.getConfigFileNameForFileFromCache(info, findFromCacheOnly); - // if (fromCache !== undefined) return fromCache || undefined; - // if (findFromCacheOnly) return undefined; - // - // !!! - // good grief, this is convoluted. I'm skipping so much stuff right now + configName, ok := s.configFileForOpenFiles[info.path] + if ok { + return configName + } + + if findFromCacheOnly { + return "" + } + projectRootPath := s.openFiles[info.path] if info.isDynamic { return "" @@ -532,6 +568,10 @@ func (s *Service) getConfigFileNameForFile(info *ScriptInfo, findFromCacheOnly b return "", false }) s.logf("getConfigFileNameForFile:: File: %s ProjectRootPath: %s:: Result: %s", info.fileName, s.openFiles[info.path], fileName) + + if _, ok := s.openFiles[info.path]; ok { + s.configFileForOpenFiles[info.path] = fileName + } return fileName } @@ -568,7 +608,7 @@ func (s *Service) findCreateOrReloadConfiguredProject(configFileName string, pro if project == nil { project = s.createConfiguredProject(configFileName, configFilePath) } - s.loadConfiguredProject(project) + project.updateGraph() default: panic("unhandled projectLoadKind") } @@ -592,11 +632,11 @@ func (s *Service) tryFindDefaultConfiguredProjectAndLoadAncestorsForOpenScriptIn return result } -func (s *Service) assignProjectToOpenedScriptInfo(info *ScriptInfo) assignProjectResult { - var result assignProjectResult +func (s *Service) assignProjectToOpenedScriptInfo(info *ScriptInfo) *Project { + // !!! todo retain projects list when its multiple projects that are looked up + var result *Project if project := s.tryFindDefaultConfiguredProjectAndLoadAncestorsForOpenScriptInfo(info, projectLoadKindCreate); project != nil { - result.configFileName = project.configFileName - // result.configFileErrors = project.getAllProjectErrors() + result = project } for _, project := range info.containingProjects { project.updateGraph() @@ -613,11 +653,125 @@ func (s *Service) assignProjectToOpenedScriptInfo(info *ScriptInfo) assignProjec return result } -func (s *Service) cleanupProjectsAndScriptInfos(toRetainConfiguredProjects map[*Project]projectLoadKind, openFilesWithRetainedConfiguredProject []tspath.Path) { - // !!! +func (s *Service) cleanupProjectsAndScriptInfos(openInfo *ScriptInfo, retainedByOpenFile *Project) { + // This was postponed from closeOpenFile to after opening next file, + // so that we can reuse the project if we need to right away + // Remove all the non marked projects + s.cleanupConfiguredProjects(openInfo, retainedByOpenFile) + + // Remove orphan inferred projects now that we have reused projects + // We need to create a duplicate because we cant guarantee order after removal + for _, inferredProject := range slices.Clone(s.inferredProjects) { + if inferredProject.isOrphan() { + s.removeProject(inferredProject) + } + } + + // Delete the orphan files here because there might be orphan script infos (which are not part of project) + // when some file/s were closed which resulted in project removal. + // It was then postponed to cleanup these script infos so that they can be reused if + // the file from that old project is reopened because of opening file from here. + s.removeOrphanScriptInfos() +} + +func (s *Service) cleanupConfiguredProjects(openInfo *ScriptInfo, retainedByOpenFile *Project) { + toRemoveProjects := maps.Clone(s.configuredProjects) + // !!! handle declarationMap + retainConfiguredProject := func(project *Project) { + if _, ok := toRemoveProjects[project.configFilePath]; !ok { + return + } + delete(toRemoveProjects, project.configFilePath) + // // Keep original projects used + // markOriginalProjectsAsUsed(project); + // // Keep all the references alive + // forEachReferencedProject(project, retainConfiguredProject); + } + + if retainedByOpenFile != nil { + retainConfiguredProject(retainedByOpenFile) + } + + // Everything needs to be retained, fast path to skip all the work + if len(toRemoveProjects) == 0 { + return + } + + // Retain default configured project for open script info + for path := range s.openFiles { + if path == openInfo.path { + continue + } + info := s.GetScriptInfoByPath(path) + // We want to retain the projects for open file if they are pending updates so deferredClosed projects are ok + result := s.tryFindDefaultConfiguredProjectAndLoadAncestorsForOpenScriptInfo( + info, + projectLoadKindFind, + ) + if result != nil { + retainConfiguredProject(result) + // Everything needs to be retained, fast path to skip all the work + if len(toRemoveProjects) == 0 { + return + } + } + } + + // !!! project references + + for _, project := range toRemoveProjects { + s.removeProject(project) + } } -func (s *Service) assignOrphanScriptInfoToInferredProject(info *ScriptInfo, projectRootDirectory string) { +func (s *Service) removeProject(project *Project) { + s.Log("`remove Project:: " + project.name) + s.Log(project.print( /*writeProjectFileNames*/ true /*writeFileExplaination*/, true /*writeFileVersionAndText*/, false, &strings.Builder{})) + + switch project.kind { + case KindConfigured: + delete(s.configuredProjects, project.configFilePath) + case KindInferred: + if index := slices.Index(s.inferredProjects, project); index != -1 { + s.inferredProjects = slices.Delete(s.inferredProjects, index, index+1) + } + if project == s.unrootedInferredProject { + s.unrootedInferredProject = nil + } + } + project.Close() +} + +func (s *Service) removeOrphanScriptInfos() { + s.scriptInfosMu.Lock() + defer s.scriptInfosMu.Unlock() + + toRemoveScriptInfos := maps.Clone(s.scriptInfos) + + for _, info := range s.scriptInfos { + if info.deferredDelete { + continue + } + + // If script info is not open and orphan, remove it + if !s.isOpenFile(info) && + info.isOrphan() && + // !scriptInfoIsContainedByBackgroundProject(info) && + !info.containedByDeferredClosedProject() { + // !!! dts map related infos and code + continue + } + // Retain this script info + delete(toRemoveScriptInfos, info.path) + } + + // if there are not projects that include this script info - delete it + for _, info := range toRemoveScriptInfos { + s.deleteScriptInfoLocked(info) + } +} + +func (s *Service) assignOrphanScriptInfoToInferredProject(info *ScriptInfo, projectRootDirectory string) *Project { if !info.isOrphan() { panic("scriptInfo is not orphan") } @@ -627,8 +781,9 @@ func (s *Service) assignOrphanScriptInfoToInferredProject(info *ScriptInfo, proj project = s.getOrCreateUnrootedInferredProject() } - project.AddRoot(info) + project.AddInferredProjectRoot(info) project.updateGraph() + return project // !!! old code ensures that scriptInfo is only part of one project } @@ -756,12 +911,6 @@ func (s *Service) toPath(fileName string) tspath.Path { return tspath.ToPath(fileName, s.host.GetCurrentDirectory(), s.host.FS().UseCaseSensitiveFileNames()) } -func (s *Service) loadConfiguredProject(project *Project) { - if err := project.LoadConfig(); err != nil { - panic(fmt.Errorf("failed to load project %q: %w", project.configFileName, err)) - } -} - func (s *Service) printProjects() { if !s.options.Logger.HasLevel(LogLevelNormal) { return @@ -770,17 +919,20 @@ func (s *Service) printProjects() { var builder strings.Builder for _, project := range s.configuredProjects { project.print(false /*writeFileNames*/, false /*writeFileExpanation*/, false /*writeFileVersionAndText*/, &builder) + builder.WriteRune('\n') } for _, project := range s.inferredProjects { project.print(false /*writeFileNames*/, false /*writeFileExpanation*/, false /*writeFileVersionAndText*/, &builder) + builder.WriteRune('\n') } - builder.WriteString("Open files: ") + builder.WriteString("Open files:") for path, projectRootPath := range s.openFiles { info := s.GetScriptInfoByPath(path) - builder.WriteString(fmt.Sprintf("\tFileName: %s ProjectRootPath: %s", info.fileName, projectRootPath)) - builder.WriteString("\t\tProjects: " + strings.Join(core.Map(info.containingProjects, func(project *Project) string { return project.name }), ", ")) + builder.WriteString(fmt.Sprintf("\n\tFileName: %s ProjectRootPath: %s", info.fileName, projectRootPath)) + builder.WriteString("\n\t\tProjects: " + strings.Join(core.Map(info.containingProjects, func(project *Project) string { return project.name }), ", ")) } + builder.WriteString("\n" + hr) s.Log(builder.String()) } diff --git a/internal/project/watch.go b/internal/project/watch.go index ffab39cdda..d55f874d7a 100644 --- a/internal/project/watch.go +++ b/internal/project/watch.go @@ -44,33 +44,27 @@ func newWatchedFiles[T any]( } func (w *watchedFiles[T]) update(ctx context.Context, newData T) { - if updated, err := w.updateWorker(ctx, newData); err != nil { - w.p.Log(fmt.Sprintf("Failed to update %s watch: %v\n%s", w.watchType, err, formatFileList(w.globs, "\t", hr))) - } else if updated { - w.p.Logf("%s watches updated %s:\n%s", w.watchType, w.watcherID, formatFileList(w.globs, "\t", hr)) - } -} - -func (w *watchedFiles[T]) updateWorker(ctx context.Context, newData T) (updated bool, err error) { newGlobs := w.getGlobs(newData) newGlobs = slices.Clone(newGlobs) slices.Sort(newGlobs) w.data = newData if slices.Equal(w.globs, newGlobs) { - return false, nil + return } w.globs = newGlobs if w.watcherID != "" { - if err = w.p.host.Client().UnwatchFiles(ctx, w.watcherID); err != nil { - return false, err + if err := w.p.host.Client().UnwatchFiles(ctx, w.watcherID); err != nil { + w.p.Log(fmt.Sprintf("%s:: Failed to unwatch %s watch: %s, err: %v newGlobs that are not updated: \n%s", w.p.name, w.watchType, w.watcherID, err, formatFileList(w.globs, "\t", hr))) + return } + w.p.Logf("%s:: %s watches unwatch %s", w.p.name, w.watchType, w.watcherID) } w.watcherID = "" if len(newGlobs) == 0 { - return true, nil + return } watchers := make([]*lsproto.FileSystemWatcher, 0, len(newGlobs)) @@ -84,10 +78,12 @@ func (w *watchedFiles[T]) updateWorker(ctx context.Context, newData T) (updated } watcherID, err := w.p.host.Client().WatchFiles(ctx, watchers) if err != nil { - return false, err + w.p.Log(fmt.Sprintf("%s:: Failed to update %s watch: %v\n%s", w.p.name, w.watchType, err, formatFileList(w.globs, "\t", hr))) + return } w.watcherID = watcherID - return true, nil + w.p.Logf("%s:: %s watches updated %s:\n%s", w.p.name, w.watchType, w.watcherID, formatFileList(w.globs, "\t", hr)) + return } func globMapperForTypingsInstaller(data map[tspath.Path]string) []string { diff --git a/internal/testutil/projecttestutil/projecttestutil.go b/internal/testutil/projecttestutil/projecttestutil.go index 6c5f8093ca..f28ad7969c 100644 --- a/internal/testutil/projecttestutil/projecttestutil.go +++ b/internal/testutil/projecttestutil/projecttestutil.go @@ -7,9 +7,11 @@ import ( "slices" "strings" "sync" + "sync/atomic" "github.com/microsoft/typescript-go/internal/bundled" "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/project" "github.com/microsoft/typescript-go/internal/tspath" "github.com/microsoft/typescript-go/internal/vfs" @@ -220,6 +222,10 @@ func newProjectServiceHost(files map[string]any) *ProjectServiceHost { defaultLibraryPath: bundled.LibPath(), ClientMock: &ClientMock{}, } + var watchCount atomic.Int32 + host.ClientMock.WatchFilesFunc = func(_ context.Context, _ []*lsproto.FileSystemWatcher) (project.WatcherHandle, error) { + return project.WatcherHandle(fmt.Sprintf("#%d", watchCount.Add(1))), nil + } host.logger = project.NewLogger([]io.Writer{&host.output}, "", project.LogLevelVerbose) return host } From 3db29346dec44bfcd5422080e162f37fd484a632 Mon Sep 17 00:00:00 2001 From: Sheetal Nandi Date: Tue, 3 Jun 2025 12:52:59 -0700 Subject: [PATCH 02/11] Handle concurrency --- internal/project/project.go | 30 +++-- internal/project/projectlifetime_test.go | 1 - internal/project/scriptinfo.go | 41 +++++- internal/project/service.go | 152 ++++++++++++++--------- 4 files changed, 146 insertions(+), 78 deletions(-) diff --git a/internal/project/project.go b/internal/project/project.go index 1ac4aedf4f..a5c45f2c75 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -511,6 +511,7 @@ func (p *Project) updateGraph() bool { for _, oldSourceFile := range oldProgram.GetSourceFiles() { if p.program.GetSourceFileByPath(oldSourceFile.Path()) == nil { p.host.DocumentRegistry().ReleaseDocument(oldSourceFile, oldProgram.GetCompilerOptions()) + p.detachScriptInfoIfNotInferredRoot(oldSourceFile.Path()) } } } @@ -825,14 +826,9 @@ func (p *Project) isRoot(info *ScriptInfo) bool { return p.rootFileNames.Has(info.path) } -func (p *Project) RemoveFile(info *ScriptInfo, fileExists bool, detachFromProject bool) { +func (p *Project) RemoveFile(info *ScriptInfo, fileExists bool) { p.mu.Lock() defer p.mu.Unlock() - p.removeFile(info, fileExists, detachFromProject) - p.markAsDirtyLocked() -} - -func (p *Project) removeFile(info *ScriptInfo, fileExists bool, detachFromProject bool) { if p.isRoot(info) && p.kind == KindInferred { p.rootFileNames.Delete(info.path) p.typeAcquisition = nil @@ -848,9 +844,7 @@ func (p *Project) removeFile(info *ScriptInfo, fileExists bool, detachFromProjec // this.resolutionCache.invalidateResolutionOfFile(info.path); // } // this.cachedUnresolvedImportsPerFile.delete(info.path); - if detachFromProject { - info.detachFromProject(p) - } + p.markAsDirtyLocked() } func (p *Project) AddInferredProjectRoot(info *ScriptInfo) { @@ -1029,6 +1023,15 @@ func (p *Project) Logf(format string, args ...interface{}) { p.Log(fmt.Sprintf(format, args...)) } +func (p *Project) detachScriptInfoIfNotInferredRoot(path tspath.Path) { + // We might not find the script info in case its not associated with the project any more + // and project graph was not updated (eg delayed update graph in case of files changed/deleted on the disk) + if scriptInfo := p.host.GetScriptInfoByPath(path); scriptInfo != nil && + (p.kind != KindInferred || !p.isRoot(scriptInfo)) { + scriptInfo.detachFromProject(p) + } +} + func (p *Project) Close() { p.mu.Lock() defer p.mu.Unlock() @@ -1036,12 +1039,13 @@ func (p *Project) Close() { if p.program != nil { for _, sourceFile := range p.program.GetSourceFiles() { p.host.DocumentRegistry().ReleaseDocument(sourceFile, p.program.GetCompilerOptions()) - if scriptInfo := p.host.GetScriptInfoByPath(sourceFile.Path()); scriptInfo != nil { - scriptInfo.detachFromProject(p) - } + // Detach script info if its not root or is root of non inferred project + p.detachScriptInfoIfNotInferredRoot(sourceFile.Path()) } p.program = nil - } else if p.kind == KindInferred { + } + + if p.kind == KindInferred { // Release root script infos for inferred projects. for path := range p.rootFileNames.Keys() { if info := p.host.GetScriptInfoByPath(path); info != nil { diff --git a/internal/project/projectlifetime_test.go b/internal/project/projectlifetime_test.go index b637a72b66..60994c9170 100644 --- a/internal/project/projectlifetime_test.go +++ b/internal/project/projectlifetime_test.go @@ -162,7 +162,6 @@ func TestProjectLifetime(t *testing.T) { service.OpenFile("/home/projects/TS/p2/src/index.ts", files["/home/projects/TS/p2/src/index.ts"].(string), core.ScriptKindTS, "/home/projects/TS/p2") assert.Equal(t, len(service.Projects()), 1) assert.Assert(t, service.InferredProject(tspath.Path("")) == nil) - assert.Assert(t, service.UnrootedInferredProject() == nil) assert.Assert(t, service.GetScriptInfoByPath(tspath.ToPath("/home/projects/TS/p1/src/index.ts", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) assert.Assert(t, service.InferredProject(tspath.ToPath("/home/projects/TS/p2", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) != nil) }) diff --git a/internal/project/scriptinfo.go b/internal/project/scriptinfo.go index 8fc59a5ef3..d32323a5bf 100644 --- a/internal/project/scriptinfo.go +++ b/internal/project/scriptinfo.go @@ -2,6 +2,7 @@ package project import ( "slices" + "sync" "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/ls" @@ -25,7 +26,8 @@ type ScriptInfo struct { matchesDiskText bool deferredDelete bool - containingProjects []*Project + containingProjectsMu sync.RWMutex + containingProjects []*Project fs vfs.FS } @@ -68,6 +70,12 @@ func (s *ScriptInfo) Version() int { return s.version } +func (s *ScriptInfo) ContainingProjects() []*Project { + s.containingProjectsMu.RLock() + defer s.containingProjectsMu.RUnlock() + return slices.Clone(s.containingProjects) +} + func (s *ScriptInfo) reloadIfNeeded() { if s.pendingReloadFromDisk { if newText, ok := s.fs.ReadFile(s.fileName); ok { @@ -97,6 +105,15 @@ func (s *ScriptInfo) close(fileExists bool) { s.pendingReloadFromDisk = true s.markContainingProjectsAsDirty() } + + s.containingProjectsMu.Lock() + defer s.containingProjectsMu.Unlock() + for _, project := range slices.Clone(s.containingProjects) { + if project.kind == KindInferred && project.isRoot(s) { + project.RemoveFile(s, fileExists) + s.detachFromProjectLocked(project) + } + } } func (s *ScriptInfo) setText(newText string) { @@ -106,6 +123,8 @@ func (s *ScriptInfo) setText(newText string) { } func (s *ScriptInfo) markContainingProjectsAsDirty() { + s.containingProjectsMu.RLock() + defer s.containingProjectsMu.RUnlock() for _, project := range s.containingProjects { project.MarkFileAsDirty(s.path) } @@ -115,7 +134,9 @@ func (s *ScriptInfo) markContainingProjectsAsDirty() { // and returns true if the script info was newly attached. func (s *ScriptInfo) attachToProject(project *Project) bool { if !s.isAttached(project) { + s.containingProjectsMu.Lock() s.containingProjects = append(s.containingProjects, project) + s.containingProjectsMu.Unlock() if project.compilerOptions.PreserveSymlinks != core.TSTrue { s.ensureRealpath(project.FS()) } @@ -126,6 +147,8 @@ func (s *ScriptInfo) attachToProject(project *Project) bool { } func (s *ScriptInfo) isAttached(project *Project) bool { + s.containingProjectsMu.RLock() + defer s.containingProjectsMu.RUnlock() return slices.Contains(s.containingProjects, project) } @@ -133,6 +156,8 @@ func (s *ScriptInfo) isOrphan() bool { if s.deferredDelete { return true } + s.containingProjectsMu.RLock() + defer s.containingProjectsMu.RUnlock() for _, project := range s.containingProjects { if !project.isOrphan() { return false @@ -148,6 +173,8 @@ func (s *ScriptInfo) editContent(change ls.TextChange) { func (s *ScriptInfo) ensureRealpath(fs vfs.FS) { if s.realpath == "" { + s.containingProjectsMu.RLock() + defer s.containingProjectsMu.RUnlock() if len(s.containingProjects) == 0 { panic("scriptInfo must be attached to a project before calling ensureRealpath") } @@ -168,17 +195,25 @@ func (s *ScriptInfo) getRealpathIfDifferent() (tspath.Path, bool) { } func (s *ScriptInfo) detachAllProjects() { + s.containingProjectsMu.Lock() + defer s.containingProjectsMu.Unlock() for _, project := range s.containingProjects { // !!! // if (isConfiguredProject(p)) { // p.getCachedDirectoryStructureHost().addOrDeleteFile(this.fileName, this.path, FileWatcherEventKind.Deleted); // } - project.RemoveFile(s, false /*fileExists*/, false /*detachFromProject*/) + project.RemoveFile(s, false /*fileExists*/) } s.containingProjects = nil } func (s *ScriptInfo) detachFromProject(project *Project) { + s.containingProjectsMu.Lock() + defer s.containingProjectsMu.Unlock() + s.detachFromProjectLocked(project) +} + +func (s *ScriptInfo) detachFromProjectLocked(project *Project) { if index := slices.Index(s.containingProjects, project); index != -1 { s.containingProjects = slices.Delete(s.containingProjects, index, index+1) } @@ -193,6 +228,8 @@ func (s *ScriptInfo) delayReloadNonMixedContentFile() { } func (s *ScriptInfo) containedByDeferredClosedProject() bool { + s.containingProjectsMu.RLock() + defer s.containingProjectsMu.RUnlock() return slices.IndexFunc(s.containingProjects, func(project *Project) bool { return project.deferredClose }) != -1 diff --git a/internal/project/service.go b/internal/project/service.go index b0bcbc79eb..2ae8fd0d3a 100644 --- a/internal/project/service.go +++ b/internal/project/service.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "maps" - "slices" "strings" "sync" @@ -45,12 +44,9 @@ type Service struct { // We check isOrphan on scriptInfo and that can change any time project structure is updated projectsMu sync.RWMutex configuredProjects map[tspath.Path]*Project - // unrootedInferredProject is the inferred project for files opened without a projectRootDirectory - // (e.g. dynamic files) - unrootedInferredProject *Project // inferredProjects is the list of all inferred projects, including the unrootedInferredProject // if it exists - inferredProjects []*Project + inferredProjects map[tspath.Path]*Project documentRegistry *DocumentRegistry scriptInfosMu sync.RWMutex @@ -80,6 +76,7 @@ func NewService(host ServiceHost, options ServiceOptions) *Service { }, configuredProjects: make(map[tspath.Path]*Project), + inferredProjects: make(map[tspath.Path]*Project), documentRegistry: &DocumentRegistry{ Options: tspath.ComparePathsOptions{ @@ -171,15 +168,21 @@ func (s *Service) IsWatchEnabled() bool { } func (s *Service) Projects() []*Project { + s.projectsMu.RLock() + defer s.projectsMu.RUnlock() projects := make([]*Project, 0, len(s.configuredProjects)+len(s.inferredProjects)) for _, project := range s.configuredProjects { projects = append(projects, project) } - projects = append(projects, s.inferredProjects...) + for _, project := range s.inferredProjects { + projects = append(projects, project) + } return projects } func (s *Service) ConfiguredProject(path tspath.Path) *Project { + s.projectsMu.RLock() + defer s.projectsMu.RUnlock() if project, ok := s.configuredProjects[path]; ok { return project } @@ -187,18 +190,14 @@ func (s *Service) ConfiguredProject(path tspath.Path) *Project { } func (s *Service) InferredProject(rootPath tspath.Path) *Project { - for _, project := range s.inferredProjects { - if project.rootPath == rootPath { - return project - } + s.projectsMu.RLock() + defer s.projectsMu.RUnlock() + if project, ok := s.inferredProjects[rootPath]; ok { + return project } return nil } -func (s *Service) UnrootedInferredProject() *Project { - return s.unrootedInferredProject -} - func (s *Service) GetScriptInfo(fileName string) *ScriptInfo { return s.GetScriptInfoByPath(s.toPath(fileName)) } @@ -223,9 +222,11 @@ func (s *Service) OpenFile(fileName string, fileContent string, scriptKind core. info := s.getOrCreateOpenScriptInfo(fileName, path, fileContent, scriptKind, projectRootPath) if existing == nil && info != nil && !info.isDynamic { // Invoke wild card directory watcher to ensure that the file presence is reflected + s.projectsMu.RLock() for _, project := range s.configuredProjects { project.tryInvokeWildCardDirectories(fileName, info.path) } + s.projectsMu.RUnlock() } result := s.assignProjectToOpenedScriptInfo(info) s.cleanupProjectsAndScriptInfos(info, result) @@ -262,11 +263,6 @@ func (s *Service) CloseFile(fileName string) { if info := s.GetScriptInfoByPath(s.toPath(fileName)); info != nil { fileExists := !info.isDynamic && s.host.FS().FileExists(info.fileName) info.close(fileExists) - for _, project := range info.containingProjects { - if project.kind == KindInferred && project.isRoot(info) { - project.RemoveFile(info, fileExists, true /*detachFromProject*/) - } - } delete(s.openFiles, info.path) delete(s.configFileForOpenFiles, info.path) if !fileExists { @@ -312,6 +308,8 @@ func (s *Service) SourceFileCount() int { } func (s *Service) OnWatchedFilesChanged(ctx context.Context, changes []*lsproto.FileEvent) error { + s.projectsMu.RLock() + defer s.projectsMu.RUnlock() for _, change := range changes { fileName := ls.DocumentURIToFileName(change.Uri) path := s.toPath(fileName) @@ -370,12 +368,14 @@ func (s *Service) onConfigFileChanged(project *Project, changeKind lsproto.FileC func (s *Service) ensureProjectStructureUpToDate() { var hasChanges bool + s.projectsMu.RLock() for _, project := range s.configuredProjects { hasChanges = project.updateGraph() || hasChanges } for _, project := range s.inferredProjects { hasChanges = project.updateGraph() || hasChanges } + s.projectsMu.RUnlock() if hasChanges { s.ensureProjectForOpenFiles() } @@ -396,9 +396,11 @@ func (s *Service) ensureProjectForOpenFiles() { // !!! s.removeRootOfInferredProjectIfNowPartOfOtherProject(info) } } + s.projectsMu.RLock() for _, project := range s.inferredProjects { project.updateGraph() } + s.projectsMu.RUnlock() s.Log("After ensureProjectForOpenFiles:") s.printProjects() @@ -417,6 +419,7 @@ func (s *Service) handleDeletedFile(info *ScriptInfo, deferredDelete bool) { // !!! // s.handleSourceMapProjects(info) + containingProjects := info.ContainingProjects() info.detachAllProjects() if deferredDelete { info.delayReloadNonMixedContentFile() @@ -424,7 +427,7 @@ func (s *Service) handleDeletedFile(info *ScriptInfo, deferredDelete bool) { } else { s.deleteScriptInfo(info) } - s.updateProjectGraphs(info.containingProjects, false /*clearSourceMapperCache*/) + s.updateProjectGraphs(containingProjects, false /*clearSourceMapperCache*/) } func (s *Service) deleteScriptInfo(info *ScriptInfo) { @@ -580,6 +583,8 @@ func (s *Service) findDefaultConfiguredProject(scriptInfo *ScriptInfo) *Project } func (s *Service) findConfiguredProjectByName(configFilePath tspath.Path, includeDeferredClosedProjects bool) *Project { + s.projectsMu.RLock() + defer s.projectsMu.RUnlock() if result, ok := s.configuredProjects[configFilePath]; ok { if includeDeferredClosedProjects || !result.deferredClose { return result @@ -589,6 +594,12 @@ func (s *Service) findConfiguredProjectByName(configFilePath tspath.Path, includ } func (s *Service) createConfiguredProject(configFileName string, configFilePath tspath.Path) *Project { + s.projectsMu.Lock() + defer s.projectsMu.Unlock() + if existingProject, ok := s.configuredProjects[configFilePath]; ok { + return existingProject + } + // !!! config file existence cache stuff omitted project := NewConfiguredProject(configFileName, configFilePath, s) s.configuredProjects[configFilePath] = project @@ -638,7 +649,7 @@ func (s *Service) assignProjectToOpenedScriptInfo(info *ScriptInfo) *Project { if project := s.tryFindDefaultConfiguredProjectAndLoadAncestorsForOpenScriptInfo(info, projectLoadKindCreate); project != nil { result = project } - for _, project := range info.containingProjects { + for _, project := range info.ContainingProjects() { project.updateGraph() } if info.isOrphan() { @@ -661,7 +672,10 @@ func (s *Service) cleanupProjectsAndScriptInfos(openInfo *ScriptInfo, retainedBy // Remove orphan inferred projects now that we have reused projects // We need to create a duplicate because we cant guarantee order after removal - for _, inferredProject := range slices.Clone(s.inferredProjects) { + s.projectsMu.RLock() + inferredProjects := maps.Clone(s.inferredProjects) + s.projectsMu.RUnlock() + for _, inferredProject := range inferredProjects { if inferredProject.isOrphan() { s.removeProject(inferredProject) } @@ -675,7 +689,9 @@ func (s *Service) cleanupProjectsAndScriptInfos(openInfo *ScriptInfo, retainedBy } func (s *Service) cleanupConfiguredProjects(openInfo *ScriptInfo, retainedByOpenFile *Project) { + s.projectsMu.RLock() toRemoveProjects := maps.Clone(s.configuredProjects) + s.projectsMu.RUnlock() // !!! handle declarationMap retainConfiguredProject := func(project *Project) { if _, ok := toRemoveProjects[project.configFilePath]; !ok { @@ -728,17 +744,14 @@ func (s *Service) removeProject(project *Project) { s.Log("`remove Project:: " + project.name) s.Log(project.print( /*writeProjectFileNames*/ true /*writeFileExplaination*/, true /*writeFileVersionAndText*/, false, &strings.Builder{})) + s.projectsMu.Lock() switch project.kind { case KindConfigured: delete(s.configuredProjects, project.configFilePath) case KindInferred: - if index := slices.Index(s.inferredProjects, project); index != -1 { - s.inferredProjects = slices.Delete(s.inferredProjects, index, index+1) - } - if project == s.unrootedInferredProject { - s.unrootedInferredProject = nil - } + delete(s.inferredProjects, project.rootPath) } + s.projectsMu.Unlock() project.Close() } @@ -777,61 +790,68 @@ func (s *Service) assignOrphanScriptInfoToInferredProject(info *ScriptInfo, proj } project := s.getOrCreateInferredProjectForProjectRootPath(info, projectRootDirectory) - if project == nil { - project = s.getOrCreateUnrootedInferredProject() - } - project.AddInferredProjectRoot(info) project.updateGraph() return project // !!! old code ensures that scriptInfo is only part of one project } -func (s *Service) getOrCreateUnrootedInferredProject() *Project { - if s.unrootedInferredProject == nil { - s.unrootedInferredProject = s.createInferredProject(s.host.GetCurrentDirectory(), "") +func (s *Service) getOrCreateInferredProjectForProjectRootPath(info *ScriptInfo, projectRootDirectory string) *Project { + project := s.getInferredProjectForProjectRootPath(info, projectRootDirectory) + if project != nil { + return project + } + if projectRootDirectory != "" { + return s.createInferredProject(projectRootDirectory, s.toPath(projectRootDirectory)) } - return s.unrootedInferredProject + return s.createInferredProject(s.host.GetCurrentDirectory(), "") } -func (s *Service) getOrCreateInferredProjectForProjectRootPath(info *ScriptInfo, projectRootDirectory string) *Project { - if info.isDynamic && projectRootDirectory == "" { +func (s *Service) getInferredProjectForProjectRootPath(info *ScriptInfo, projectRootDirectory string) *Project { + s.projectsMu.RLock() + defer s.projectsMu.RUnlock() + if projectRootDirectory != "" { + projectRootPath := s.toPath(projectRootDirectory) + if project, ok := s.inferredProjects[projectRootPath]; ok { + return project + } return nil } - if projectRootDirectory != "" { - projectRootPath := s.toPath(projectRootDirectory) + if !info.isDynamic { + var bestMatch *Project for _, project := range s.inferredProjects { - if project.rootPath == projectRootPath { - return project + if project.rootPath == "" { + continue + } + if !tspath.ContainsPath(string(project.rootPath), string(info.path), s.comparePathsOptions) { + continue } + if bestMatch != nil && len(bestMatch.rootPath) > len(project.rootPath) { + continue + } + bestMatch = project } - return s.createInferredProject(projectRootDirectory, projectRootPath) - } - var bestMatch *Project - for _, project := range s.inferredProjects { - if project.rootPath == "" { - continue - } - if !tspath.ContainsPath(string(project.rootPath), string(info.path), s.comparePathsOptions) { - continue - } - if bestMatch != nil && len(bestMatch.rootPath) > len(project.rootPath) { - continue + if bestMatch != nil { + return bestMatch } - bestMatch = project } - return bestMatch + // unrooted inferred project if no best match found + if unrootedProject, ok := s.inferredProjects[""]; ok { + return unrootedProject + } + return nil } func (s *Service) getDefaultProjectForScript(scriptInfo *ScriptInfo) *Project { - switch len(scriptInfo.containingProjects) { + containingProjects := scriptInfo.ContainingProjects() + switch len(containingProjects) { case 0: panic("scriptInfo must be attached to a project before calling getDefaultProject") case 1: - project := scriptInfo.containingProjects[0] + project := containingProjects[0] if project.deferredClose || project.kind == KindAutoImportProvider || project.kind == KindAuxiliary { panic("scriptInfo must be attached to a non-background project before calling getDefaultProject") } @@ -848,13 +868,13 @@ func (s *Service) getDefaultProjectForScript(scriptInfo *ScriptInfo) *Project { var firstNonSourceOfProjectReferenceRedirect *Project var defaultConfiguredProject *Project - for index, project := range scriptInfo.containingProjects { + for index, project := range containingProjects { if project.kind == KindConfigured { if project.deferredClose { continue } // !!! if !project.isSourceOfProjectReferenceRedirect(scriptInfo.fileName) { - if defaultConfiguredProject == nil && index != len(scriptInfo.containingProjects)-1 { + if defaultConfiguredProject == nil && index != len(containingProjects)-1 { defaultConfiguredProject = s.findDefaultConfiguredProject(scriptInfo) } if defaultConfiguredProject == project { @@ -888,6 +908,12 @@ func (s *Service) getDefaultProjectForScript(scriptInfo *ScriptInfo) *Project { } func (s *Service) createInferredProject(currentDirectory string, projectRootPath tspath.Path) *Project { + s.projectsMu.Lock() + defer s.projectsMu.Unlock() + if existingProject, ok := s.inferredProjects[projectRootPath]; ok { + return existingProject + } + compilerOptions := core.CompilerOptions{ AllowJs: core.TSTrue, Module: core.ModuleKindESNext, @@ -903,7 +929,7 @@ func (s *Service) createInferredProject(currentDirectory string, projectRootPath ResolveJsonModule: core.TSTrue, } project := NewInferredProject(&compilerOptions, currentDirectory, projectRootPath, s) - s.inferredProjects = append(s.inferredProjects, project) + s.inferredProjects[project.rootPath] = project return project } @@ -917,6 +943,7 @@ func (s *Service) printProjects() { } var builder strings.Builder + s.projectsMu.RLock() for _, project := range s.configuredProjects { project.print(false /*writeFileNames*/, false /*writeFileExpanation*/, false /*writeFileVersionAndText*/, &builder) builder.WriteRune('\n') @@ -925,12 +952,13 @@ func (s *Service) printProjects() { project.print(false /*writeFileNames*/, false /*writeFileExpanation*/, false /*writeFileVersionAndText*/, &builder) builder.WriteRune('\n') } + s.projectsMu.RUnlock() builder.WriteString("Open files:") for path, projectRootPath := range s.openFiles { info := s.GetScriptInfoByPath(path) builder.WriteString(fmt.Sprintf("\n\tFileName: %s ProjectRootPath: %s", info.fileName, projectRootPath)) - builder.WriteString("\n\t\tProjects: " + strings.Join(core.Map(info.containingProjects, func(project *Project) string { return project.name }), ", ")) + builder.WriteString("\n\t\tProjects: " + strings.Join(core.Map(info.ContainingProjects(), func(project *Project) string { return project.name }), ", ")) } builder.WriteString("\n" + hr) s.Log(builder.String()) From 69a2853207a06e1cdb4889b58a3d48c4148a57a3 Mon Sep 17 00:00:00 2001 From: Sheetal Nandi Date: Tue, 3 Jun 2025 15:43:41 -0700 Subject: [PATCH 03/11] Use sync maps --- internal/project/service.go | 144 +++++++++++++----------------------- 1 file changed, 53 insertions(+), 91 deletions(-) diff --git a/internal/project/service.go b/internal/project/service.go index 2ae8fd0d3a..e4d21cbd27 100644 --- a/internal/project/service.go +++ b/internal/project/service.go @@ -8,6 +8,7 @@ import ( "strings" "sync" + "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/ls" "github.com/microsoft/typescript-go/internal/lsp/lsproto" @@ -39,14 +40,10 @@ type Service struct { comparePathsOptions tspath.ComparePathsOptions converters *ls.Converters - // !!! sheetal we probably need concurrency handling for project structure and scriptInfo.containingProjects to ensure its not updated across threads - // eg EnsureProjectForOpenFiles - can be called on any thread and may create inferred project through go routine - // We check isOrphan on scriptInfo and that can change any time project structure is updated - projectsMu sync.RWMutex - configuredProjects map[tspath.Path]*Project + configuredProjects collections.SyncMap[tspath.Path, *Project] // inferredProjects is the list of all inferred projects, including the unrootedInferredProject // if it exists - inferredProjects map[tspath.Path]*Project + inferredProjects collections.SyncMap[tspath.Path, *Project] documentRegistry *DocumentRegistry scriptInfosMu sync.RWMutex @@ -75,9 +72,6 @@ func NewService(host ServiceHost, options ServiceOptions) *Service { CurrentDirectory: host.GetCurrentDirectory(), }, - configuredProjects: make(map[tspath.Path]*Project), - inferredProjects: make(map[tspath.Path]*Project), - documentRegistry: &DocumentRegistry{ Options: tspath.ComparePathsOptions{ UseCaseSensitiveFileNames: host.FS().UseCaseSensitiveFileNames(), @@ -168,31 +162,27 @@ func (s *Service) IsWatchEnabled() bool { } func (s *Service) Projects() []*Project { - s.projectsMu.RLock() - defer s.projectsMu.RUnlock() - projects := make([]*Project, 0, len(s.configuredProjects)+len(s.inferredProjects)) - for _, project := range s.configuredProjects { + projects := make([]*Project, 0, s.configuredProjects.Size()+s.inferredProjects.Size()) + s.configuredProjects.Range(func(key tspath.Path, project *Project) bool { projects = append(projects, project) - } - for _, project := range s.inferredProjects { + return true + }) + s.inferredProjects.Range(func(key tspath.Path, project *Project) bool { projects = append(projects, project) - } + return true + }) return projects } func (s *Service) ConfiguredProject(path tspath.Path) *Project { - s.projectsMu.RLock() - defer s.projectsMu.RUnlock() - if project, ok := s.configuredProjects[path]; ok { + if project, ok := s.configuredProjects.Load(path); ok { return project } return nil } func (s *Service) InferredProject(rootPath tspath.Path) *Project { - s.projectsMu.RLock() - defer s.projectsMu.RUnlock() - if project, ok := s.inferredProjects[rootPath]; ok { + if project, ok := s.inferredProjects.Load(rootPath); ok { return project } return nil @@ -222,11 +212,10 @@ func (s *Service) OpenFile(fileName string, fileContent string, scriptKind core. info := s.getOrCreateOpenScriptInfo(fileName, path, fileContent, scriptKind, projectRootPath) if existing == nil && info != nil && !info.isDynamic { // Invoke wild card directory watcher to ensure that the file presence is reflected - s.projectsMu.RLock() - for _, project := range s.configuredProjects { + s.configuredProjects.Range(func(key tspath.Path, project *Project) bool { project.tryInvokeWildCardDirectories(fileName, info.path) - } - s.projectsMu.RUnlock() + return true + }) } result := s.assignProjectToOpenedScriptInfo(info) s.cleanupProjectsAndScriptInfos(info, result) @@ -308,12 +297,10 @@ func (s *Service) SourceFileCount() int { } func (s *Service) OnWatchedFilesChanged(ctx context.Context, changes []*lsproto.FileEvent) error { - s.projectsMu.RLock() - defer s.projectsMu.RUnlock() for _, change := range changes { fileName := ls.DocumentURIToFileName(change.Uri) path := s.toPath(fileName) - if project, ok := s.configuredProjects[path]; ok { + if project, ok := s.configuredProjects.Load(path); ok { // tsconfig of project if err := s.onConfigFileChanged(project, change.Type); err != nil { return fmt.Errorf("error handling config file change: %w", err) @@ -332,12 +319,14 @@ func (s *Service) OnWatchedFilesChanged(ctx context.Context, changes []*lsproto. // !!! s.handleSourceMapProjects(info) } } else { - for _, project := range s.configuredProjects { + s.configuredProjects.Range(func(key tspath.Path, project *Project) bool { project.onWatchEventForNilScriptInfo(fileName) - } - for _, project := range s.inferredProjects { + return true + }) + s.inferredProjects.Range(func(key tspath.Path, project *Project) bool { project.onWatchEventForNilScriptInfo(fileName) - } + return true + }) } } @@ -368,14 +357,14 @@ func (s *Service) onConfigFileChanged(project *Project, changeKind lsproto.FileC func (s *Service) ensureProjectStructureUpToDate() { var hasChanges bool - s.projectsMu.RLock() - for _, project := range s.configuredProjects { + s.configuredProjects.Range(func(key tspath.Path, project *Project) bool { hasChanges = project.updateGraph() || hasChanges - } - for _, project := range s.inferredProjects { + return true + }) + s.inferredProjects.Range(func(key tspath.Path, project *Project) bool { hasChanges = project.updateGraph() || hasChanges - } - s.projectsMu.RUnlock() + return true + }) if hasChanges { s.ensureProjectForOpenFiles() } @@ -396,11 +385,10 @@ func (s *Service) ensureProjectForOpenFiles() { // !!! s.removeRootOfInferredProjectIfNowPartOfOtherProject(info) } } - s.projectsMu.RLock() - for _, project := range s.inferredProjects { + s.inferredProjects.Range(func(key tspath.Path, project *Project) bool { project.updateGraph() - } - s.projectsMu.RUnlock() + return true + }) s.Log("After ensureProjectForOpenFiles:") s.printProjects() @@ -583,9 +571,7 @@ func (s *Service) findDefaultConfiguredProject(scriptInfo *ScriptInfo) *Project } func (s *Service) findConfiguredProjectByName(configFilePath tspath.Path, includeDeferredClosedProjects bool) *Project { - s.projectsMu.RLock() - defer s.projectsMu.RUnlock() - if result, ok := s.configuredProjects[configFilePath]; ok { + if result, ok := s.configuredProjects.Load(configFilePath); ok { if includeDeferredClosedProjects || !result.deferredClose { return result } @@ -594,15 +580,9 @@ func (s *Service) findConfiguredProjectByName(configFilePath tspath.Path, includ } func (s *Service) createConfiguredProject(configFileName string, configFilePath tspath.Path) *Project { - s.projectsMu.Lock() - defer s.projectsMu.Unlock() - if existingProject, ok := s.configuredProjects[configFilePath]; ok { - return existingProject - } - // !!! config file existence cache stuff omitted project := NewConfiguredProject(configFileName, configFilePath, s) - s.configuredProjects[configFilePath] = project + project, _ = s.configuredProjects.LoadOrStore(configFilePath, project) // !!! // s.createConfigFileWatcherForParsedConfig(configFileName, configFilePath, project) return project @@ -672,9 +652,7 @@ func (s *Service) cleanupProjectsAndScriptInfos(openInfo *ScriptInfo, retainedBy // Remove orphan inferred projects now that we have reused projects // We need to create a duplicate because we cant guarantee order after removal - s.projectsMu.RLock() - inferredProjects := maps.Clone(s.inferredProjects) - s.projectsMu.RUnlock() + inferredProjects := s.inferredProjects.ToMap() for _, inferredProject := range inferredProjects { if inferredProject.isOrphan() { s.removeProject(inferredProject) @@ -689,9 +667,7 @@ func (s *Service) cleanupProjectsAndScriptInfos(openInfo *ScriptInfo, retainedBy } func (s *Service) cleanupConfiguredProjects(openInfo *ScriptInfo, retainedByOpenFile *Project) { - s.projectsMu.RLock() - toRemoveProjects := maps.Clone(s.configuredProjects) - s.projectsMu.RUnlock() + toRemoveProjects := s.configuredProjects.ToMap() // !!! handle declarationMap retainConfiguredProject := func(project *Project) { if _, ok := toRemoveProjects[project.configFilePath]; !ok { @@ -744,14 +720,12 @@ func (s *Service) removeProject(project *Project) { s.Log("`remove Project:: " + project.name) s.Log(project.print( /*writeProjectFileNames*/ true /*writeFileExplaination*/, true /*writeFileVersionAndText*/, false, &strings.Builder{})) - s.projectsMu.Lock() switch project.kind { case KindConfigured: - delete(s.configuredProjects, project.configFilePath) + s.configuredProjects.Delete(project.configFilePath) case KindInferred: - delete(s.inferredProjects, project.rootPath) + s.inferredProjects.Delete(project.rootPath) } - s.projectsMu.Unlock() project.Close() } @@ -808,11 +782,9 @@ func (s *Service) getOrCreateInferredProjectForProjectRootPath(info *ScriptInfo, } func (s *Service) getInferredProjectForProjectRootPath(info *ScriptInfo, projectRootDirectory string) *Project { - s.projectsMu.RLock() - defer s.projectsMu.RUnlock() if projectRootDirectory != "" { projectRootPath := s.toPath(projectRootDirectory) - if project, ok := s.inferredProjects[projectRootPath]; ok { + if project, ok := s.inferredProjects.Load(projectRootPath); ok { return project } return nil @@ -820,18 +792,14 @@ func (s *Service) getInferredProjectForProjectRootPath(info *ScriptInfo, project if !info.isDynamic { var bestMatch *Project - for _, project := range s.inferredProjects { - if project.rootPath == "" { - continue + s.inferredProjects.Range(func(key tspath.Path, project *Project) bool { + if project.rootPath != "" && + tspath.ContainsPath(string(project.rootPath), string(info.path), s.comparePathsOptions) && + (bestMatch == nil || len(bestMatch.rootPath) <= len(project.rootPath)) { + bestMatch = project } - if !tspath.ContainsPath(string(project.rootPath), string(info.path), s.comparePathsOptions) { - continue - } - if bestMatch != nil && len(bestMatch.rootPath) > len(project.rootPath) { - continue - } - bestMatch = project - } + return true + }) if bestMatch != nil { return bestMatch @@ -839,7 +807,7 @@ func (s *Service) getInferredProjectForProjectRootPath(info *ScriptInfo, project } // unrooted inferred project if no best match found - if unrootedProject, ok := s.inferredProjects[""]; ok { + if unrootedProject, ok := s.inferredProjects.Load(""); ok { return unrootedProject } return nil @@ -908,12 +876,6 @@ func (s *Service) getDefaultProjectForScript(scriptInfo *ScriptInfo) *Project { } func (s *Service) createInferredProject(currentDirectory string, projectRootPath tspath.Path) *Project { - s.projectsMu.Lock() - defer s.projectsMu.Unlock() - if existingProject, ok := s.inferredProjects[projectRootPath]; ok { - return existingProject - } - compilerOptions := core.CompilerOptions{ AllowJs: core.TSTrue, Module: core.ModuleKindESNext, @@ -929,7 +891,7 @@ func (s *Service) createInferredProject(currentDirectory string, projectRootPath ResolveJsonModule: core.TSTrue, } project := NewInferredProject(&compilerOptions, currentDirectory, projectRootPath, s) - s.inferredProjects[project.rootPath] = project + project, _ = s.inferredProjects.LoadOrStore(project.rootPath, project) return project } @@ -943,16 +905,16 @@ func (s *Service) printProjects() { } var builder strings.Builder - s.projectsMu.RLock() - for _, project := range s.configuredProjects { + s.configuredProjects.Range(func(key tspath.Path, project *Project) bool { project.print(false /*writeFileNames*/, false /*writeFileExpanation*/, false /*writeFileVersionAndText*/, &builder) builder.WriteRune('\n') - } - for _, project := range s.inferredProjects { + return true + }) + s.inferredProjects.Range(func(key tspath.Path, project *Project) bool { project.print(false /*writeFileNames*/, false /*writeFileExpanation*/, false /*writeFileVersionAndText*/, &builder) builder.WriteRune('\n') - } - s.projectsMu.RUnlock() + return true + }) builder.WriteString("Open files:") for path, projectRootPath := range s.openFiles { From b6e58333e6209c2c40af5a1b31d04873cec5ff29 Mon Sep 17 00:00:00 2001 From: Sheetal Nandi Date: Tue, 3 Jun 2025 16:46:27 -0700 Subject: [PATCH 04/11] refactor scriptinfo code --- internal/project/scriptinfo.go | 36 ++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/internal/project/scriptinfo.go b/internal/project/scriptinfo.go index d32323a5bf..6748b82640 100644 --- a/internal/project/scriptinfo.go +++ b/internal/project/scriptinfo.go @@ -133,22 +133,30 @@ func (s *ScriptInfo) markContainingProjectsAsDirty() { // attachToProject attaches the script info to the project if it's not already attached // and returns true if the script info was newly attached. func (s *ScriptInfo) attachToProject(project *Project) bool { - if !s.isAttached(project) { - s.containingProjectsMu.Lock() - s.containingProjects = append(s.containingProjects, project) + if s.isAttached(project) { + return false + } + s.containingProjectsMu.Lock() + if s.isAttachedLocked(project) { s.containingProjectsMu.Unlock() - if project.compilerOptions.PreserveSymlinks != core.TSTrue { - s.ensureRealpath(project.FS()) - } - project.onFileAddedOrRemoved() - return true + return false + } + s.containingProjects = append(s.containingProjects, project) + s.containingProjectsMu.Unlock() + if project.compilerOptions.PreserveSymlinks != core.TSTrue { + s.ensureRealpath(project) } - return false + project.onFileAddedOrRemoved() + return true } func (s *ScriptInfo) isAttached(project *Project) bool { s.containingProjectsMu.RLock() defer s.containingProjectsMu.RUnlock() + return s.isAttachedLocked(project) +} + +func (s *ScriptInfo) isAttachedLocked(project *Project) bool { return slices.Contains(s.containingProjects, project) } @@ -171,15 +179,9 @@ func (s *ScriptInfo) editContent(change ls.TextChange) { s.markContainingProjectsAsDirty() } -func (s *ScriptInfo) ensureRealpath(fs vfs.FS) { +func (s *ScriptInfo) ensureRealpath(project *Project) { if s.realpath == "" { - s.containingProjectsMu.RLock() - defer s.containingProjectsMu.RUnlock() - if len(s.containingProjects) == 0 { - panic("scriptInfo must be attached to a project before calling ensureRealpath") - } - realpath := fs.Realpath(string(s.path)) - project := s.containingProjects[0] + realpath := project.FS().Realpath(string(s.path)) s.realpath = project.toPath(realpath) if s.realpath != s.path { project.host.OnDiscoveredSymlink(s) From 8995a394982e51bb1192bf935a584146bb88dbee Mon Sep 17 00:00:00 2001 From: Sheetal Nandi Date: Tue, 3 Jun 2025 22:22:46 -0700 Subject: [PATCH 05/11] More refactor and fixes --- internal/api/api.go | 2 +- internal/project/project.go | 20 +++++++------------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index d03df66cd6..fc18968509 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -220,7 +220,7 @@ func (api *API) LoadProject(configFileName string) (*ProjectResponse, error) { configFileName = api.toAbsoluteFileName(configFileName) configFilePath := api.toPath(configFileName) p := project.NewConfiguredProject(configFileName, configFilePath, api) - if err := p.LoadConfig(); err != nil { + if _, err := p.LoadConfig(); err != nil { return nil, err } p.GetProgram() diff --git a/internal/project/project.go b/internal/project/project.go index a5c45f2c75..f3f7fc1447 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -194,6 +194,7 @@ func NewProject(name string, kind Kind, currentDirectory string, host ProjectHos kind: kind, currentDirectory: currentDirectory, rootFileNames: &collections.OrderedMap[tspath.Path, string]{}, + dirty: true, } project.comparePathsOptions = tspath.ComparePathsOptions{ CurrentDirectory: currentDirectory, @@ -490,7 +491,9 @@ func (p *Project) updateGraph() bool { p.programConfig = nil p.pendingReload = PendingReloadNone case PendingReloadFull: - if err := p.loadConfig(); err != nil { + var err error + writeFileNames, err = p.LoadConfig() + if err != nil { panic(fmt.Sprintf("failed to reload config: %v", err)) } } @@ -865,15 +868,7 @@ func (p *Project) AddInferredProjectRoot(info *ScriptInfo) { p.markAsDirtyLocked() } -func (p *Project) LoadConfig() error { - if err := p.loadConfig(); err != nil { - return err - } - p.markAsDirty() - return nil -} - -func (p *Project) loadConfig() error { +func (p *Project) LoadConfig() (bool, error) { if p.kind != KindConfigured { panic("loadConfig called on non-configured project") } @@ -906,13 +901,12 @@ func (p *Project) loadConfig() error { p.parsedCommandLine = parsedCommandLine p.compilerOptions = parsedCommandLine.CompilerOptions() p.typeAcquisition = parsedCommandLine.TypeAcquisition() - p.setRootFiles(parsedCommandLine.FileNames()) + return p.setRootFiles(parsedCommandLine.FileNames()), nil } else { p.compilerOptions = &core.CompilerOptions{} p.typeAcquisition = nil - return fmt.Errorf("could not read file %q", p.configFileName) + return false, fmt.Errorf("could not read file %q", p.configFileName) } - return nil } // setRootFiles returns true if the set of root files has changed. From a577da2b410938a7499575d7b17a42859d27563e Mon Sep 17 00:00:00 2001 From: Sheetal Nandi Date: Tue, 3 Jun 2025 22:50:02 -0700 Subject: [PATCH 06/11] Print memory usage in the log for now --- internal/project/service.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/project/service.go b/internal/project/service.go index e4d21cbd27..5279e69a3c 100644 --- a/internal/project/service.go +++ b/internal/project/service.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "maps" + "runtime" "strings" "sync" @@ -219,6 +220,7 @@ func (s *Service) OpenFile(fileName string, fileContent string, scriptKind core. } result := s.assignProjectToOpenedScriptInfo(info) s.cleanupProjectsAndScriptInfos(info, result) + s.printMemoryUsage() s.printProjects() } @@ -929,3 +931,14 @@ func (s *Service) printProjects() { func (s *Service) logf(format string, args ...any) { s.Log(fmt.Sprintf(format, args...)) } + +func (s *Service) printMemoryUsage() { + runtime.GC() // Force garbage collection to get accurate memory stats + var memStats runtime.MemStats + runtime.ReadMemStats(&memStats) + + s.logf("Alloc: %v KB", memStats.Alloc/1024) + s.logf("TotalAlloc: %v KB", memStats.TotalAlloc/1024) + s.logf("Sys: %v KB", memStats.Sys/1024) + s.logf("NumGC: %v", memStats.NumGC) +} From fc36044ed524c83da84e427f21fbccb26728d52e Mon Sep 17 00:00:00 2001 From: Sheetal Nandi Date: Wed, 4 Jun 2025 11:34:22 -0700 Subject: [PATCH 07/11] ContainsFunc --- internal/project/scriptinfo.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/project/scriptinfo.go b/internal/project/scriptinfo.go index 6748b82640..9a8fd9f515 100644 --- a/internal/project/scriptinfo.go +++ b/internal/project/scriptinfo.go @@ -232,7 +232,7 @@ func (s *ScriptInfo) delayReloadNonMixedContentFile() { func (s *ScriptInfo) containedByDeferredClosedProject() bool { s.containingProjectsMu.RLock() defer s.containingProjectsMu.RUnlock() - return slices.IndexFunc(s.containingProjects, func(project *Project) bool { + return slices.ContainsFunc(s.containingProjects, func(project *Project) bool { return project.deferredClose - }) != -1 + }) } From 63609b7f237dc2745d10ec818eec1620af989a59 Mon Sep 17 00:00:00 2001 From: Sheetal Nandi Date: Wed, 4 Jun 2025 11:37:31 -0700 Subject: [PATCH 08/11] Update internal/testutil/projecttestutil/projecttestutil.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/testutil/projecttestutil/projecttestutil.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/testutil/projecttestutil/projecttestutil.go b/internal/testutil/projecttestutil/projecttestutil.go index f28ad7969c..333bafc6cc 100644 --- a/internal/testutil/projecttestutil/projecttestutil.go +++ b/internal/testutil/projecttestutil/projecttestutil.go @@ -222,7 +222,7 @@ func newProjectServiceHost(files map[string]any) *ProjectServiceHost { defaultLibraryPath: bundled.LibPath(), ClientMock: &ClientMock{}, } - var watchCount atomic.Int32 + var watchCount atomic.Uint32 host.ClientMock.WatchFilesFunc = func(_ context.Context, _ []*lsproto.FileSystemWatcher) (project.WatcherHandle, error) { return project.WatcherHandle(fmt.Sprintf("#%d", watchCount.Add(1))), nil } From 54947dbd3d5df5743311f88bd152489f5dc824d4 Mon Sep 17 00:00:00 2001 From: Sheetal Nandi Date: Wed, 4 Jun 2025 11:49:47 -0700 Subject: [PATCH 09/11] Feedback --- internal/project/service.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/internal/project/service.go b/internal/project/service.go index 5279e69a3c..908aa03bbf 100644 --- a/internal/project/service.go +++ b/internal/project/service.go @@ -719,7 +719,7 @@ func (s *Service) cleanupConfiguredProjects(openInfo *ScriptInfo, retainedByOpen } func (s *Service) removeProject(project *Project) { - s.Log("`remove Project:: " + project.name) + s.Log("remove Project:: " + project.name) s.Log(project.print( /*writeProjectFileNames*/ true /*writeFileExplaination*/, true /*writeFileVersionAndText*/, false, &strings.Builder{})) switch project.kind { @@ -936,9 +936,5 @@ func (s *Service) printMemoryUsage() { runtime.GC() // Force garbage collection to get accurate memory stats var memStats runtime.MemStats runtime.ReadMemStats(&memStats) - - s.logf("Alloc: %v KB", memStats.Alloc/1024) - s.logf("TotalAlloc: %v KB", memStats.TotalAlloc/1024) - s.logf("Sys: %v KB", memStats.Sys/1024) - s.logf("NumGC: %v", memStats.NumGC) + s.logf("MemoryStats:\n\tAlloc: %v KB\n\tSys: %v KB\n\tNumGC: %v", memStats.Alloc/1024, memStats.Sys/1024, memStats.NumGC) } From 58509c3e40472ba8202a5ce45f7f252bd667d398 Mon Sep 17 00:00:00 2001 From: Sheetal Nandi Date: Wed, 4 Jun 2025 11:50:16 -0700 Subject: [PATCH 10/11] use hasAddedOrRemovedFile now that we have it available --- internal/api/api.go | 2 +- internal/project/project.go | 25 ++++++++----------------- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index fc18968509..d03df66cd6 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -220,7 +220,7 @@ func (api *API) LoadProject(configFileName string) (*ProjectResponse, error) { configFileName = api.toAbsoluteFileName(configFileName) configFilePath := api.toPath(configFileName) p := project.NewConfiguredProject(configFileName, configFilePath, api) - if _, err := p.LoadConfig(); err != nil { + if err := p.LoadConfig(); err != nil { return nil, err } p.GetProgram() diff --git a/internal/project/project.go b/internal/project/project.go index d2d8bd5eb0..9b2a936674 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -479,7 +479,6 @@ func (p *Project) updateGraph() bool { start := time.Now() p.Log("Starting updateGraph: Project: " + p.name) - var writeFileNames bool oldProgram := p.program p.initialLoadPending = false @@ -487,12 +486,11 @@ func (p *Project) updateGraph() bool { switch p.pendingReload { case PendingReloadFileNames: p.parsedCommandLine = tsoptions.ReloadFileNamesOfParsedCommandLine(p.parsedCommandLine, p.host.FS()) - writeFileNames = p.setRootFiles(p.parsedCommandLine.FileNames()) + p.setRootFiles(p.parsedCommandLine.FileNames()) p.programConfig = nil p.pendingReload = PendingReloadNone case PendingReloadFull: - var err error - writeFileNames, err = p.LoadConfig() + err := p.LoadConfig() if err != nil { panic(fmt.Sprintf("failed to reload config: %v", err)) } @@ -504,7 +502,7 @@ func (p *Project) updateGraph() bool { p.hasAddedorRemovedFiles.Store(false) p.dirty = false p.dirtyFilePath = "" - if writeFileNames { + if hasAddedOrRemovedFiles { p.Log(p.print(true /*writeFileNames*/, true /*writeFileExplanation*/, false /*writeFileVersionAndText*/, &strings.Builder{})) } else if p.program != oldProgram { p.Log("Different program with same set of root files") @@ -868,7 +866,7 @@ func (p *Project) AddInferredProjectRoot(info *ScriptInfo) { p.markAsDirtyLocked() } -func (p *Project) LoadConfig() (bool, error) { +func (p *Project) LoadConfig() error { if p.kind != KindConfigured { panic("loadConfig called on non-configured project") } @@ -901,25 +899,23 @@ func (p *Project) LoadConfig() (bool, error) { p.parsedCommandLine = parsedCommandLine p.compilerOptions = parsedCommandLine.CompilerOptions() p.typeAcquisition = parsedCommandLine.TypeAcquisition() - return p.setRootFiles(parsedCommandLine.FileNames()), nil + p.setRootFiles(parsedCommandLine.FileNames()) } else { p.compilerOptions = &core.CompilerOptions{} p.typeAcquisition = nil - return false, fmt.Errorf("could not read file %q", p.configFileName) + return fmt.Errorf("could not read file %q", p.configFileName) } + return nil } // setRootFiles returns true if the set of root files has changed. -func (p *Project) setRootFiles(rootFileNames []string) bool { - var hasChanged bool +func (p *Project) setRootFiles(rootFileNames []string) { newRootScriptInfos := make(map[tspath.Path]struct{}, len(rootFileNames)) for _, file := range rootFileNames { path := p.toPath(file) // !!! updateNonInferredProjectFiles uses a fileExists check, which I guess // could be needed if a watcher fails? newRootScriptInfos[path] = struct{}{} - isAlreadyRoot := p.rootFileNames.Has(path) - hasChanged = hasChanged || !isAlreadyRoot p.rootFileNames.Set(path, file) // if !isAlreadyRoot { // if scriptInfo.isOpen { @@ -929,17 +925,12 @@ func (p *Project) setRootFiles(rootFileNames []string) bool { } if p.rootFileNames.Size() > len(rootFileNames) { - hasChanged = true for root := range p.rootFileNames.Keys() { if _, ok := newRootScriptInfos[root]; !ok { p.rootFileNames.Delete(root) } } } - if hasChanged { - p.onFileAddedOrRemoved() - } - return hasChanged } func (p *Project) clearSourceMapperCache() { From 98d81329d3fa6d01ae181666a3faa884d12f6ccb Mon Sep 17 00:00:00 2001 From: Sheetal Nandi Date: Wed, 4 Jun 2025 15:32:42 -0700 Subject: [PATCH 11/11] Revert to mutex and handle case where getProgram can return nil if its closed --- internal/project/project.go | 16 +++-- internal/project/service.go | 133 ++++++++++++++++++++++-------------- 2 files changed, 91 insertions(+), 58 deletions(-) diff --git a/internal/project/project.go b/internal/project/project.go index 9b2a936674..dfb34b3f2e 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -254,8 +254,8 @@ func (p *Project) GetSourceFile(fileName string, path tspath.Path, languageVersi // Updates the program if needed. func (p *Project) GetProgram() *compiler.Program { - p.updateGraph() - return p.program + program, _ := p.updateGraph() + return program } // NewLine implements compiler.CompilerHost. @@ -294,6 +294,9 @@ func (p *Project) GetLanguageServiceForRequest(ctx context.Context) (*ls.Languag panic("context must already have a request ID") } program := p.GetProgram() + if program == nil { + panic("must have gced by other request") + } checkerPool := p.checkerPool snapshot := &snapshot{ project: p, @@ -469,12 +472,12 @@ func (p *Project) onFileAddedOrRemoved() { // Returns true if the set of files in has changed. NOTE: this is the // opposite of the return value in Strada, which was frequently inverted, // as in `updateProjectIfDirty()`. -func (p *Project) updateGraph() bool { +func (p *Project) updateGraph() (*compiler.Program, bool) { p.mu.Lock() defer p.mu.Unlock() - if !p.dirty { - return false + if !p.dirty || p.isClosed() { + return p.program, false } start := time.Now() @@ -522,7 +525,7 @@ func (p *Project) updateGraph() bool { p.updateWatchers(context.TODO()) } p.Logf("Finishing updateGraph: Project: %s version: %d in %s", p.name, p.version, time.Since(start)) - return true + return p.program, true } func (p *Project) updateProgram() bool { @@ -1043,6 +1046,7 @@ func (p *Project) Close() { p.programConfig = nil p.checkerPool = nil p.unresolvedImportsPerFile = nil + p.unresolvedImports = nil p.typingsInfo = nil p.typingFiles = nil diff --git a/internal/project/service.go b/internal/project/service.go index 908aa03bbf..2327bfe953 100644 --- a/internal/project/service.go +++ b/internal/project/service.go @@ -9,7 +9,6 @@ import ( "strings" "sync" - "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/ls" "github.com/microsoft/typescript-go/internal/lsp/lsproto" @@ -41,10 +40,11 @@ type Service struct { comparePathsOptions tspath.ComparePathsOptions converters *ls.Converters - configuredProjects collections.SyncMap[tspath.Path, *Project] + projectsMu sync.RWMutex + configuredProjects map[tspath.Path]*Project // inferredProjects is the list of all inferred projects, including the unrootedInferredProject // if it exists - inferredProjects collections.SyncMap[tspath.Path, *Project] + inferredProjects map[tspath.Path]*Project documentRegistry *DocumentRegistry scriptInfosMu sync.RWMutex @@ -73,6 +73,9 @@ func NewService(host ServiceHost, options ServiceOptions) *Service { CurrentDirectory: host.GetCurrentDirectory(), }, + configuredProjects: make(map[tspath.Path]*Project), + inferredProjects: make(map[tspath.Path]*Project), + documentRegistry: &DocumentRegistry{ Options: tspath.ComparePathsOptions{ UseCaseSensitiveFileNames: host.FS().UseCaseSensitiveFileNames(), @@ -163,27 +166,31 @@ func (s *Service) IsWatchEnabled() bool { } func (s *Service) Projects() []*Project { - projects := make([]*Project, 0, s.configuredProjects.Size()+s.inferredProjects.Size()) - s.configuredProjects.Range(func(key tspath.Path, project *Project) bool { + s.projectsMu.RLock() + defer s.projectsMu.RUnlock() + projects := make([]*Project, 0, len(s.configuredProjects)+len(s.inferredProjects)) + for _, project := range s.configuredProjects { projects = append(projects, project) - return true - }) - s.inferredProjects.Range(func(key tspath.Path, project *Project) bool { + } + for _, project := range s.inferredProjects { projects = append(projects, project) - return true - }) + } return projects } func (s *Service) ConfiguredProject(path tspath.Path) *Project { - if project, ok := s.configuredProjects.Load(path); ok { + s.projectsMu.RLock() + defer s.projectsMu.RUnlock() + if project, ok := s.configuredProjects[path]; ok { return project } return nil } func (s *Service) InferredProject(rootPath tspath.Path) *Project { - if project, ok := s.inferredProjects.Load(rootPath); ok { + s.projectsMu.RLock() + defer s.projectsMu.RUnlock() + if project, ok := s.inferredProjects[rootPath]; ok { return project } return nil @@ -213,10 +220,11 @@ func (s *Service) OpenFile(fileName string, fileContent string, scriptKind core. info := s.getOrCreateOpenScriptInfo(fileName, path, fileContent, scriptKind, projectRootPath) if existing == nil && info != nil && !info.isDynamic { // Invoke wild card directory watcher to ensure that the file presence is reflected - s.configuredProjects.Range(func(key tspath.Path, project *Project) bool { + s.projectsMu.RLock() + for _, project := range s.configuredProjects { project.tryInvokeWildCardDirectories(fileName, info.path) - return true - }) + } + s.projectsMu.RUnlock() } result := s.assignProjectToOpenedScriptInfo(info) s.cleanupProjectsAndScriptInfos(info, result) @@ -299,10 +307,12 @@ func (s *Service) SourceFileCount() int { } func (s *Service) OnWatchedFilesChanged(ctx context.Context, changes []*lsproto.FileEvent) error { + s.projectsMu.RLock() + defer s.projectsMu.RUnlock() for _, change := range changes { fileName := ls.DocumentURIToFileName(change.Uri) path := s.toPath(fileName) - if project, ok := s.configuredProjects.Load(path); ok { + if project, ok := s.configuredProjects[path]; ok { // tsconfig of project if err := s.onConfigFileChanged(project, change.Type); err != nil { return fmt.Errorf("error handling config file change: %w", err) @@ -321,14 +331,12 @@ func (s *Service) OnWatchedFilesChanged(ctx context.Context, changes []*lsproto. // !!! s.handleSourceMapProjects(info) } } else { - s.configuredProjects.Range(func(key tspath.Path, project *Project) bool { + for _, project := range s.configuredProjects { project.onWatchEventForNilScriptInfo(fileName) - return true - }) - s.inferredProjects.Range(func(key tspath.Path, project *Project) bool { + } + for _, project := range s.inferredProjects { project.onWatchEventForNilScriptInfo(fileName) - return true - }) + } } } @@ -359,14 +367,16 @@ func (s *Service) onConfigFileChanged(project *Project, changeKind lsproto.FileC func (s *Service) ensureProjectStructureUpToDate() { var hasChanges bool - s.configuredProjects.Range(func(key tspath.Path, project *Project) bool { - hasChanges = project.updateGraph() || hasChanges - return true - }) - s.inferredProjects.Range(func(key tspath.Path, project *Project) bool { - hasChanges = project.updateGraph() || hasChanges - return true - }) + s.projectsMu.RLock() + for _, project := range s.configuredProjects { + _, updated := project.updateGraph() + hasChanges = updated || hasChanges + } + for _, project := range s.inferredProjects { + _, updated := project.updateGraph() + hasChanges = updated || hasChanges + } + s.projectsMu.RUnlock() if hasChanges { s.ensureProjectForOpenFiles() } @@ -387,10 +397,11 @@ func (s *Service) ensureProjectForOpenFiles() { // !!! s.removeRootOfInferredProjectIfNowPartOfOtherProject(info) } } - s.inferredProjects.Range(func(key tspath.Path, project *Project) bool { + s.projectsMu.RLock() + for _, project := range s.inferredProjects { project.updateGraph() - return true - }) + } + s.projectsMu.RUnlock() s.Log("After ensureProjectForOpenFiles:") s.printProjects() @@ -573,7 +584,9 @@ func (s *Service) findDefaultConfiguredProject(scriptInfo *ScriptInfo) *Project } func (s *Service) findConfiguredProjectByName(configFilePath tspath.Path, includeDeferredClosedProjects bool) *Project { - if result, ok := s.configuredProjects.Load(configFilePath); ok { + s.projectsMu.RLock() + defer s.projectsMu.RUnlock() + if result, ok := s.configuredProjects[configFilePath]; ok { if includeDeferredClosedProjects || !result.deferredClose { return result } @@ -582,9 +595,12 @@ func (s *Service) findConfiguredProjectByName(configFilePath tspath.Path, includ } func (s *Service) createConfiguredProject(configFileName string, configFilePath tspath.Path) *Project { + s.projectsMu.Lock() + defer s.projectsMu.Unlock() + // !!! config file existence cache stuff omitted project := NewConfiguredProject(configFileName, configFilePath, s) - project, _ = s.configuredProjects.LoadOrStore(configFilePath, project) + s.configuredProjects[configFilePath] = project // !!! // s.createConfigFileWatcherForParsedConfig(configFileName, configFilePath, project) return project @@ -654,7 +670,9 @@ func (s *Service) cleanupProjectsAndScriptInfos(openInfo *ScriptInfo, retainedBy // Remove orphan inferred projects now that we have reused projects // We need to create a duplicate because we cant guarantee order after removal - inferredProjects := s.inferredProjects.ToMap() + s.projectsMu.RLock() + inferredProjects := maps.Clone(s.inferredProjects) + s.projectsMu.RUnlock() for _, inferredProject := range inferredProjects { if inferredProject.isOrphan() { s.removeProject(inferredProject) @@ -669,7 +687,10 @@ func (s *Service) cleanupProjectsAndScriptInfos(openInfo *ScriptInfo, retainedBy } func (s *Service) cleanupConfiguredProjects(openInfo *ScriptInfo, retainedByOpenFile *Project) { - toRemoveProjects := s.configuredProjects.ToMap() + s.projectsMu.RLock() + toRemoveProjects := maps.Clone(s.configuredProjects) + s.projectsMu.RUnlock() + // !!! handle declarationMap retainConfiguredProject := func(project *Project) { if _, ok := toRemoveProjects[project.configFilePath]; !ok { @@ -721,13 +742,14 @@ func (s *Service) cleanupConfiguredProjects(openInfo *ScriptInfo, retainedByOpen func (s *Service) removeProject(project *Project) { s.Log("remove Project:: " + project.name) s.Log(project.print( /*writeProjectFileNames*/ true /*writeFileExplaination*/, true /*writeFileVersionAndText*/, false, &strings.Builder{})) - + s.projectsMu.Lock() switch project.kind { case KindConfigured: - s.configuredProjects.Delete(project.configFilePath) + delete(s.configuredProjects, project.configFilePath) case KindInferred: - s.inferredProjects.Delete(project.rootPath) + delete(s.inferredProjects, project.rootPath) } + s.projectsMu.Unlock() project.Close() } @@ -784,9 +806,11 @@ func (s *Service) getOrCreateInferredProjectForProjectRootPath(info *ScriptInfo, } func (s *Service) getInferredProjectForProjectRootPath(info *ScriptInfo, projectRootDirectory string) *Project { + s.projectsMu.RLock() + defer s.projectsMu.RUnlock() if projectRootDirectory != "" { projectRootPath := s.toPath(projectRootDirectory) - if project, ok := s.inferredProjects.Load(projectRootPath); ok { + if project, ok := s.inferredProjects[projectRootPath]; ok { return project } return nil @@ -794,14 +818,13 @@ func (s *Service) getInferredProjectForProjectRootPath(info *ScriptInfo, project if !info.isDynamic { var bestMatch *Project - s.inferredProjects.Range(func(key tspath.Path, project *Project) bool { + for _, project := range s.inferredProjects { if project.rootPath != "" && tspath.ContainsPath(string(project.rootPath), string(info.path), s.comparePathsOptions) && (bestMatch == nil || len(bestMatch.rootPath) <= len(project.rootPath)) { bestMatch = project } - return true - }) + } if bestMatch != nil { return bestMatch @@ -809,7 +832,7 @@ func (s *Service) getInferredProjectForProjectRootPath(info *ScriptInfo, project } // unrooted inferred project if no best match found - if unrootedProject, ok := s.inferredProjects.Load(""); ok { + if unrootedProject, ok := s.inferredProjects[""]; ok { return unrootedProject } return nil @@ -878,6 +901,12 @@ func (s *Service) getDefaultProjectForScript(scriptInfo *ScriptInfo) *Project { } func (s *Service) createInferredProject(currentDirectory string, projectRootPath tspath.Path) *Project { + s.projectsMu.Lock() + defer s.projectsMu.Unlock() + if existingProject, ok := s.inferredProjects[projectRootPath]; ok { + return existingProject + } + compilerOptions := core.CompilerOptions{ AllowJs: core.TSTrue, Module: core.ModuleKindESNext, @@ -893,7 +922,7 @@ func (s *Service) createInferredProject(currentDirectory string, projectRootPath ResolveJsonModule: core.TSTrue, } project := NewInferredProject(&compilerOptions, currentDirectory, projectRootPath, s) - project, _ = s.inferredProjects.LoadOrStore(project.rootPath, project) + s.inferredProjects[project.rootPath] = project return project } @@ -907,16 +936,16 @@ func (s *Service) printProjects() { } var builder strings.Builder - s.configuredProjects.Range(func(key tspath.Path, project *Project) bool { + s.projectsMu.RLock() + for _, project := range s.configuredProjects { project.print(false /*writeFileNames*/, false /*writeFileExpanation*/, false /*writeFileVersionAndText*/, &builder) builder.WriteRune('\n') - return true - }) - s.inferredProjects.Range(func(key tspath.Path, project *Project) bool { + } + for _, project := range s.inferredProjects { project.print(false /*writeFileNames*/, false /*writeFileExpanation*/, false /*writeFileVersionAndText*/, &builder) builder.WriteRune('\n') - return true - }) + } + s.projectsMu.RUnlock() builder.WriteString("Open files:") for path, projectRootPath := range s.openFiles {