From 2e65f6f022c63570d818e18508fa0879a19134ad Mon Sep 17 00:00:00 2001 From: Christopher Plieger <917744+cplieger@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:02:21 +0100 Subject: [PATCH] fix: own the configs a cached extended config extends A cache hit in GetExtendedConfig now adds the snapshot as an owner of every config the entry extends (TryAddOwner), skipping the configs on the resolution stack. When one has no live entry, it parses the config again through ParseAndAcquire, which stores the result in the cache entry with the snapshot as an owner unless another load stored a live, unexpired entry during the parse. Tested with go vet and go test -race on internal/project and internal/api, npx hereby lint, npx hereby check:format and npx hereby test, all passing. Both new tests panic on main with OwnerCache.AddOwner: entry not found and pass here; without the store, the second outer open loses strict. --- tsc/internal/api/session_apistate_test.go | 90 +++++++++++++++++++ .../project/configfileregistrybuilder.go | 12 ++- tsc/internal/project/ownercache.go | 29 +++++- 3 files changed, 127 insertions(+), 4 deletions(-) diff --git a/tsc/internal/api/session_apistate_test.go b/tsc/internal/api/session_apistate_test.go index 3ec3a729fd1d2..4b5ba1b295811 100644 --- a/tsc/internal/api/session_apistate_test.go +++ b/tsc/internal/api/session_apistate_test.go @@ -571,3 +571,93 @@ func TestGetCurrentLanguageServerSnapshotCloseProjectRetainsProjectForOpenFile(t assert.NilError(t, err) assert.Assert(t, projectSession.Snapshot().ProjectCollection.ConfiguredProject(tspath.PathKeyFromCanonical(configFileName)) != nil) } + +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 configFileParsingDiagnosticCodes(t *testing.T, session *Session, snapshot SnapshotID, configFileName string) []int32 { + t.Helper() + configuredProject := session.snapshots[snapshot].snapshot.ProjectCollection.GetProject(configuredProjectID(configFileName)) + assert.Assert(t, configuredProject != nil) + diagnostics, err := session.handleGetConfigFileParsingDiagnostics(context.Background(), &GetProjectDiagnosticsParams{ + Snapshot: snapshot, + Project: configuredProject.Handle(), + }) + 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", "compilerOptions": { "strict": true } }`, + "/home/projects/cycle/a.json": `{ "extends": "./tsconfig.json", "compilerOptions": { "noUnusedLocals": true } }`, + 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) + for range 2 { + snapshot := openProjectInNewSnapshot(t, session, outer) + assert.DeepEqual(t, configFileParsingDiagnosticCodes(t, session, snapshot, outer), []int32{18000}) + options := session.snapshots[snapshot].snapshot.ProjectCollection.GetProject(configuredProjectID(outer)).CommandLine.CompilerOptions() + assert.Equal(t, options.Strict, core.TSTrue) + assert.Equal(t, options.NoUnusedLocals, core.TSTrue) + } +} + +func TestReopenProjectAfterExtendsChangeUsesNewExtendedConfig(t *testing.T) { + t.Parallel() + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + + const first = "/home/projects/p1/tsconfig.json" + const second = "/home/projects/p2/tsconfig.json" + const shared = "/home/projects/shared/a.json" + projectSession, utils := projecttestutil.Setup(map[string]any{ + first: `{ "extends": "../shared/a.json" }`, + second: `{ "extends": "../shared/a.json" }`, + shared: `{ "extends": "./b.json" }`, + "/home/projects/shared/b.json": `{ "compilerOptions": { "strict": true } }`, + "/home/projects/shared/c.json": `{ "compilerOptions": { "strict": false } }`, + "/home/projects/p1/a.ts": `export const a = 1;`, + "/home/projects/p2/b.ts": `export const b = 1;`, + }) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + + openProjectInNewSnapshot(t, session, first) + assert.NilError(t, utils.FS().WriteFile(shared, `{ "extends": "./c.json" }`)) + changed, err := session.handleCreateSnapshot(context.Background(), &CreateSnapshotParams{ + OpenProjects: []DocumentIdentifier{{FileName: second}}, + FileNotifications: &FileNotifications{Changed: []DocumentIdentifier{{FileName: shared}}}, + }) + assert.NilError(t, err) + _, err = session.handleRelease(context.Background(), &ReleaseParams{Snapshot: changed.Snapshot}) + assert.NilError(t, err) + snapshot := openProjectInNewSnapshot(t, session, second) + options := session.snapshots[snapshot].snapshot.ProjectCollection.GetProject(configuredProjectID(second)).CommandLine.CompilerOptions() + assert.Equal(t, options.Strict, core.TSFalse) +} diff --git a/tsc/internal/project/configfileregistrybuilder.go b/tsc/internal/project/configfileregistrybuilder.go index 9233ad4d48786..dd77835cd5a40 100644 --- a/tsc/internal/project/configfileregistrybuilder.go +++ b/tsc/internal/project/configfileregistrybuilder.go @@ -822,14 +822,22 @@ func (c *configFileRegistryBuilder) GetExtendedConfig(fileName tspath.RootedFile content = fh.Content() } - return c.extendedConfigCache.LoadAndAcquire(path, c.snapshotID, ExtendedConfigParseArgs{ + args := ExtendedConfigParseArgs{ FileName: fileName, Content: content, FS: c.fs.source, ResolutionStack: resolutionStack, ConfigFS: fs, Cache: c, - }).ExtendedConfigCacheEntry + } + entry := c.extendedConfigCache.LoadAndAcquire(path, c.snapshotID, args) + for _, extendedFileName := range entry.ExtendedFileNames() { + extendedPath := fs.CaseSensitivity().PathKey(extendedFileName.AsPath()) + if !slices.Contains(resolutionStack, extendedPath) && !c.extendedConfigCache.TryAddOwner(extendedPath, c.snapshotID) { + return c.extendedConfigCache.ParseAndAcquire(path, c.snapshotID, args, entry).ExtendedConfigCacheEntry + } + } + return entry.ExtendedConfigCacheEntry } func (c *configFileRegistryBuilder) Cleanup() { diff --git a/tsc/internal/project/ownercache.go b/tsc/internal/project/ownercache.go index 801233d748b03..53dbf9a2fc68f 100644 --- a/tsc/internal/project/ownercache.go +++ b/tsc/internal/project/ownercache.go @@ -18,14 +18,14 @@ type ownerCacheEntry[V any] struct { // be Acquired multiple times during config parsing while only appearing once in // the ParsedCommandLine's list of extended files. When updating this code, check // if the same changes should be made to RefCountCache as well. -type OwnerCache[K comparable, V any, LoadArgs any] struct { +type OwnerCache[K comparable, V comparable, LoadArgs any] struct { entries collections.SyncMap[K, *ownerCacheEntry[V]] isExpired func(K, V, LoadArgs) bool parse func(K, LoadArgs) V } -func NewOwnerCache[K comparable, V any, LoadArgs any]( +func NewOwnerCache[K comparable, V comparable, LoadArgs any]( parse func(K, LoadArgs) V, isExpired func(K, V, LoadArgs) bool, ) *OwnerCache[K, V, LoadArgs] { @@ -70,6 +70,31 @@ 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]) ParseAndAcquire(identity K, owner uint64, loadArgs LoadArgs, stale V) V { + value := c.parse(identity, loadArgs) + entry, loaded := c.loadOrStoreLockedEntry(identity) + defer entry.mu.Unlock() + if !loaded || entry.value == stale || c.isExpired != nil && c.isExpired(identity, entry.value, loadArgs) { + entry.value = value + } + entry.owners[owner] = struct{}{} + return entry.value +} + func (c *OwnerCache[K, V, LoadArgs]) Has(identity K) bool { _, ok := c.entries.Load(identity) return ok