From 780cee18880d20aca597133f4b71606472e2caac Mon Sep 17 00:00:00 2001 From: Christopher Plieger <917744+cplieger@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:11:31 +0200 Subject: [PATCH 1/4] fix: reparse an extended config cached under another resolution stack --- tsc/internal/api/session_apistate_test.go | 39 +++++++++++++++++++++ tsc/internal/project/extendedconfigcache.go | 5 +++ 2 files changed, 44 insertions(+) diff --git a/tsc/internal/api/session_apistate_test.go b/tsc/internal/api/session_apistate_test.go index 05407b492dd9d..6456ada66c50f 100644 --- a/tsc/internal/api/session_apistate_test.go +++ b/tsc/internal/api/session_apistate_test.go @@ -535,3 +535,42 @@ func TestOpenProjectsWithCircularExtendsReportCircularity(t *testing.T) { } assert.DeepEqual(t, codes, []int32{18000}) } + +func TestOpenProjectsExtendingIntoCircularExtendsReportCircularity(t *testing.T) { + t.Parallel() + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + + const cycle = "/home/projects/cycle/tsconfig.json" + const outer = "/home/projects/outer/tsconfig.json" + projectSession, _ := projecttestutil.Setup(map[string]any{ + cycle: `{ "extends": "./a.json" }`, + "/home/projects/cycle/a.json": `{ "extends": "./tsconfig.json" }`, + outer: `{ "extends": "../cycle/a.json" }`, + "/home/projects/cycle/main.ts": `export const a = 1;`, + "/home/projects/outer/main.ts": `export const b = 1;`, + }) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + + _, err := session.handleCreateSnapshot(context.Background(), &CreateSnapshotParams{ + OpenProjects: []DocumentIdentifier{{FileName: cycle}}, + }) + assert.NilError(t, err) + response, err := session.handleCreateSnapshot(context.Background(), &CreateSnapshotParams{ + OpenProjects: []DocumentIdentifier{{FileName: outer}}, + }) + assert.NilError(t, err) + diagnostics, err := session.handleGetConfigFileParsingDiagnostics(context.Background(), &GetProjectDiagnosticsParams{ + Snapshot: response.Snapshot, + Project: configuredProjectID(outer), + }) + assert.NilError(t, err) + codes := make([]int32, 0, len(diagnostics)) + for _, diagnostic := range diagnostics { + codes = append(codes, diagnostic.Code) + } + assert.DeepEqual(t, codes, []int32{18000}) +} diff --git a/tsc/internal/project/extendedconfigcache.go b/tsc/internal/project/extendedconfigcache.go index 6318fe93bbb83..8dcee29f87920 100644 --- a/tsc/internal/project/extendedconfigcache.go +++ b/tsc/internal/project/extendedconfigcache.go @@ -1,6 +1,8 @@ package project import ( + "slices" + "github.com/microsoft/TypeScript/tsc/internal/tsoptions" "github.com/microsoft/TypeScript/tsc/internal/tspath" "github.com/microsoft/TypeScript/tsc/internal/vfs" @@ -47,6 +49,9 @@ func hash(entry *tsoptions.ExtendedConfigCacheEntry, args ExtendedConfigParseArg return xxh3.Uint128{} } _, _ = hasher.WriteString(fh.Content()) + if slices.Contains(args.ResolutionStack, args.ConfigFS.CaseSensitivity().PathKey(fileName.AsPath())) { + _, _ = hasher.WriteString(fileName.AsString()) + } } return hasher.Sum128() } From 39b2373280f59c732513fbd7ffbb3f080e12dc18 Mon Sep 17 00:00:00 2001 From: Christopher Plieger <917744+cplieger@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:03:58 +0200 Subject: [PATCH 2/4] fix: reparse an expired owner cache entry without holding its lock --- tsc/internal/api/session_apistate_test.go | 48 +++++++++++++++-------- tsc/internal/project/ownercache.go | 8 +++- 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/tsc/internal/api/session_apistate_test.go b/tsc/internal/api/session_apistate_test.go index 6456ada66c50f..26d9d1fbb695d 100644 --- a/tsc/internal/api/session_apistate_test.go +++ b/tsc/internal/api/session_apistate_test.go @@ -2,6 +2,7 @@ package api import ( "context" + "sync" "testing" "github.com/microsoft/TypeScript/tsc/internal/bundled" @@ -543,13 +544,16 @@ func TestOpenProjectsExtendingIntoCircularExtendsReportCircularity(t *testing.T) } const cycle = "/home/projects/cycle/tsconfig.json" - const outer = "/home/projects/outer/tsconfig.json" + outers := []string{"/home/projects/p1/tsconfig.json", "/home/projects/p2/tsconfig.json"} projectSession, _ := projecttestutil.Setup(map[string]any{ cycle: `{ "extends": "./a.json" }`, - "/home/projects/cycle/a.json": `{ "extends": "./tsconfig.json" }`, - outer: `{ "extends": "../cycle/a.json" }`, + "/home/projects/cycle/a.json": `{ "extends": "./b.json" }`, + "/home/projects/cycle/b.json": `{ "extends": "./tsconfig.json" }`, + outers[0]: `{ "extends": "../cycle/a.json" }`, + outers[1]: `{ "extends": "../cycle/b.json" }`, "/home/projects/cycle/main.ts": `export const a = 1;`, - "/home/projects/outer/main.ts": `export const b = 1;`, + "/home/projects/p1/main.ts": `export const b = 1;`, + "/home/projects/p2/main.ts": `export const c = 1;`, }) defer projectSession.Close() session := NewLSPSession(projectSession, nil) @@ -559,18 +563,28 @@ func TestOpenProjectsExtendingIntoCircularExtendsReportCircularity(t *testing.T) OpenProjects: []DocumentIdentifier{{FileName: cycle}}, }) assert.NilError(t, err) - response, err := session.handleCreateSnapshot(context.Background(), &CreateSnapshotParams{ - OpenProjects: []DocumentIdentifier{{FileName: outer}}, - }) - assert.NilError(t, err) - diagnostics, err := session.handleGetConfigFileParsingDiagnostics(context.Background(), &GetProjectDiagnosticsParams{ - Snapshot: response.Snapshot, - Project: configuredProjectID(outer), - }) - assert.NilError(t, err) - codes := make([]int32, 0, len(diagnostics)) - for _, diagnostic := range diagnostics { - codes = append(codes, diagnostic.Code) + responses := make([]*CreateSnapshotResponse, len(outers)) + errs := make([]error, len(outers)) + var wg sync.WaitGroup + for i, outer := range outers { + wg.Go(func() { + responses[i], errs[i] = session.handleCreateSnapshot(context.Background(), &CreateSnapshotParams{ + OpenProjects: []DocumentIdentifier{{FileName: outer}}, + }) + }) + } + wg.Wait() + for i, outer := range outers { + assert.NilError(t, errs[i]) + diagnostics, err := session.handleGetConfigFileParsingDiagnostics(context.Background(), &GetProjectDiagnosticsParams{ + Snapshot: responses[i].Snapshot, + Project: configuredProjectID(outer), + }) + assert.NilError(t, err) + codes := make([]int32, 0, len(diagnostics)) + for _, diagnostic := range diagnostics { + codes = append(codes, diagnostic.Code) + } + assert.DeepEqual(t, codes, []int32{18000}) } - assert.DeepEqual(t, codes, []int32{18000}) } diff --git a/tsc/internal/project/ownercache.go b/tsc/internal/project/ownercache.go index 801233d748b03..5aaa33c2426dd 100644 --- a/tsc/internal/project/ownercache.go +++ b/tsc/internal/project/ownercache.go @@ -37,8 +37,14 @@ func NewOwnerCache[K comparable, V any, LoadArgs any]( func (c *OwnerCache[K, V, LoadArgs]) LoadAndAcquire(identity K, owner uint64, loadArgs LoadArgs) V { entry, loaded := c.loadOrStoreLockedEntry(identity) + if loaded && c.isExpired != nil && c.isExpired(identity, entry.value, loadArgs) { + entry.mu.Unlock() + value := c.parse(identity, loadArgs) + entry, _ = c.loadOrStoreLockedEntry(identity) + entry.value = value + } defer entry.mu.Unlock() - if !loaded || c.isExpired != nil && c.isExpired(identity, entry.value, loadArgs) { + if !loaded { entry.value = c.parse(identity, loadArgs) } entry.owners[owner] = struct{}{} From d1f795a3dcc97c06c00cdaf31db76584be27637e Mon Sep 17 00:00:00 2001 From: Christopher Plieger <917744+cplieger@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:21:59 +0200 Subject: [PATCH 3/4] fix: acquire a cache hit's extended configs, parse without the lock --- tsc/internal/api/session_apistate_test.go | 125 ++++++++++++++---- .../project/configfileregistrybuilder.go | 9 +- tsc/internal/project/ownercache.go | 35 +++-- tsc/internal/tsoptions/tsconfigparsing.go | 4 +- 4 files changed, 134 insertions(+), 39 deletions(-) diff --git a/tsc/internal/api/session_apistate_test.go b/tsc/internal/api/session_apistate_test.go index 26d9d1fbb695d..62ea15d174083 100644 --- a/tsc/internal/api/session_apistate_test.go +++ b/tsc/internal/api/session_apistate_test.go @@ -537,12 +537,80 @@ func TestOpenProjectsWithCircularExtendsReportCircularity(t *testing.T) { assert.DeepEqual(t, codes, []int32{18000}) } +func openProjectInNewSnapshot(t *testing.T, session *Session, configFileName string) SnapshotID { + t.Helper() + response, err := session.handleCreateSnapshot(context.Background(), &CreateSnapshotParams{ + OpenProjects: []DocumentIdentifier{{FileName: configFileName}}, + }) + assert.NilError(t, err) + return response.Snapshot +} + +func openProjectsInConcurrentSnapshots(t *testing.T, session *Session, configFileNames []string) []SnapshotID { + t.Helper() + responses := make([]*CreateSnapshotResponse, len(configFileNames)) + errs := make([]error, len(configFileNames)) + var wg sync.WaitGroup + for i, configFileName := range configFileNames { + wg.Go(func() { + responses[i], errs[i] = session.handleCreateSnapshot(context.Background(), &CreateSnapshotParams{ + OpenProjects: []DocumentIdentifier{{FileName: configFileName}}, + }) + }) + } + wg.Wait() + snapshots := make([]SnapshotID, len(configFileNames)) + for i := range configFileNames { + assert.NilError(t, errs[i]) + snapshots[i] = responses[i].Snapshot + } + return snapshots +} + +func configFileParsingDiagnosticCodes(t *testing.T, session *Session, snapshot SnapshotID, configFileName string) []int32 { + t.Helper() + diagnostics, err := session.handleGetConfigFileParsingDiagnostics(context.Background(), &GetProjectDiagnosticsParams{ + Snapshot: snapshot, + Project: configuredProjectID(configFileName), + }) + assert.NilError(t, err) + codes := make([]int32, 0, len(diagnostics)) + for _, diagnostic := range diagnostics { + codes = append(codes, diagnostic.Code) + } + return codes +} + func TestOpenProjectsExtendingIntoCircularExtendsReportCircularity(t *testing.T) { t.Parallel() if !bundled.Embedded { t.Skip("bundled files are not embedded") } + const cycle = "/home/projects/cycle/tsconfig.json" + const outer = "/home/projects/outer/tsconfig.json" + projectSession, _ := projecttestutil.Setup(map[string]any{ + cycle: `{ "extends": "./a.json" }`, + "/home/projects/cycle/a.json": `{ "extends": "./tsconfig.json" }`, + outer: `{ "extends": "../cycle/a.json" }`, + "/home/projects/cycle/main.ts": `export const a = 1;`, + "/home/projects/outer/main.ts": `export const b = 1;`, + }) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + + openProjectInNewSnapshot(t, session, cycle) + snapshot := openProjectInNewSnapshot(t, session, outer) + assert.DeepEqual(t, configFileParsingDiagnosticCodes(t, session, snapshot, outer), []int32{18000}) +} + +func TestConcurrentlyOpenProjectsExtendingIntoCircularExtendsReportCircularity(t *testing.T) { + t.Parallel() + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + const cycle = "/home/projects/cycle/tsconfig.json" outers := []string{"/home/projects/p1/tsconfig.json", "/home/projects/p2/tsconfig.json"} projectSession, _ := projecttestutil.Setup(map[string]any{ @@ -559,32 +627,37 @@ func TestOpenProjectsExtendingIntoCircularExtendsReportCircularity(t *testing.T) session := NewLSPSession(projectSession, nil) defer session.Close() - _, err := session.handleCreateSnapshot(context.Background(), &CreateSnapshotParams{ - OpenProjects: []DocumentIdentifier{{FileName: cycle}}, - }) - assert.NilError(t, err) - responses := make([]*CreateSnapshotResponse, len(outers)) - errs := make([]error, len(outers)) - var wg sync.WaitGroup - for i, outer := range outers { - wg.Go(func() { - responses[i], errs[i] = session.handleCreateSnapshot(context.Background(), &CreateSnapshotParams{ - OpenProjects: []DocumentIdentifier{{FileName: outer}}, - }) - }) + for i, snapshot := range openProjectsInConcurrentSnapshots(t, session, outers) { + assert.DeepEqual(t, configFileParsingDiagnosticCodes(t, session, snapshot, outers[i]), []int32{18000}) } - wg.Wait() - for i, outer := range outers { - assert.NilError(t, errs[i]) - diagnostics, err := session.handleGetConfigFileParsingDiagnostics(context.Background(), &GetProjectDiagnosticsParams{ - Snapshot: responses[i].Snapshot, - Project: configuredProjectID(outer), - }) - assert.NilError(t, err) - codes := make([]int32, 0, len(diagnostics)) - for _, diagnostic := range diagnostics { - codes = append(codes, diagnostic.Code) - } - assert.DeepEqual(t, codes, []int32{18000}) + openProjectInNewSnapshot(t, session, cycle) + for i, snapshot := range openProjectsInConcurrentSnapshots(t, session, outers) { + assert.DeepEqual(t, configFileParsingDiagnosticCodes(t, session, snapshot, outers[i]), []int32{18000}) } } + +func TestReopenProjectExtendingIntoCircularExtendsReportsCircularity(t *testing.T) { + t.Parallel() + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + + const cycle = "/home/projects/cycle/tsconfig.json" + const outer = "/home/projects/outer/tsconfig.json" + projectSession, _ := projecttestutil.Setup(map[string]any{ + cycle: `{ "extends": "./a.json" }`, + "/home/projects/cycle/a.json": `{ "extends": "./tsconfig.json" }`, + outer: `{ "extends": "../cycle/a.json" }`, + "/home/projects/cycle/main.ts": `export const a = 1;`, + "/home/projects/outer/main.ts": `export const b = 1;`, + }) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + + openProjectInNewSnapshot(t, session, cycle) + _, err := session.handleRelease(context.Background(), &ReleaseParams{Snapshot: openProjectInNewSnapshot(t, session, outer)}) + assert.NilError(t, err) + snapshot := openProjectInNewSnapshot(t, session, outer) + assert.DeepEqual(t, configFileParsingDiagnosticCodes(t, session, snapshot, outer), []int32{18000}) +} diff --git a/tsc/internal/project/configfileregistrybuilder.go b/tsc/internal/project/configfileregistrybuilder.go index df1bb93aa8a42..0e35f2631a0e1 100644 --- a/tsc/internal/project/configfileregistrybuilder.go +++ b/tsc/internal/project/configfileregistrybuilder.go @@ -796,7 +796,7 @@ func (c *configFileRegistryBuilder) GetExtendedConfig(fileName tspath.RootedFile content = fh.Content() } - return c.extendedConfigCache.LoadAndAcquire(path, c.snapshotID, ExtendedConfigParseArgs{ + entry := c.extendedConfigCache.LoadAndAcquire(path, c.snapshotID, ExtendedConfigParseArgs{ FileName: fileName, Content: content, FS: c.fs.source, @@ -804,6 +804,13 @@ func (c *configFileRegistryBuilder) GetExtendedConfig(fileName tspath.RootedFile ConfigFS: fs, Cache: c, }).ExtendedConfigCacheEntry + for _, extendedFileName := range entry.ExtendedFileNames() { + extendedPath := fs.CaseSensitivity().PathKey(extendedFileName.AsPath()) + if !slices.Contains(resolutionStack, extendedPath) && !c.extendedConfigCache.TryAddOwner(extendedPath, c.snapshotID) { + return tsoptions.ParseExtendedConfig(fileName, path, resolutionStack, fs, c) + } + } + return entry } func (c *configFileRegistryBuilder) Cleanup() { diff --git a/tsc/internal/project/ownercache.go b/tsc/internal/project/ownercache.go index 5aaa33c2426dd..655f20346f328 100644 --- a/tsc/internal/project/ownercache.go +++ b/tsc/internal/project/ownercache.go @@ -36,19 +36,22 @@ func NewOwnerCache[K comparable, V any, LoadArgs any]( } func (c *OwnerCache[K, V, LoadArgs]) LoadAndAcquire(identity K, owner uint64, loadArgs LoadArgs) V { - entry, loaded := c.loadOrStoreLockedEntry(identity) - if loaded && c.isExpired != nil && c.isExpired(identity, entry.value, loadArgs) { + if entry, ok := c.entries.Load(identity); ok { + entry.mu.Lock() + if len(entry.owners) > 0 && (c.isExpired == nil || !c.isExpired(identity, entry.value, loadArgs)) { + entry.owners[owner] = struct{}{} + value := entry.value + entry.mu.Unlock() + return value + } entry.mu.Unlock() - value := c.parse(identity, loadArgs) - entry, _ = c.loadOrStoreLockedEntry(identity) - entry.value = value } + value := c.parse(identity, loadArgs) + entry, _ := c.loadOrStoreLockedEntry(identity) defer entry.mu.Unlock() - if !loaded { - entry.value = c.parse(identity, loadArgs) - } + entry.value = value entry.owners[owner] = struct{}{} - return entry.value + return value } func (c *OwnerCache[K, V, LoadArgs]) Acquire(identity K, owner uint64, value V) { @@ -76,6 +79,20 @@ func (c *OwnerCache[K, V, LoadArgs]) AddOwner(identity K, owner uint64) { entry.owners[owner] = struct{}{} } +func (c *OwnerCache[K, V, LoadArgs]) TryAddOwner(identity K, owner uint64) bool { + entry, ok := c.entries.Load(identity) + if !ok { + return false + } + entry.mu.Lock() + defer entry.mu.Unlock() + if len(entry.owners) == 0 { + return false + } + entry.owners[owner] = struct{}{} + return true +} + func (c *OwnerCache[K, V, LoadArgs]) Has(identity K) bool { _, ok := c.entries.Load(identity) return ok diff --git a/tsc/internal/tsoptions/tsconfigparsing.go b/tsc/internal/tsoptions/tsconfigparsing.go index d08c8a9a9600b..fb7487c9dea4c 100644 --- a/tsc/internal/tsoptions/tsconfigparsing.go +++ b/tsc/internal/tsoptions/tsconfigparsing.go @@ -904,9 +904,7 @@ func getExtendedConfig( var cacheEntry *ExtendedConfigCacheEntry // Bypass the cache when we detect a cycle in the resolution stack. - // The cache locks entries during parsing, and a cycle would cause the same goroutine - // to re-lock the same entry, resulting in a deadlock. Let parseConfig handle the - // circularity error via its own resolution stack check. + // Let parseConfig handle the circularity error via its own resolution stack check. if extendedConfigCache != nil && !slices.Contains(resolutionStack, extendedConfigPath) { cacheEntry = extendedConfigCache.GetExtendedConfig(extendedConfigFileName, extendedConfigPath, resolutionStack, fs) } else { From 3eb305299ba903f29f3779fa38e55978bdded924 Mon Sep 17 00:00:00 2001 From: Christopher Plieger <917744+cplieger@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:23:40 +0200 Subject: [PATCH 4/4] fix: keep the cache bypass comment, still true for the CLI cache --- tsc/internal/tsoptions/tsconfigparsing.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tsc/internal/tsoptions/tsconfigparsing.go b/tsc/internal/tsoptions/tsconfigparsing.go index fb7487c9dea4c..d08c8a9a9600b 100644 --- a/tsc/internal/tsoptions/tsconfigparsing.go +++ b/tsc/internal/tsoptions/tsconfigparsing.go @@ -904,7 +904,9 @@ func getExtendedConfig( var cacheEntry *ExtendedConfigCacheEntry // Bypass the cache when we detect a cycle in the resolution stack. - // Let parseConfig handle the circularity error via its own resolution stack check. + // The cache locks entries during parsing, and a cycle would cause the same goroutine + // to re-lock the same entry, resulting in a deadlock. Let parseConfig handle the + // circularity error via its own resolution stack check. if extendedConfigCache != nil && !slices.Contains(resolutionStack, extendedConfigPath) { cacheEntry = extendedConfigCache.GetExtendedConfig(extendedConfigFileName, extendedConfigPath, resolutionStack, fs) } else {