Skip to content

[api] createSnapshot panics opening a project that extends into a circular extends chain #64824

Open

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

⏯ 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:

  • cycle then outer: opening cycle parses a.json through the cache with cycle/tsconfig.json on the resolution stack, so getExtendedConfig parses cycle/tsconfig.json without the cache. The cached a.json entry lists cycle/tsconfig.json, which has no entry. Opening outer hits that a.json entry.
  • outer, cycle, dispose, outer: the first snapshot parses a.json and acquires cycle/tsconfig.json through the cache. The second snapshot hits a.json and owns only that entry. Disposing the first snapshot releases the last owner of cycle/tsconfig.json, so the entry is deleted while the cached a.json still lists it, and reopening outer hits a.json again.
  • The same deletion happens with no cycle, which I reproduced in a Go test of the API session on main: a snapshot opens p1, which extends shared/a.json, which extends shared/b.json. After a.json is changed to extend shared/c.json, the next snapshot opens p2, which also extends a.json, so it parses a.json again and is the only owner of c.json. Releasing that snapshot deletes the c.json entry while the cached a.json still lists it, and opening p2 again 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.

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

Metadata

Metadata

Labels

BugA bug in TypeScript

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions