Repository navigation
Description
🔎 Search Terms
createSnapshot, concurrent, hang, deadlock, never returns, extends, circular extends, TS18000, extendedConfigCache, OwnerCache, LoadAndAcquire
🕗 Version & Regression Information
- This is the behavior in every version I tried:
[email protected](native binary@typescript/[email protected], built frommainat 6161772) and a build ofmainat 622d4f7, with the asynchronous API (typescript/async) on linux-x64.
⏯ 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 100 copies of one tree: cycle/tsconfig.json extends cycle/a.json, which extends cycle/b.json, which extends cycle/tsconfig.json again; p1/tsconfig.json extends ../cycle/a.json and p2/tsconfig.json extends ../cycle/b.json. For each copy it starts createSnapshot for p1, then for p2 two event loop turns later, and waits up to 10 seconds for both:
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { API } from "typescript/async";
const dir = join(process.cwd(), "proj");
rmSync(dir, { recursive: true, force: true });
const write = (file, text) => {
mkdirSync(join(file, ".."), { recursive: true });
writeFileSync(file, text);
};
for (let k = 1; k <= 100; k++) {
const root = join(dir, String(k));
write(join(root, "cycle", "tsconfig.json"), JSON.stringify({ extends: "./a.json" }));
write(join(root, "cycle", "a.json"), JSON.stringify({ extends: "./b.json" }));
write(join(root, "cycle", "b.json"), JSON.stringify({ extends: "./tsconfig.json" }));
write(join(root, "p1", "tsconfig.json"), JSON.stringify({ extends: "../cycle/a.json" }));
write(join(root, "p2", "tsconfig.json"), JSON.stringify({ extends: "../cycle/b.json" }));
for (const d of ["cycle", "p1", "p2"]) write(join(root, d, "main.ts"), "export {};\n");
}
const api = new API({ cwd: dir });
let result = "all 100 rounds returned";
for (let k = 1; k <= 100; k++) {
const first = api.createSnapshot({ openProjects: [join(dir, String(k), "p1", "tsconfig.json")] });
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
const open = Promise.all([first, api.createSnapshot({ openProjects: [join(dir, String(k), "p2", "tsconfig.json")] })]);
const timeout = new Promise((resolve) => setTimeout(resolve, 10000, "timeout").unref());
if ((await Promise.race([open, timeout])) === "timeout") {
result = `round ${k}: no answer after 10 s`;
break;
}
}
console.log(result);
process.exit(0);🙁 Actual behavior
The two calls of one round never return, in every run:
$ node repro.mjs
round 2: no answer after 10 s
Here it stopped at round 2 in 3 of 3 runs on the nightly, and at rounds 2, 5 and 3 on the build of main. Each project alone reports TS18000:
$ cd proj/1 && npx tsc -p p1; echo $?
error TS18000: Circularity detected while resolving configuration: {0}
2
When both calls are sent in the same turn, all 100 rounds returned in 5 of 5 runs.
🙂 Expected behavior
Both calls return, and each project's config file parsing diagnostics contain TS18000, as tsc -p p1 and tsc -p p2 report.
Additional information about the issue
Impact: an analyzer that opens a repository's projects concurrently on the API waits forever, with no error, on a repository where two configs extend into one circular extends chain at different points.
OwnerCache.LoadAndAcquire stores a new entry locked and parses it while holding that lock. The parse calls GetExtendedConfig for each config the entry extends, which calls LoadAndAcquire again. Loading p1 holds the a.json entry and waits for b.json. Loading p2 holds b.json, then cycle/tsconfig.json, and waits for a.json. The cycle bypass in getExtendedConfig only covers a config already on the same load's resolution stack, so neither load reaches it.
#64833 reports the same lock pattern in the CLI's own extended config cache, used by tsc -b, which is a separate cache. #64824 is a panic on the same kind of tree with a different cause.
I met this while building deadset-ts, the TypeScript analyzer of deadset, on the TypeScript 7 API.
Activity