417 lines
15 KiB
Go
417 lines
15 KiB
Go
package module_test
|
|
|
|
import (
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/microsoft/typescript-go/internal/core"
|
|
"github.com/microsoft/typescript-go/internal/module"
|
|
"github.com/microsoft/typescript-go/internal/vfs"
|
|
"github.com/microsoft/typescript-go/internal/vfs/vfstest"
|
|
)
|
|
|
|
type resolutionHostStub struct {
|
|
fs vfs.FS
|
|
cwd string
|
|
}
|
|
|
|
func (h *resolutionHostStub) FS() vfs.FS { return h.fs }
|
|
func (h *resolutionHostStub) GetCurrentDirectory() string { return h.cwd }
|
|
|
|
// Regression test for https://github.com/microsoft/typescript-go/issues/3526.
|
|
//
|
|
// Resolving a node_modules import with a trailing slash (e.g. `pkg/`) must
|
|
// produce the same result as without one.
|
|
func TestResolveModuleNameTrailingSlash(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := vfstest.FromMap(map[string]string{
|
|
"/repo/node_modules/pkg/package.json": `{"name":"pkg","main":"main.js","types":"main.d.ts"}`,
|
|
"/repo/node_modules/pkg/main.d.ts": "export const x: number;",
|
|
"/repo/node_modules/pkg/main.js": "exports.x = 1;",
|
|
"/repo/src/file.ts": "",
|
|
}, true)
|
|
host := &resolutionHostStub{fs: fs, cwd: "/repo"}
|
|
opts := &core.CompilerOptions{
|
|
ModuleResolution: core.ModuleResolutionKindBundler,
|
|
Module: core.ModuleKindESNext,
|
|
Target: core.ScriptTargetESNext,
|
|
}
|
|
resolver := module.NewResolver(host, opts, "", "")
|
|
|
|
for _, name := range []string{"pkg", "pkg/"} {
|
|
r, _ := resolver.ResolveModuleName(name, "/repo/src/file.ts", core.ModuleKindESNext, nil)
|
|
if !r.IsResolved() {
|
|
t.Errorf("%q failed to resolve", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// blockingFS wraps a vfs.FS and forces FileExists calls for `targetPath` to
|
|
// block on `gate` until released. Each caller sends on `arrived` when it
|
|
// reaches the gate. This is used to deterministically reproduce the
|
|
// `package.json` info-cache insert race described in
|
|
// https://github.com/microsoft/typescript-go/issues/3526.
|
|
type blockingFS struct {
|
|
vfs.FS
|
|
targetPath string
|
|
gate chan struct{}
|
|
arrived chan struct{} // each blocked goroutine sends one value
|
|
}
|
|
|
|
// waitForSignal waits for a synchronization point in these race regression
|
|
// tests and converts deadlocks into deterministic test failures.
|
|
func waitForSignal(t *testing.T, ch <-chan struct{}, description string) {
|
|
t.Helper()
|
|
select {
|
|
case <-ch:
|
|
return
|
|
case <-t.Context().Done():
|
|
t.Fatalf("timed out waiting for %s", description)
|
|
}
|
|
}
|
|
|
|
func (f *blockingFS) FileExists(path string) bool {
|
|
if path == f.targetPath {
|
|
f.arrived <- struct{}{}
|
|
<-f.gate
|
|
}
|
|
return f.FS.FileExists(path)
|
|
}
|
|
|
|
// flipFileExistsFS wraps a vfs.FS and returns false for the first
|
|
// FileExists call to `targetPath`, then true for the second. Both calls
|
|
// signal arrival via channel then block until released via their respective
|
|
// gate channels. ReadFile for the target path also signals arrival then
|
|
// blocks, so the "file doesn't exist" Set completes before the "file exists"
|
|
// Set (reproducing the LoadOrStore race).
|
|
type flipFileExistsFS struct {
|
|
vfs.FS
|
|
targetPath string
|
|
callCount atomic.Int32
|
|
firstArrived chan struct{} // closed when the first FileExists caller arrives
|
|
secondArrived chan struct{} // closed when the second FileExists caller arrives
|
|
firstGate chan struct{}
|
|
secondGate chan struct{}
|
|
readArrived chan struct{} // closed when ReadFile caller arrives
|
|
readGate chan struct{}
|
|
}
|
|
|
|
func (f *flipFileExistsFS) FileExists(path string) bool {
|
|
if path == f.targetPath {
|
|
n := f.callCount.Add(1)
|
|
if n == 1 {
|
|
close(f.firstArrived)
|
|
<-f.firstGate
|
|
return false // first caller: simulate "file not yet visible"
|
|
}
|
|
if n == 2 {
|
|
close(f.secondArrived)
|
|
<-f.secondGate
|
|
return f.FS.FileExists(path) // second caller: file is visible
|
|
}
|
|
}
|
|
return f.FS.FileExists(path)
|
|
}
|
|
|
|
func (f *flipFileExistsFS) ReadFile(path string) (string, bool) {
|
|
if path == f.targetPath {
|
|
close(f.readArrived)
|
|
<-f.readGate
|
|
}
|
|
return f.FS.ReadFile(path)
|
|
}
|
|
|
|
// Regression test for https://github.com/microsoft/typescript-go/issues/3526.
|
|
//
|
|
// Two goroutines resolve the same package via specifiers that differ only by
|
|
// a trailing slash (`pkg` and `pkg/`). A blocking FS holds both at the
|
|
// `FileExists` check for `package.json` — *after* each has confirmed a
|
|
// `package.json` info-cache miss but *before* either has called `Set`. When
|
|
// released, both proceed to `LoadOrStore` and one of them loses. Without the
|
|
// fix, the loser receives the winner's `InfoCacheEntry` whose
|
|
// `PackageDirectory` doesn't match its own `candidate` (because one spelling
|
|
// has a trailing slash and the other doesn't), and
|
|
// `loadNodeModuleFromDirectoryWorker`'s `ComparePaths` check skips loading
|
|
// the package's `main`/`types`. With no `index.*` present, resolution falls
|
|
// through to "unresolved" — the phantom TS2307 the issue describes. This
|
|
// test deterministically fails when the fix is reverted.
|
|
func TestResolveModuleNameTrailingSlashRace(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const pkgJSONPath = "/repo/node_modules/pkg/package.json"
|
|
files := map[string]string{
|
|
// `types` points at a file that is not discoverable through any
|
|
// fallback path: there is no `index.*` and no `main`. The only way
|
|
// to resolve `pkg` (or `pkg/`) is via the package.json `types` field
|
|
// inside `loadNodeModuleFromDirectoryWorker`, which is exactly the
|
|
// step that the bug skips when `candidate` and
|
|
// `packageInfo.PackageDirectory` mismatch.
|
|
pkgJSONPath: `{"name":"pkg","types":"./typings/index.d.ts"}`,
|
|
"/repo/node_modules/pkg/typings/index.d.ts": "export const x: number;",
|
|
// Distinct containing files so each `ResolveModuleName` call has a
|
|
// unique module-resolution-cache key.
|
|
"/repo/src/a/file.ts": "",
|
|
"/repo/src/b/file.ts": "",
|
|
}
|
|
fs := &blockingFS{
|
|
FS: vfstest.FromMap(files, true),
|
|
targetPath: pkgJSONPath,
|
|
gate: make(chan struct{}),
|
|
arrived: make(chan struct{}, 2),
|
|
}
|
|
host := &resolutionHostStub{fs: fs, cwd: "/repo"}
|
|
opts := &core.CompilerOptions{
|
|
ModuleResolution: core.ModuleResolutionKindBundler,
|
|
Module: core.ModuleKindESNext,
|
|
Target: core.ScriptTargetESNext,
|
|
}
|
|
resolver := module.NewResolver(host, opts, "", "")
|
|
|
|
type resolutionResult struct {
|
|
name string
|
|
resolved bool
|
|
}
|
|
results := make(chan resolutionResult, 2)
|
|
var wg sync.WaitGroup
|
|
for _, name := range []string{"pkg", "pkg/"} {
|
|
containingFile := "/repo/src/a/file.ts"
|
|
if strings.HasSuffix(name, "/") {
|
|
containingFile = "/repo/src/b/file.ts"
|
|
}
|
|
wg.Go(func() {
|
|
r, _ := resolver.ResolveModuleName(name, containingFile, core.ModuleKindESNext, nil)
|
|
results <- resolutionResult{name, r.IsResolved()}
|
|
})
|
|
}
|
|
|
|
// Wait for both goroutines to reach the FileExists gate, guaranteeing
|
|
// both have observed a package.json info-cache miss.
|
|
waitForSignal(t, fs.arrived, "first FileExists gate arrival")
|
|
waitForSignal(t, fs.arrived, "second FileExists gate arrival")
|
|
close(fs.gate)
|
|
|
|
wg.Wait()
|
|
close(results)
|
|
for r := range results {
|
|
if !r.resolved {
|
|
t.Errorf("%q failed to resolve", r.name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Regression test for https://github.com/microsoft/typescript-go/issues/1290.
|
|
//
|
|
// Two goroutines resolve `pkg/sub` concurrently. Both miss the package.json
|
|
// info-cache for the root package directory. A `flipFileExistsFS` forces the
|
|
// first goroutine's `FileExists` to return false (simulating the file not yet
|
|
// being visible), so it stores a nil-Contents cache entry. The second
|
|
// goroutine's `FileExists` returns true, but its `Set` call (`LoadOrStore`)
|
|
// returns the first goroutine's nil-Contents entry. Without the `Exists()`
|
|
// guard on the `typesVersions` lookup, `packageInfo.Contents.GetVersionPaths`
|
|
// dereferences nil and panics. With the guard the nil-Contents entry is safely
|
|
// skipped.
|
|
func TestResolveSubpathNilContentsRace(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const rootPkgJSON = "/repo/node_modules/pkg/package.json"
|
|
files := map[string]string{
|
|
rootPkgJSON: `{"name":"pkg","version":"1.0.0"}`,
|
|
"/repo/node_modules/pkg/sub/index.d.ts": "export declare const sub: number;",
|
|
"/repo/node_modules/pkg/sub/index.js": "exports.sub = 1;",
|
|
"/repo/src/a/file.ts": "",
|
|
"/repo/src/b/file.ts": "",
|
|
}
|
|
fs := &flipFileExistsFS{
|
|
FS: vfstest.FromMap(files, true),
|
|
targetPath: rootPkgJSON,
|
|
firstArrived: make(chan struct{}),
|
|
secondArrived: make(chan struct{}),
|
|
firstGate: make(chan struct{}),
|
|
secondGate: make(chan struct{}),
|
|
readArrived: make(chan struct{}),
|
|
readGate: make(chan struct{}),
|
|
}
|
|
host := &resolutionHostStub{fs: fs, cwd: "/repo"}
|
|
opts := &core.CompilerOptions{
|
|
ModuleResolution: core.ModuleResolutionKindBundler,
|
|
Module: core.ModuleKindESNext,
|
|
Target: core.ScriptTargetESNext,
|
|
}
|
|
resolver := module.NewResolver(host, opts, "", "")
|
|
|
|
var panicked atomic.Bool
|
|
type resolutionResult struct {
|
|
containingFile string
|
|
resolved bool
|
|
}
|
|
results := make(chan resolutionResult, 2)
|
|
var wg sync.WaitGroup
|
|
// Two goroutines both resolve "pkg/sub". Each calls getPackageJsonInfo
|
|
// for the root package directory, reaching FileExists for rootPkgJSON.
|
|
for _, containingFile := range []string{"/repo/src/a/file.ts", "/repo/src/b/file.ts"} {
|
|
wg.Go(func() {
|
|
resolved := false
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
panicked.Store(true)
|
|
}
|
|
results <- resolutionResult{containingFile: containingFile, resolved: resolved}
|
|
}()
|
|
r, _ := resolver.ResolveModuleName("pkg/sub", containingFile, core.ModuleKindESNext, nil)
|
|
resolved = r.IsResolved()
|
|
})
|
|
}
|
|
|
|
// Phase 1: Wait for both goroutines to reach FileExists for the root
|
|
// package.json, guaranteeing both have observed a cache miss.
|
|
waitForSignal(t, fs.firstArrived, "first root package.json FileExists arrival")
|
|
waitForSignal(t, fs.secondArrived, "second root package.json FileExists arrival")
|
|
|
|
// Phase 2: Release the first FileExists caller (returns false).
|
|
// It enters the "file not found" branch and stores a nil-Contents entry
|
|
// via Set — this is nearly instant (no ReadFile).
|
|
close(fs.firstGate)
|
|
|
|
// Phase 3: Release the second FileExists caller (returns true).
|
|
// It proceeds to ReadFile, which we gate separately to ensure the first
|
|
// goroutine's nil-Contents Set has completed.
|
|
close(fs.secondGate)
|
|
|
|
// Phase 4: Wait for the second goroutine to reach ReadFile, then release.
|
|
// By this point the first goroutine has stored its nil-Contents entry.
|
|
// The second goroutine's Set (LoadOrStore) will return that stale entry.
|
|
waitForSignal(t, fs.readArrived, "root package.json ReadFile arrival")
|
|
close(fs.readGate)
|
|
|
|
wg.Wait()
|
|
close(results)
|
|
if panicked.Load() {
|
|
t.Fatal("resolver panicked due to nil Contents dereference in loadModuleFromSpecificNodeModulesDirectory")
|
|
}
|
|
for r := range results {
|
|
if !r.resolved {
|
|
t.Fatalf("%q failed to resolve pkg/sub", r.containingFile)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseNodeModuleFromPath(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
isFolder bool
|
|
want string
|
|
}{
|
|
{"file in package", "/a/node_modules/b/lib/index.d.ts", false, "/a/node_modules/b"},
|
|
{"file in scoped package", "/a/node_modules/@scope/b/lib/index.d.ts", false, "/a/node_modules/@scope/b"},
|
|
{"folder subpath", "/a/node_modules/b/lib/File", true, "/a/node_modules/b"},
|
|
{"folder subpath scoped", "/a/node_modules/@scope/b/lib/File", true, "/a/node_modules/@scope/b"},
|
|
{"package root folder", "/a/node_modules/b", true, "/a/node_modules/b"},
|
|
{"scoped package root folder", "/a/node_modules/@scope/b", true, "/a/node_modules/@scope/b"},
|
|
// A bare scope directory has no package name; must not panic (https://github.com/microsoft/typescript-go/issues/4373).
|
|
{"scope-only folder", "/a/node_modules/@scope", true, "/a/node_modules/@scope"},
|
|
{"types scope-only folder", "/a/node_modules/@types", true, "/a/node_modules/@types"},
|
|
{"not in node_modules", "/a/src/index.ts", false, ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
if got := module.ParseNodeModuleFromPath(tt.path, tt.isFolder); got != tt.want {
|
|
t.Errorf("ParseNodeModuleFromPath(%q, %v) = %q, want %q", tt.path, tt.isFolder, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Regression test for https://github.com/microsoft/typescript-go/issues/4478.
|
|
//
|
|
// While resolving a package with peerDependencies, two goroutines look up the
|
|
// peer package's package.json concurrently. A `flipFileExistsFS` forces the
|
|
// first lookup to cache a nil-Contents entry and the second lookup to receive
|
|
// that stale entry from `Set`. The resolver must not dereference the peer
|
|
// package.json contents unless the entry actually exists.
|
|
func TestResolvePeerDependencyNilContentsRace(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const peerPkgJSON = "/repo/node_modules/peer/package.json"
|
|
files := map[string]string{
|
|
"/repo/node_modules/pkg/package.json": `{"name":"pkg","version":"1.0.0","types":"index.d.ts","peerDependencies":{"peer":"*"}}`,
|
|
"/repo/node_modules/pkg/index.d.ts": "export declare const x: number;",
|
|
peerPkgJSON: `{"name":"peer","version":"2.0.0"}`,
|
|
"/repo/src/a/file.ts": "",
|
|
"/repo/src/b/file.ts": "",
|
|
}
|
|
fs := &flipFileExistsFS{
|
|
FS: vfstest.FromMap(files, true),
|
|
targetPath: peerPkgJSON,
|
|
firstArrived: make(chan struct{}),
|
|
secondArrived: make(chan struct{}),
|
|
firstGate: make(chan struct{}),
|
|
secondGate: make(chan struct{}),
|
|
readArrived: make(chan struct{}),
|
|
readGate: make(chan struct{}),
|
|
}
|
|
host := &resolutionHostStub{fs: fs, cwd: "/repo"}
|
|
opts := &core.CompilerOptions{
|
|
ModuleResolution: core.ModuleResolutionKindBundler,
|
|
Module: core.ModuleKindESNext,
|
|
Target: core.ScriptTargetESNext,
|
|
}
|
|
resolver := module.NewResolver(host, opts, "", "")
|
|
|
|
var panicked atomic.Bool
|
|
type resolutionResult struct {
|
|
containingFile string
|
|
resolved bool
|
|
}
|
|
results := make(chan resolutionResult, 2)
|
|
var wg sync.WaitGroup
|
|
for _, containingFile := range []string{"/repo/src/a/file.ts", "/repo/src/b/file.ts"} {
|
|
wg.Go(func() {
|
|
resolved := false
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
panicked.Store(true)
|
|
}
|
|
results <- resolutionResult{containingFile: containingFile, resolved: resolved}
|
|
}()
|
|
r, _ := resolver.ResolveModuleName("pkg", containingFile, core.ModuleKindESNext, nil)
|
|
resolved = r.IsResolved()
|
|
})
|
|
}
|
|
|
|
waitForSignal(t, fs.firstArrived, "first peer package.json FileExists arrival")
|
|
waitForSignal(t, fs.secondArrived, "second peer package.json FileExists arrival")
|
|
close(fs.firstGate)
|
|
var firstResult resolutionResult
|
|
select {
|
|
case result := <-results:
|
|
firstResult = result
|
|
case <-t.Context().Done():
|
|
t.Fatal("timed out waiting for first peer package.json lookup to finish")
|
|
}
|
|
close(fs.secondGate)
|
|
waitForSignal(t, fs.readArrived, "peer package.json ReadFile arrival")
|
|
close(fs.readGate)
|
|
|
|
wg.Wait()
|
|
close(results)
|
|
if panicked.Load() {
|
|
t.Fatal("resolver panicked due to nil Contents dereference in readPackageJsonPeerDependencies")
|
|
}
|
|
if !firstResult.resolved {
|
|
t.Fatalf("%q failed to resolve pkg", firstResult.containingFile)
|
|
}
|
|
for r := range results {
|
|
if !r.resolved {
|
|
t.Fatalf("%q failed to resolve pkg", r.containingFile)
|
|
}
|
|
}
|
|
}
|