Skip to content

Commit 230d600

Browse files
CopilotDanielRosenwasserandrewbranch
authored
Fix nil dereference crash in loadModuleFromSpecificNodeModulesDirectory (#3941)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: DanielRosenwasser <972891+DanielRosenwasser@users.noreply.github.com> Co-authored-by: andrewbranch <3277153+andrewbranch@users.noreply.github.com>
1 parent 70b87f0 commit 230d600

2 files changed

Lines changed: 162 additions & 16 deletions

File tree

internal/module/resolver.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1134,7 +1134,7 @@ func (r *resolutionState) loadModuleFromSpecificNodeModulesDirectory(ext extensi
11341134
// https://github.com/microsoft/TypeScript/pull/49327
11351135
return r.loadModuleFromExports(packageInfo, ext, tspath.CombinePaths(".", rest))
11361136
}
1137-
if rest != "" {
1137+
if rest != "" && packageInfo.Exists() {
11381138
versionPaths := packageInfo.Contents.GetVersionPaths(r.getTraceFunc())
11391139
if versionPaths.Exists() {
11401140
if r.tracer != nil {

internal/module/resolver_test.go

Lines changed: 161 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
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.
5857
type 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

6576
func (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

Comments
 (0)