Skip to content

Commit eb04840

Browse files
authored
Fix auto-import bucket state race and CreateProgram/UpdateProgram race (#3590)
1 parent 6284425 commit eb04840

8 files changed

Lines changed: 323 additions & 24 deletions

File tree

internal/compiler/program.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,9 +273,14 @@ func NewProgram(opts ProgramOptions) *Program {
273273

274274
// Return an updated program for which it is known that only the file with the given path has changed.
275275
// In addition to a new program, return a boolean indicating whether the data of the old program was reused.
276-
func (p *Program) UpdateProgram(changedFilePath tspath.Path, newHost CompilerHost) (*Program, bool) {
276+
// createCheckerPool, if non-nil, overrides the CreateCheckerPool stored in the old program's options,
277+
// ensuring each caller uses a fresh closure and avoiding data races on captured variables.
278+
func (p *Program) UpdateProgram(changedFilePath tspath.Path, newHost CompilerHost, createCheckerPool func(*Program) CheckerPool) (*Program, bool) {
277279
newOpts := p.opts
278280
newOpts.Host = newHost
281+
if createCheckerPool != nil {
282+
newOpts.CreateCheckerPool = createCheckerPool
283+
}
279284

280285
oldFile := p.filesByPath[changedFilePath]
281286
newFile := newHost.GetSourceFile(oldFile.ParseOptions())
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package fourslash_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/microsoft/typescript-go/internal/fourslash"
7+
"github.com/microsoft/typescript-go/internal/testutil"
8+
)
9+
10+
const TestAutoImportSymlinkedMonorepoSourceUpdateScenario = `
11+
// @Filename: /home/src/workspaces/project/tsconfig.base.json
12+
{
13+
"compilerOptions": {
14+
"module": "nodenext",
15+
"moduleResolution": "nodenext",
16+
"composite": true
17+
}
18+
}
19+
20+
// @Filename: /home/src/workspaces/project/packages/foo/package.json
21+
{
22+
"name": "@packages/foo",
23+
"type": "module",
24+
"exports": {
25+
".": {
26+
"types": "./src/index.ts",
27+
"default": "./dist/index.js"
28+
}
29+
}
30+
}
31+
32+
// @Filename: /home/src/workspaces/project/packages/foo/tsconfig.json
33+
{ "extends": "../../tsconfig.base.json" }
34+
35+
// @Filename: /home/src/workspaces/project/packages/foo/src/index.ts
36+
/*fooEdit*/
37+
38+
// @Filename: /home/src/workspaces/project/packages/bar/package.json
39+
{
40+
"name": "@packages/bar",
41+
"type": "module",
42+
"exports": {
43+
".": {
44+
"types": "./src/index.ts",
45+
"default": "./dist/index.js"
46+
}
47+
},
48+
"dependencies": {
49+
"@packages/foo": "*"
50+
}
51+
}
52+
53+
// @Filename: /home/src/workspaces/project/packages/bar/tsconfig.json
54+
{ "extends": "../../tsconfig.base.json" }
55+
56+
// @Filename: /home/src/workspaces/project/packages/bar/src/index.ts
57+
/*fooCompletion*/
58+
59+
// @Filename: /home/src/workspaces/project/package.json
60+
{ "workspaces": ["packages/*"], "type": "module" }
61+
62+
// @link: /home/src/workspaces/project/packages/bar -> /home/src/workspaces/project/node_modules/@packages/bar
63+
// @link: /home/src/workspaces/project/packages/foo -> /home/src/workspaces/project/node_modules/@packages/foo
64+
`
65+
66+
func TestAutoImportSymlinkedMonorepoSourceUpdate(t *testing.T) {
67+
t.Parallel()
68+
69+
defer testutil.RecoverAndFail(t, "Panic on fourslash test")
70+
71+
f, done := fourslash.NewFourslash(t, nil /*capabilities*/, TestAutoImportSymlinkedMonorepoSourceUpdateScenario)
72+
73+
defer done()
74+
75+
// Force auto import to build the cache (no exports yet).
76+
f.GoToMarker(t, "fooCompletion")
77+
f.BaselineAutoImportsCompletions(t, []string{"fooCompletion"})
78+
79+
// Add a new export to the symlinked source package.
80+
f.GoToMarker(t, "fooEdit")
81+
f.Insert(t, "\nexport function foo() {}")
82+
83+
// The new export should appear via granular cache update.
84+
f.GoToMarker(t, "fooCompletion")
85+
f.BaselineAutoImportsCompletions(t, []string{"fooCompletion"})
86+
}

internal/ls/autoimport/registry.go

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,20 @@ type BucketState struct {
5757
// the bucket was built. If changed, the bucket should be rebuilt.
5858
fileExcludePatterns []string
5959
// dirtyPackages is the set of package names that need to be re-indexed.
60-
// This is used for granular updates: when a file in a project reference package
60+
// This is used for granular updates: when a file in a local workspace package
6161
// changes, only that package needs to be re-extracted rather than rebuilding
62-
// the entire node_modules bucket. If nil, no granular updates are pending.
62+
// the entire node_modules bucket.
63+
// If nil, no granular updates are pending.
6364
// If set but multipleFilesDirty is true, the entire bucket needs to be rebuilt.
6465
dirtyPackages *collections.Set[string]
6566
}
6667

68+
func (b BucketState) Clone() BucketState {
69+
b.fileExcludePatterns = slices.Clone(b.fileExcludePatterns)
70+
b.dirtyPackages = b.dirtyPackages.Clone()
71+
return b
72+
}
73+
6774
func (b BucketState) Dirty() bool {
6875
return b.multipleFilesDirty || b.dirtyFile != "" || b.newProgramStructure > 0 || b.dirtyPackages.Len() > 0
6976
}
@@ -98,31 +105,50 @@ type RegistryBucket struct {
98105

99106
// Paths maps file paths to package names. For project buckets, the package name
100107
// is always empty string. For node_modules buckets, this enables reverse lookup
101-
// from path to package for granular updates. Only paths eligible for granular
102-
// update (project reference packages) have entries here.
108+
// from path to package for granular updates. Only paths for local workspace
109+
// packages (symlinked and within the workspace root) have entries here, since
110+
// their realpaths are outside node_modules and need reverse lookup for dirty
111+
// detection.
112+
//
113+
// Paths is considered immutable after the bucket is finalized.
114+
// It should be fully replaced rather than mutated while changing a bucket.
103115
Paths map[tspath.Path]string
104116
// PackageFiles maps package names to their file paths and file names.
105117
// All package directory names in node_modules are keys; indexed packages have
106118
// non-nil maps with path→fileName entries, unindexed packages have nil maps.
107119
// This enables efficient removal of a package's files during granular updates
108120
// without iterating through all entries. Only defined for node_modules buckets.
121+
//
122+
// PackageFiles is considered immutable after the bucket is finalized.
123+
// It should be fully replaced rather than mutated while changing a bucket.
109124
PackageFiles map[string]map[tspath.Path]string
110125
// ResolvedPackageNames is only defined for project buckets. It is the set of
111126
// package names that were resolved from imports in the project's program files.
112127
// This is passed to node_modules buckets so they include packages that are
113128
// directly imported even if not listed in package.json dependencies.
129+
//
130+
// ResolvedPackageNames is considered immutable after the bucket is finalized.
131+
// It should be fully replaced rather than mutated while changing a bucket.
114132
ResolvedPackageNames *collections.Set[string]
115133
// DependencyNames is only defined for node_modules buckets. It is the set of
116134
// package names that will be included in the bucket if present in the directory,
117135
// computed from package.json dependencies plus resolved package names from
118136
// active programs. If nil, all packages are included because at least one open
119137
// file has access to this node_modules directory without being filtered by a
120138
// package.json.
139+
//
140+
// DependencyNames is considered immutable after the bucket is finalized.
141+
// It should be fully replaced rather than mutated while changing a bucket.
121142
DependencyNames *collections.Set[string]
122143
// AmbientModuleNames is only defined for node_modules buckets. It is the set of
123144
// ambient module names found while extracting exports in the bucket.
145+
//
146+
// AmbientModuleNames is considered immutable after the bucket is finalized.
147+
// It should be fully replaced rather than mutated while changing a bucket.
124148
AmbientModuleNames map[string][]string
125-
Index *Index[*Export]
149+
// Index is considered immutable after the bucket is finalized.
150+
// It should be cloned and replaced rather than mutated while changing a bucket.
151+
Index *Index[*Export]
126152
}
127153

128154
func newRegistryBucket() *RegistryBucket {
@@ -136,7 +162,7 @@ func newRegistryBucket() *RegistryBucket {
136162

137163
func (b *RegistryBucket) Clone() *RegistryBucket {
138164
return &RegistryBucket{
139-
state: b.state,
165+
state: b.state.Clone(),
140166
Paths: b.Paths,
141167
PackageFiles: b.PackageFiles,
142168
ResolvedPackageNames: b.ResolvedPackageNames,
@@ -615,8 +641,8 @@ func (b *registryBuilder) markBucketsDirty(change RegistryChange, logger *loggin
615641
}
616642
}
617643
} else {
618-
// Check if this path (possibly a realpath of a symlinked package) is in any bucket's Paths.
619-
// This handles symlinked packages where the realpath doesn't contain /node_modules/.
644+
// Check if this path (possibly a realpath of a workspace package) is in any bucket's Paths.
645+
// This handles local workspace packages where the realpath doesn't contain /node_modules/.
620646
for bucketDirPath := range cleanNodeModulesBuckets {
621647
entry := core.FirstResult(b.nodeModules.Get(bucketDirPath))
622648
if packageName, ok := entry.Value().Paths[path]; ok {
@@ -1184,6 +1210,7 @@ type discoveredPackage struct {
11841210
typesPackageJson *packagejson.InfoCacheEntry
11851211
typesRealpath string
11861212
dirPath tspath.Path // bucket directory path (used as extraction context)
1213+
isLocal bool // true if realpath is within the workspace root
11871214
}
11881215

11891216
// perPackageExtractionResult holds the extraction output for one physical package.
@@ -1197,7 +1224,7 @@ type perPackageExtractionResult struct {
11971224
statsExports int
11981225
statsUsedChecker int
11991226
skippedEntrypoints int
1200-
isProjectReference bool
1227+
isSymlinked bool
12011228
failedAmbientModuleLookupSources map[tspath.Path]*failedAmbientModuleLookupSource
12021229
failedAmbientModuleLookupTargets *collections.Set[string]
12031230
}
@@ -1208,7 +1235,7 @@ type packageExtractionResult struct {
12081235
packageFiles map[string]map[tspath.Path]string
12091236
ambientModuleNames map[string][]string
12101237
entrypoints [][]*module.ResolvedEntrypoint
1211-
projectReferencePackages *collections.Set[string]
1238+
workspacePackages *collections.Set[string]
12121239
possibleFailedAmbientModuleLookupSources *collections.SyncMap[tspath.Path, *failedAmbientModuleLookupSource]
12131240
possibleFailedAmbientModuleLookupTargets *collections.SyncSet[string]
12141241
stats extractorStats
@@ -1241,13 +1268,21 @@ func (b *registryBuilder) discoverBucketPackages(
12411268
if typesPackageJson != nil {
12421269
typesRealpath = b.host.FS().Realpath(typesPackageJson.PackageDirectory)
12431270
}
1271+
isLocal := realpath != "" &&
1272+
!strings.Contains(realpath, "/node_modules/") &&
1273+
tspath.ContainsPath(
1274+
b.host.GetCurrentDirectory(),
1275+
realpath,
1276+
tspath.ComparePathsOptions{UseCaseSensitiveFileNames: b.host.FS().UseCaseSensitiveFileNames()},
1277+
)
12441278
result = append(result, &discoveredPackage{
12451279
packageName: packageName,
12461280
packageJson: packageJson,
12471281
realpath: realpath,
12481282
typesPackageJson: typesPackageJson,
12491283
typesRealpath: typesRealpath,
12501284
dirPath: dirPath,
1285+
isLocal: isLocal,
12511286
})
12521287
}
12531288
return result
@@ -1309,7 +1344,6 @@ func (b *registryBuilder) extractPackage(
13091344
fileName = toSymlink(inputFileName)
13101345
realpathFileName = inputFileName
13111346
realpathPath = b.base.toPath(realpathFileName)
1312-
result.isProjectReference = true
13131347
}
13141348

13151349
if !seenFiles.AddIfAbsent(realpathPath) {
@@ -1318,6 +1352,7 @@ func (b *registryBuilder) extractPackage(
13181352
if fileName != realpathFileName {
13191353
symlinkPath := b.base.toPath(fileName)
13201354
symlinks[realpathPath] = pathAndFileName{path: symlinkPath, fileName: fileName}
1355+
result.isSymlinked = true
13211356
}
13221357
wg.Go(func() {
13231358
file := b.host.GetSourceFile(realpathFileName, realpathPath)
@@ -1377,7 +1412,7 @@ func installExtractions(
13771412
exports: make(map[tspath.Path][]*Export),
13781413
packageFiles: make(map[string]map[tspath.Path]string),
13791414
ambientModuleNames: make(map[string][]string),
1380-
projectReferencePackages: &collections.Set[string]{},
1415+
workspacePackages: &collections.Set[string]{},
13811416
possibleFailedAmbientModuleLookupSources: &collections.SyncMap[tspath.Path, *failedAmbientModuleLookupSource]{},
13821417
possibleFailedAmbientModuleLookupTargets: &collections.SyncSet[string]{},
13831418
}
@@ -1407,8 +1442,8 @@ func installExtractions(
14071442
for target := range extraction.failedAmbientModuleLookupTargets.Keys() {
14081443
result.possibleFailedAmbientModuleLookupTargets.Add(target)
14091444
}
1410-
if extraction.isProjectReference {
1411-
result.projectReferencePackages.Add(pkg.packageName)
1445+
if extraction.isSymlinked && pkg.isLocal {
1446+
result.workspacePackages.Add(pkg.packageName)
14121447
}
14131448
result.stats.exports.Add(int32(extraction.statsExports))
14141449
result.stats.usedChecker.Add(int32(extraction.statsUsedChecker))
@@ -1444,9 +1479,9 @@ func (b *registryBuilder) buildNodeModulesBucket(
14441479
}
14451480

14461481
// Build Paths as reverse mapping from path to package name.
1447-
// Only include paths for packages that are project references (eligible for granular updates).
1482+
// Only include paths for local workspace packages (eligible for granular updates).
14481483
paths := make(map[tspath.Path]string)
1449-
for pkgName := range extraction.projectReferencePackages.Keys() {
1484+
for pkgName := range extraction.workspacePackages.Keys() {
14501485
if files, ok := extraction.packageFiles[pkgName]; ok {
14511486
for path := range files {
14521487
paths[path] = pkgName
@@ -1545,8 +1580,8 @@ func (b *registryBuilder) updateNodeModulesBucket(
15451580
}
15461581
newPaths[path] = pkgName
15471582
}
1548-
// Add paths for newly extracted project reference packages
1549-
for pkgName := range extraction.projectReferencePackages.Keys() {
1583+
// Add paths for newly extracted workspace packages
1584+
for pkgName := range extraction.workspacePackages.Keys() {
15501585
if files, ok := extraction.packageFiles[pkgName]; ok {
15511586
for path := range files {
15521587
newPaths[path] = pkgName

0 commit comments

Comments
 (0)