55 "sync"
66 "sync/atomic"
77 "testing"
8- "time"
98
109 "github.com/microsoft/typescript-go/internal/core"
1110 "github.com/microsoft/typescript-go/internal/module"
@@ -51,25 +50,80 @@ func TestResolveModuleNameTrailingSlash(t *testing.T) {
5150}
5251
5352// blockingFS wraps a vfs.FS and forces FileExists calls for `targetPath` to
54- // block on `gate` until released. It also counts how many goroutines are
55- // waiting at the gate. This is used to deterministically reproduce the
53+ // block on `gate` until released. Each caller sends on `arrived` when it
54+ // reaches the gate. This is used to deterministically reproduce the
5655// `package.json` info-cache insert race described in
5756// https://github.com/microsoft/typescript-go/issues/3526.
5857type blockingFS struct {
5958 vfs.FS
6059 targetPath string
6160 gate chan struct {}
62- waiting atomic.Int32
61+ arrived chan struct {} // each blocked goroutine sends one value
62+ }
63+
64+ // waitForSignal waits for a synchronization point in these race regression
65+ // tests and converts deadlocks into deterministic test failures.
66+ func waitForSignal (t * testing.T , ch <- chan struct {}, description string ) {
67+ t .Helper ()
68+ select {
69+ case <- ch :
70+ return
71+ case <- t .Context ().Done ():
72+ t .Fatalf ("timed out waiting for %s" , description )
73+ }
6374}
6475
6576func (f * blockingFS ) FileExists (path string ) bool {
6677 if path == f .targetPath {
67- f .waiting . Add ( 1 )
78+ f .arrived <- struct {}{}
6879 <- f .gate
6980 }
7081 return f .FS .FileExists (path )
7182}
7283
84+ // flipFileExistsFS wraps a vfs.FS and returns false for the first
85+ // FileExists call to `targetPath`, then true for the second. Both calls
86+ // signal arrival via channel then block until released via their respective
87+ // gate channels. ReadFile for the target path also signals arrival then
88+ // blocks, so the "file doesn't exist" Set completes before the "file exists"
89+ // Set (reproducing the LoadOrStore race).
90+ type flipFileExistsFS struct {
91+ vfs.FS
92+ targetPath string
93+ callCount atomic.Int32
94+ firstArrived chan struct {} // closed when the first FileExists caller arrives
95+ secondArrived chan struct {} // closed when the second FileExists caller arrives
96+ firstGate chan struct {}
97+ secondGate chan struct {}
98+ readArrived chan struct {} // closed when ReadFile caller arrives
99+ readGate chan struct {}
100+ }
101+
102+ func (f * flipFileExistsFS ) FileExists (path string ) bool {
103+ if path == f .targetPath {
104+ n := f .callCount .Add (1 )
105+ if n == 1 {
106+ close (f .firstArrived )
107+ <- f .firstGate
108+ return false // first caller: simulate "file not yet visible"
109+ }
110+ if n == 2 {
111+ close (f .secondArrived )
112+ <- f .secondGate
113+ return f .FS .FileExists (path ) // second caller: file is visible
114+ }
115+ }
116+ return f .FS .FileExists (path )
117+ }
118+
119+ func (f * flipFileExistsFS ) ReadFile (path string ) (string , bool ) {
120+ if path == f .targetPath {
121+ close (f .readArrived )
122+ <- f .readGate
123+ }
124+ return f .FS .ReadFile (path )
125+ }
126+
73127// Regression test for https://github.com/microsoft/typescript-go/issues/3526.
74128//
75129// Two goroutines resolve the same package via specifiers that differ only by
@@ -106,6 +160,7 @@ func TestResolveModuleNameTrailingSlashRace(t *testing.T) {
106160 FS : vfstest .FromMap (files , true ),
107161 targetPath : pkgJSONPath ,
108162 gate : make (chan struct {}),
163+ arrived : make (chan struct {}, 2 ),
109164 }
110165 host := & resolutionHostStub {fs : fs , cwd : "/repo" }
111166 opts := & core.CompilerOptions {
@@ -115,11 +170,11 @@ func TestResolveModuleNameTrailingSlashRace(t *testing.T) {
115170 }
116171 resolver := module .NewResolver (host , opts , "" , "" )
117172
118- type result struct {
173+ type resolutionResult struct {
119174 name string
120175 resolved bool
121176 }
122- results := make (chan result , 2 )
177+ results := make (chan resolutionResult , 2 )
123178 var wg sync.WaitGroup
124179 for _ , name := range []string {"pkg" , "pkg/" } {
125180 containingFile := "/repo/src/a/file.ts"
@@ -128,19 +183,14 @@ func TestResolveModuleNameTrailingSlashRace(t *testing.T) {
128183 }
129184 wg .Go (func () {
130185 r , _ := resolver .ResolveModuleName (name , containingFile , core .ModuleKindESNext , nil )
131- results <- result {name , r .IsResolved ()}
186+ results <- resolutionResult {name , r .IsResolved ()}
132187 })
133188 }
134189
135190 // Wait for both goroutines to reach the FileExists gate, guaranteeing
136191 // both have observed a package.json info-cache miss.
137- deadline := time .Now ().Add (5 * time .Second )
138- for fs .waiting .Load () < 2 {
139- if time .Now ().After (deadline ) {
140- t .Fatalf ("timed out waiting for both goroutines to reach FileExists gate; got %d" , fs .waiting .Load ())
141- }
142- time .Sleep (time .Millisecond )
143- }
192+ waitForSignal (t , fs .arrived , "first FileExists gate arrival" )
193+ waitForSignal (t , fs .arrived , "second FileExists gate arrival" )
144194 close (fs .gate )
145195
146196 wg .Wait ()
@@ -151,3 +201,99 @@ func TestResolveModuleNameTrailingSlashRace(t *testing.T) {
151201 }
152202 }
153203}
204+
205+ // Regression test for https://github.com/microsoft/typescript-go/issues/1290.
206+ //
207+ // Two goroutines resolve `pkg/sub` concurrently. Both miss the package.json
208+ // info-cache for the root package directory. A `flipFileExistsFS` forces the
209+ // first goroutine's `FileExists` to return false (simulating the file not yet
210+ // being visible), so it stores a nil-Contents cache entry. The second
211+ // goroutine's `FileExists` returns true, but its `Set` call (`LoadOrStore`)
212+ // returns the first goroutine's nil-Contents entry. Without the `Exists()`
213+ // guard on the `typesVersions` lookup, `packageInfo.Contents.GetVersionPaths`
214+ // dereferences nil and panics. With the guard the nil-Contents entry is safely
215+ // skipped.
216+ func TestResolveSubpathNilContentsRace (t * testing.T ) {
217+ t .Parallel ()
218+
219+ const rootPkgJSON = "/repo/node_modules/pkg/package.json"
220+ files := map [string ]string {
221+ rootPkgJSON : `{"name":"pkg","version":"1.0.0"}` ,
222+ "/repo/node_modules/pkg/sub/index.d.ts" : "export declare const sub: number;" ,
223+ "/repo/node_modules/pkg/sub/index.js" : "exports.sub = 1;" ,
224+ "/repo/src/a/file.ts" : "" ,
225+ "/repo/src/b/file.ts" : "" ,
226+ }
227+ fs := & flipFileExistsFS {
228+ FS : vfstest .FromMap (files , true ),
229+ targetPath : rootPkgJSON ,
230+ firstArrived : make (chan struct {}),
231+ secondArrived : make (chan struct {}),
232+ firstGate : make (chan struct {}),
233+ secondGate : make (chan struct {}),
234+ readArrived : make (chan struct {}),
235+ readGate : make (chan struct {}),
236+ }
237+ host := & resolutionHostStub {fs : fs , cwd : "/repo" }
238+ opts := & core.CompilerOptions {
239+ ModuleResolution : core .ModuleResolutionKindBundler ,
240+ Module : core .ModuleKindESNext ,
241+ Target : core .ScriptTargetESNext ,
242+ }
243+ resolver := module .NewResolver (host , opts , "" , "" )
244+
245+ var panicked atomic.Bool
246+ type resolutionResult struct {
247+ containingFile string
248+ resolved bool
249+ }
250+ results := make (chan resolutionResult , 2 )
251+ var wg sync.WaitGroup
252+ // Two goroutines both resolve "pkg/sub". Each calls getPackageJsonInfo
253+ // for the root package directory, reaching FileExists for rootPkgJSON.
254+ for _ , containingFile := range []string {"/repo/src/a/file.ts" , "/repo/src/b/file.ts" } {
255+ wg .Go (func () {
256+ resolved := false
257+ defer func () {
258+ if r := recover (); r != nil {
259+ panicked .Store (true )
260+ }
261+ results <- resolutionResult {containingFile : containingFile , resolved : resolved }
262+ }()
263+ r , _ := resolver .ResolveModuleName ("pkg/sub" , containingFile , core .ModuleKindESNext , nil )
264+ resolved = r .IsResolved ()
265+ })
266+ }
267+
268+ // Phase 1: Wait for both goroutines to reach FileExists for the root
269+ // package.json, guaranteeing both have observed a cache miss.
270+ waitForSignal (t , fs .firstArrived , "first root package.json FileExists arrival" )
271+ waitForSignal (t , fs .secondArrived , "second root package.json FileExists arrival" )
272+
273+ // Phase 2: Release the first FileExists caller (returns false).
274+ // It enters the "file not found" branch and stores a nil-Contents entry
275+ // via Set — this is nearly instant (no ReadFile).
276+ close (fs .firstGate )
277+
278+ // Phase 3: Release the second FileExists caller (returns true).
279+ // It proceeds to ReadFile, which we gate separately to ensure the first
280+ // goroutine's nil-Contents Set has completed.
281+ close (fs .secondGate )
282+
283+ // Phase 4: Wait for the second goroutine to reach ReadFile, then release.
284+ // By this point the first goroutine has stored its nil-Contents entry.
285+ // The second goroutine's Set (LoadOrStore) will return that stale entry.
286+ waitForSignal (t , fs .readArrived , "root package.json ReadFile arrival" )
287+ close (fs .readGate )
288+
289+ wg .Wait ()
290+ close (results )
291+ if panicked .Load () {
292+ t .Fatal ("resolver panicked due to nil Contents dereference in loadModuleFromSpecificNodeModulesDirectory" )
293+ }
294+ for r := range results {
295+ if ! r .resolved {
296+ t .Fatalf ("%q failed to resolve pkg/sub" , r .containingFile )
297+ }
298+ }
299+ }
0 commit comments