Skip to content

[api] Concurrent createSnapshot calls can deadlock on projects entering one extends cycle #65300

Open

Description

🔎 Search Terms

createSnapshot, concurrent, hang, deadlock, never returns, extends, circular extends, TS18000, extendedConfigCache, OwnerCache, LoadAndAcquire

🕗 Version & Regression Information

⏯ 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions