Skip to content

Commit 186bc06

Browse files
Copilotjakebailey
andauthored
fix: prevent deadlock on tsconfig extends cycle by bypassing cache when cycle detected
When a tsconfig extends cycle is present (e.g., base.json extends itself), the ExtendedConfigCache would deadlock because the same goroutine tried to re-lock a mutex it already held. The fix checks the resolutionStack before entering the cache - if the file is already in the stack, it bypasses the cache and lets parseConfig handle the circularity error normally. Agent-Logs-Url: https://github.com/microsoft/typescript-go/sessions/c02ab152-7feb-4028-b517-64facd33ddf4 Co-authored-by: jakebailey <5341706+jakebailey@users.noreply.github.com>
1 parent e6e88fc commit 186bc06

2 files changed

Lines changed: 80 additions & 1 deletion

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package tsc_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/microsoft/typescript-go/internal/execute/tsc"
7+
"github.com/microsoft/typescript-go/internal/tsoptions"
8+
"github.com/microsoft/typescript-go/internal/vfs"
9+
"github.com/microsoft/typescript-go/internal/vfs/vfstest"
10+
)
11+
12+
type testParseConfigHost struct {
13+
fs vfs.FS
14+
cwd string
15+
}
16+
17+
func (h *testParseConfigHost) FS() vfs.FS { return h.fs }
18+
func (h *testParseConfigHost) GetCurrentDirectory() string { return h.cwd }
19+
20+
func TestExtendedConfigCacheExtendsCircularity(t *testing.T) {
21+
t.Parallel()
22+
23+
t.Run("self-referencing extends", func(t *testing.T) {
24+
t.Parallel()
25+
26+
// Regression test: a tsconfig extends cycle should produce an error,
27+
// not a deadlock when using the tsc ExtendedConfigCache.
28+
files := map[string]any{
29+
"/project/tsconfig.json": `{"extends": "./base.json"}`,
30+
"/project/base.json": `{"extends": "./base.json"}`,
31+
"/project/main.ts": `// Hello World!`,
32+
}
33+
34+
fs := vfstest.FromMap(files, false /*useCaseSensitiveFileNames*/)
35+
host := &testParseConfigHost{fs: fs, cwd: "/project"}
36+
cache := &tsc.ExtendedConfigCache{}
37+
38+
cmd, _ := tsoptions.GetParsedCommandLineOfConfigFile("/project/tsconfig.json", nil, nil, host, cache)
39+
if cmd == nil {
40+
t.Fatal("expected non-nil ParsedCommandLine")
41+
}
42+
assertHasCircularityDiagnostic(t, cmd)
43+
})
44+
45+
t.Run("mutual extends cycle", func(t *testing.T) {
46+
t.Parallel()
47+
48+
// Two config files that extend each other.
49+
files := map[string]any{
50+
"/project/tsconfig.json": `{"extends": "./other.json"}`,
51+
"/project/other.json": `{"extends": "./tsconfig.json"}`,
52+
"/project/main.ts": `// Hello World!`,
53+
}
54+
55+
fs := vfstest.FromMap(files, false /*useCaseSensitiveFileNames*/)
56+
host := &testParseConfigHost{fs: fs, cwd: "/project"}
57+
cache := &tsc.ExtendedConfigCache{}
58+
59+
cmd, _ := tsoptions.GetParsedCommandLineOfConfigFile("/project/tsconfig.json", nil, nil, host, cache)
60+
if cmd == nil {
61+
t.Fatal("expected non-nil ParsedCommandLine")
62+
}
63+
assertHasCircularityDiagnostic(t, cmd)
64+
})
65+
}
66+
67+
func assertHasCircularityDiagnostic(t *testing.T, cmd *tsoptions.ParsedCommandLine) {
68+
t.Helper()
69+
for _, d := range cmd.Errors {
70+
if d != nil && d.Code() == 18000 {
71+
return
72+
}
73+
}
74+
t.Error("expected circularity diagnostic (code 18000), but none was found")
75+
}

internal/tsoptions/tsconfigparsing.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -971,7 +971,11 @@ func getExtendedConfig(
971971
extendedConfigPath := tspath.ToPath(extendedConfigFileName, host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())
972972

973973
var cacheEntry *ExtendedConfigCacheEntry
974-
if extendedConfigCache != nil {
974+
// Bypass the cache when we detect a cycle in the resolution stack.
975+
// The cache locks entries during parsing, and a cycle would cause the same goroutine
976+
// to re-lock the same entry, resulting in a deadlock. Let parseConfig handle the
977+
// circularity error via its own resolution stack check.
978+
if extendedConfigCache != nil && !slices.Contains(resolutionStack, extendedConfigFileName) {
975979
cacheEntry = extendedConfigCache.GetExtendedConfig(extendedConfigFileName, extendedConfigPath, resolutionStack, host)
976980
} else {
977981
cacheEntry = ParseExtendedConfig(extendedConfigFileName, extendedConfigPath, resolutionStack, host, extendedConfigCache)

0 commit comments

Comments
 (0)