🔎 Search Terms
createModuleResolver, resolveModuleName callback, nested request, inProgressSnapshot, SyncConn, wrong module
🕗 Version & Regression Information
- This is the behavior in every version I tried: 7.1.0-dev.20261003.1 and 7.1.0-dev.20261004.1 (commit 50d70a3, the newest typescript@next), the synchronous API (typescript/unstable/sync), linux-x64, Node 26.8.2. Module resolver callbacks arrived in [api] Provide module resolution overrides #64299, so earlier versions do not have this API.
⏯ 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.20261004.1" } }
repro.mjs writes 300 files that each import three siblings, then loads them with a resolveModuleName callback that asks a default resolver for the answer with the in-progress snapshot, as the "module resolver callbacks can delegate to another resolver" API test does. The callback always returns the right file itself and only counts whether the nested answer was right.
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { API } from "typescript/unstable/sync";
const N = 300;
const nested = process.argv[2] !== "local";
const dir = join(process.cwd(), "proj");
rmSync(dir, { recursive: true, force: true });
mkdirSync(dir, { recursive: true });
for (let i = 0; i < N; i++) {
const next = [1, 2, 3].map((k) => (i + k) % N);
writeFileSync(
join(dir, `m${i}.ts`),
next.map((j) => `import { v${j} } from "./m${j}.js";`).join("\n") +
`\nexport const v${i}: number = 1;\nexport const use${i} = [${next.map((j) => `v${j}`).join(", ")}];\n`,
);
}
const compilerOptions = { module: 199, moduleResolution: 99, strict: true, noEmit: true };
const rootFiles = Array.from({ length: N }, (_, i) => join(dir, `m${i}.ts`));
const api = new API({ cwd: dir });
const defaultResolver = api.createModuleResolver(compilerOptions);
let right = 0;
let wrong = 0;
const customResolver = api.createModuleResolver(compilerOptions, {
resolveModuleName(moduleName, containingDirectory, resolutionMode, { snapshot }) {
const expected = join(containingDirectory, moduleName.replace(/\.js$/u, ".ts"));
if (nested) {
const answer = defaultResolver.resolveModuleName(moduleName, containingDirectory, resolutionMode, { snapshot });
if (answer.resolvedModule?.resolvedFileName === expected) right++;
else wrong++;
}
return { resolvedFileName: expected };
},
});
const snapshot = api.createSnapshot({
createPrograms: [{ rootFiles, compilerOptions, options: { moduleResolver: customResolver } }],
});
const errors = snapshot.operation.createdPrograms[0].getSemanticDiagnostics();
console.log(
nested ? `nested answers: ${right} right, ${wrong} for another request;` : "no nested request;",
`program errors: ${errors.length}${errors[0] ? ` (first: ${errors[0].text})` : ""}`,
);
snapshot.dispose();
api.close();
🙁 Actual behavior
On 7.1.0-dev.20261004.1:
$ node repro.mjs
nested answers: 16 right, 884 for another request; program errors: 893 (first: Module '"./m1.js"' has no exported member 'v1'.)
$ node repro.mjs
nested answers: 27 right, 873 for another request; program errors: 889 (first: Module '"./m1.js"' has no exported member 'v1'.)
$ node repro.mjs local
no nested request; program errors: 0
$ GOMAXPROCS=1 node repro.mjs
nested answers: 900 right, 0 for another request; program errors: 0
Two answers go astray, and only when the callback makes a nested request. The nested resolveModuleName returns the answer to a different import, and the callback's own return value reaches the wrong import too: every callback returns the correct file, yet almost every import in the program is bound to another module.
🙂 Expected behavior
Each nested request gets its own answer and each callback answer reaches the import it was asked for, so the program has 0 errors with any number of threads.
Additional information about the issue
Impact: a resolver callback that delegates to another resolver, the pattern the API's own test shows, binds most imports to the wrong module once the loader uses more than one thread. Every type and diagnostic the tool reads from that program is then wrong, and no error says so. In the reproduction, 873 of 900 nested answers went to another request.
The program loader resolves imports from several goroutines, and each one calls the callback through SyncConn.Call. Call matches a response by method name, so it relies on c.mu to keep its request and its response together. When the client's callback makes a nested request, Call releases c.mu while it handles that request. Another goroutine's Call then writes its own callback request, which the synchronous client handles inside its pending nested call. When the first nested request finishes, its response reaches the client while the client waits for the second callback's nested answer, and the two callback responses then reach the wrong goroutines because they share a method name.
I met this while building deadset-ts, the TypeScript analyzer of deadset, on the TypeScript 7 API. A pull request with a fix and a test follows.
🔎 Search Terms
createModuleResolver, resolveModuleName callback, nested request, inProgressSnapshot, SyncConn, wrong module
🕗 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.20261004.1" } }repro.mjs writes 300 files that each import three siblings, then loads them with a resolveModuleName callback that asks a default resolver for the answer with the in-progress snapshot, as the "module resolver callbacks can delegate to another resolver" API test does. The callback always returns the right file itself and only counts whether the nested answer was right.
🙁 Actual behavior
On 7.1.0-dev.20261004.1:
Two answers go astray, and only when the callback makes a nested request. The nested resolveModuleName returns the answer to a different import, and the callback's own return value reaches the wrong import too: every callback returns the correct file, yet almost every import in the program is bound to another module.
🙂 Expected behavior
Each nested request gets its own answer and each callback answer reaches the import it was asked for, so the program has 0 errors with any number of threads.
Additional information about the issue
Impact: a resolver callback that delegates to another resolver, the pattern the API's own test shows, binds most imports to the wrong module once the loader uses more than one thread. Every type and diagnostic the tool reads from that program is then wrong, and no error says so. In the reproduction, 873 of 900 nested answers went to another request.
The program loader resolves imports from several goroutines, and each one calls the callback through SyncConn.Call. Call matches a response by method name, so it relies on c.mu to keep its request and its response together. When the client's callback makes a nested request, Call releases c.mu while it handles that request. Another goroutine's Call then writes its own callback request, which the synchronous client handles inside its pending nested call. When the first nested request finishes, its response reaches the client while the client waits for the second callback's nested answer, and the two callback responses then reach the wrong goroutines because they share a method name.
I met this while building deadset-ts, the TypeScript analyzer of deadset, on the TypeScript 7 API. A pull request with a fix and a test follows.