Repository navigation
Description
🔎 Search Terms
createSnapshot, openProjects, extends, circular extends, TS18000, Circularity detected while resolving configuration, ExtendedSourceFiles, extendedConfigCache, OwnerCache.AddOwner, entry not found, panic, dispose
🕗 Version & Regression Information
- This is the behavior in every version I tried:
typescript@7.1.0-dev.20261102.1(native binary@typescript/typescript-linux-x64@7.1.0-dev.20261102.1, built frommainat 6161772), a build ofmainat 622d4f7, andtypescript@7.1.0-dev.20261014.1, where I first saw it, with the synchronous API (typescript/sync) on linux-x64. - It follows [api] createSnapshot never returns when the opened tsconfig.json has a circular extends #64754. Since [api] Leave a config out of its own extended source files #64755, a project whose own
extendschain is circular opens and reports TS18000, but a second project that extends into the same cycle can panic.
⏯ Playground Link
The API is not in the playground, so the reproduction below runs it from Node.
💻 Code
package.json:
{ "private": true, "type": "module", "dependencies": { "typescript": "7.1.0-dev.20261102.1" } }repro.mjs writes cycle/tsconfig.json, which extends cycle/a.json, which extends cycle/tsconfig.json again, and outer/tsconfig.json, which extends cycle/a.json. It opens one project after the other in one API session, in both orders. Then it opens outer and cycle, disposes the first snapshot and opens outer again:
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { API } from "typescript/sync";
const dir = join(process.cwd(), "proj");
rmSync(dir, { recursive: true, force: true });
mkdirSync(join(dir, "cycle"), { recursive: true });
mkdirSync(join(dir, "outer"), { recursive: true });
writeFileSync(join(dir, "cycle", "tsconfig.json"), JSON.stringify({ extends: "./a.json" }));
writeFileSync(join(dir, "cycle", "a.json"), JSON.stringify({ extends: "./tsconfig.json" }));
writeFileSync(join(dir, "cycle", "main.ts"), "export const one = 1;\n");
writeFileSync(join(dir, "outer", "tsconfig.json"), JSON.stringify({ extends: "../cycle/a.json" }));
writeFileSync(join(dir, "outer", "main.ts"), "export const two = 2;\n");
for (const order of [["outer", "cycle"], ["cycle", "outer"]]) {
const api = new API({ cwd: dir });
try {
api.createSnapshot({ openProjects: [join(dir, order[0], "tsconfig.json")] });
const snapshot = api.createSnapshot({ openProjects: [join(dir, order[1], "tsconfig.json")] });
const project = snapshot.getProjects().find((p) => p.configFileName.endsWith(`${order[1]}/tsconfig.json`));
console.log(`${order.join(" then ")}:`, project.program.getConfigFileParsingDiagnostics().map((d) => `TS${d.code}: ${d.text}`));
} catch (error) {
console.log(`${order.join(" then ")}:`, String(error).split("\n")[0]);
}
api.close();
}
const api = new API({ cwd: dir });
try {
const first = api.createSnapshot({ openProjects: [join(dir, "outer", "tsconfig.json")] });
api.createSnapshot({ openProjects: [join(dir, "cycle", "tsconfig.json")] });
first.dispose();
api.createSnapshot({ openProjects: [join(dir, "outer", "tsconfig.json")] });
console.log("outer then cycle, dispose the first, outer again: ok");
} catch (error) {
console.log("outer then cycle, dispose the first, outer again:", String(error).split("\n")[0]);
}
api.close();🙁 Actual behavior
Opening outer first works. Opening cycle first makes the second createSnapshot throw a Go panic, and so does reopening outer after the snapshot that first opened it is disposed, every time:
$ node repro.mjs
outer then cycle: [ 'TS18000: Circularity detected while resolving configuration: {0}' ]
cycle then outer: Error: panic: OwnerCache.AddOwner: entry not found
outer then cycle, dispose the first, outer again: Error: panic: OwnerCache.AddOwner: entry not found
$ cd proj && npx tsc -p outer; echo $?
error TS18000: Circularity detected while resolving configuration: {0}
2
The stack, trimmed to the relevant frames:
github.com/microsoft/TypeScript/tsc/internal/project.(*OwnerCache[...]).AddOwner(0x275463c087e0?, {0x275463c087e0?, 0x2?}, 0x2?)
github.com/microsoft/TypeScript/tsc/internal/project/ownercache.go:63 +0xd8
github.com/microsoft/TypeScript/tsc/internal/project.(*Snapshot).Clone(_, {_, _}, {{{0x0, 0x0, 0x0}, {0x0, 0x0, 0x0}, {0x0, ...}, ...}, ...}, ...)
github.com/microsoft/TypeScript/tsc/internal/project/snapshot.go:725 +0x2108
github.com/microsoft/TypeScript/tsc/internal/project.(*SnapshotHost).CloneSnapshot(0x0?, {0x1c85e88?, 0x275463d7ab00?}, 0x558ec0?, {{0x0, 0x0}, {0x0, 0x0}, {0x0}, {0x0}, ...}, ...)
github.com/microsoft/TypeScript/tsc/internal/project/snapshothost.go:131 +0x27b
github.com/microsoft/TypeScript/tsc/internal/api.(*Session).handleCreateSnapshot(0x275463dfa000, {0x1c85e88, 0x275463d7ab00}, 0x275463cd0b60)
github.com/microsoft/TypeScript/tsc/internal/api/session.go:1440 +0x3d7
🙂 Expected behavior
Both projects open in any order, also after a snapshot is disposed, and each project's config file parsing diagnostics contain TS18000, as tsc -p cycle and tsc -p outer report.
Additional information about the issue
Impact: a repository with a circular extends chain and another config that extends into it makes createSnapshot fail with a Go panic depending on the order its projects are opened and disposed in, where tsc -p reports TS18000 for each project, so an analyzer that opens a repository's projects on the API fails on that repository. A client that changes a shared extended config and releases snapshots can hit the same panic with no cycle.
Snapshot.Clone calls AddOwner on the extended config cache for every path in each config's ExtendedSourceFiles, and AddOwner panics when a path has no cache entry. On a cache hit, GetExtendedConfig hands out the cached entry through LoadAndAcquire, which makes the snapshot an owner of that entry only, not of the configs it extends. Those configs are in the entry's list, so they reach the snapshot's ExtendedSourceFiles, though this snapshot acquired no entry for them. Clone then panics when one of them has no entry:
cyclethenouter: openingcycleparsesa.jsonthrough the cache withcycle/tsconfig.jsonon the resolution stack, sogetExtendedConfigparsescycle/tsconfig.jsonwithout the cache. The cacheda.jsonentry listscycle/tsconfig.json, which has no entry. Openingouterhits thata.jsonentry.outer,cycle, dispose,outer: the first snapshot parsesa.jsonand acquirescycle/tsconfig.jsonthrough the cache. The second snapshot hitsa.jsonand owns only that entry. Disposing the first snapshot releases the last owner ofcycle/tsconfig.json, so the entry is deleted while the cacheda.jsonstill lists it, and reopeningouterhitsa.jsonagain.- The same deletion happens with no cycle, which I reproduced in a Go test of the API session on
main: a snapshot opensp1, which extendsshared/a.json, which extendsshared/b.json. Aftera.jsonis changed to extendshared/c.json, the next snapshot opensp2, which also extendsa.json, so it parsesa.jsonagain and is the only owner ofc.json. Releasing that snapshot deletes thec.jsonentry while the cacheda.jsonstill lists it, and openingp2again panics.
The {0} in the TS18000 text has a separate cause and is filled on main by #65091.
I met this while building deadset-ts, the TypeScript analyzer of deadset, on the TypeScript 7 API.
Activity