[api] createSnapshot never returns when the opened tsconfig.json has a circular extends #64754
Description
🔎 Search Terms
createSnapshot, openProjects, extends, circular extends, TS18000, Circularity detected while resolving configuration, ExtendedSourceFiles, configFileRegistryBuilder, hang, deadlock
🕗 Version & Regression Information
- This is the behavior in every version I tried:
typescript@7.1.0-dev.20261010.1(native binary@typescript/typescript-linux-x64@7.1.0-dev.20261010.1, built frommainat aa81492) and a build ofmainat 96fffa9, with the synchronous API (typescript/sync) on linux-x64, Node 26.11.1.
⏯ Playground Link
The API is not in the playground. The reproduction is one file.
💻 Code
package.json:
{ "private": true, "type": "module", "dependencies": { "typescript": "7.1.0-dev.20261010.1" } }repro.mjs writes a tsconfig.json that extends a.json, which extends tsconfig.json again, then opens the project:
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(dir, { recursive: true });
writeFileSync(join(dir, "tsconfig.json"), JSON.stringify({ extends: "./a.json" }));
writeFileSync(join(dir, "a.json"), JSON.stringify({ extends: "./tsconfig.json" }));
writeFileSync(join(dir, "main.ts"), "export const one = 1;\n");
const configFileName = join(dir, "tsconfig.json");
const api = new API({ cwd: dir });
console.log("parseConfigFile:", api.parseConfigFile(configFileName).errors.map((d) => `TS${d.code}: ${d.text}`));
const snapshot = api.createSnapshot({ openProjects: [configFileName] });
console.log("createSnapshot:", snapshot.getProjects()[0].program.getConfigFileParsingDiagnostics().map((d) => `TS${d.code}: ${d.text}`));
snapshot.dispose();
api.close();🙁 Actual behavior
parseConfigFile reports the circularity, and then createSnapshot never returns. The process has to be killed:
$ node repro.mjs
parseConfigFile: [ 'TS18000: Circularity detected while resolving configuration: {0}' ]
^C
$ npx tsc -p proj
error TS18000: Circularity detected while resolving configuration: {0}
$ echo $?
2
🙂 Expected behavior
createSnapshot returns, and the project's config file parsing diagnostics contain TS18000, as parseConfigFile and tsc -p proj already report.
Additional information about the issue
Impact: one circular extends in a repository makes createSnapshot hang with no error, where tsc -p reports TS18000 and exits 2 at once, so an analyzer on the API stalls on that repository instead of reporting what is wrong with it. The same happens when the cycle runs through a base config shared with a project that was opened first.
acquireConfigForProject parses the config inside entry.ChangeIf, which holds that config's entry lock. reloadIfNeeded then calls updateExtendingConfigs, which runs LoadOrStore on every path in the parsed config's ExtendedSourceFiles, and LoadOrStore locks an entry it finds. parseConfig builds that list from each extended config and that config's own list, with nothing removed. When the chain cycles back to the opened config, the opened config is in its own list, so the goroutine waits on the entry lock it already holds. tsc -p does not walk the list under a lock, which is why it returns. The language server goes through the same acquireConfigForProject, but I reproduced only the API.
The {0} left in the TS18000 text, on the CLI too, has a separate cause and is not what this report is about.
I met this while building deadset-ts, the TypeScript analyzer of deadset, on the TypeScript 7 API. A pull request with the fix and a test follows.
Activity