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

🕗 Version & Regression Information

⏯ 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.20261014.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 then opens one project after the other in one API session, in both orders:

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();
}

🙁 Actual behavior

Opening outer first works. Opening cycle first makes the second createSnapshot throw a Go panic, 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
$ 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(0x377ff0dde2a0?, {0x377ff0dde2a0?, 0x2?}, 0x2?)
	github.com/microsoft/TypeScript/tsc/internal/project/ownercache.go:63 +0xe7
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:717 +0x20a8
github.com/microsoft/TypeScript/tsc/internal/project.(*SnapshotHost).CloneSnapshot(0x0?, {0x1b41078?, 0x377ff0e94b00?}, 0x544780?, {{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(0x377ff0dc0000, {0x1b41078, 0x377ff0e94b00}, 0x377ff2543930)
	github.com/microsoft/TypeScript/tsc/internal/api/session.go:1440 +0x377

Opening both in one createSnapshot({ openProjects: [cycle, outer] }) panics the same way in most runs (8 of 10 here), and occasionally (1 of 10) with [outer, cycle], because the two projects load concurrently.

🙂 Expected behavior

Both projects open in either order, 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 whenever the cyclic project was opened first, where tsc -p reports TS18000 for each project, so an analyzer that opens a repository's projects on the API fails on that repository depending on the order it opens them in.

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. Opening cycle parses a.json through the cache with cycle/tsconfig.json on the resolution stack. Inside it, cycle/tsconfig.json is already on the stack, so getExtendedConfig parses it without the cache, and the cached a.json entry lists cycle/tsconfig.json though nothing acquired an entry for it. Opening outer then finds a.json in the cache. The entry is still valid because its hash covers only file contents, not the resolution stack it was parsed under, so outer inherits cycle/tsconfig.json in its ExtendedSourceFiles, and Clone panics on it. In the other order, outer parses a.json with only outer/tsconfig.json on the stack, so cycle/tsconfig.json goes through the cache and gets an entry.

The {0} left in the TS18000 text has a separate cause, as noted in #64754, 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

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