diff --git a/tsc/internal/api/session_apistate_test.go b/tsc/internal/api/session_apistate_test.go index 05407b492dd9d..62ea15d174083 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" @@ -535,3 +536,128 @@ 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{ + cycle: `{ "extends": "./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/p1/main.ts": `export const b = 1;`, + "/home/projects/p2/main.ts": `export const c = 1;`, + }) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + + for i, snapshot := range openProjectsInConcurrentSnapshots(t, session, outers) { + assert.DeepEqual(t, configFileParsingDiagnosticCodes(t, session, snapshot, outers[i]), []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/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() } diff --git a/tsc/internal/project/ownercache.go b/tsc/internal/project/ownercache.go index 801233d748b03..655f20346f328 100644 --- a/tsc/internal/project/ownercache.go +++ b/tsc/internal/project/ownercache.go @@ -36,13 +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) - defer entry.mu.Unlock() - if !loaded || c.isExpired != nil && c.isExpired(identity, entry.value, loadArgs) { - entry.value = c.parse(identity, 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) + defer entry.mu.Unlock() + 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) { @@ -70,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