Repository navigation
Description
🔎 Search Terms
async API, RpcClient, connection closed, disconnect, pending response, request never settles, batchRequests, PendingResponseRejected.
🕗 Version & Regression Information
Reproduced against unpatched main at 6161772c1370bdd4a82f808e6823bd755eb1870d. The same behavior was reproduced at 5237fd46890956e4bb8177a1a05dcd69e4166560.
Environment: Windows, Node.js 25.9.0. No regression boundary has been identified; this report does not claim that all released versions are affected. The FAQ was checked for this transport/API lifecycle behavior.
⏯ Playground Link
Not applicable: this requires a Node.js socket/process transport, rather than a standalone compiler invocation.
💻 Code
On the unpatched source checkout, save the script below as packages/typescript/repro-disconnect.mjs, then run from the repository root:
node --conditions @typescript/source packages/typescript/repro-disconnect.mjsThe peer uses the repository's vendored JSON-RPC implementation. No native compiler binary or external server is required. It first answers initialization, then disconnects only after receiving the test request. The normal-request control case succeeds. TS_API_TIMING_DIR is disabled in this repro to isolate ordinary requests from timing-dump handling.
Self-contained source-checkout reproduction
import { createClient } from "./src/api/async/client.ts";
import { createServer } from "node:net";
import { once } from "node:events";
import { setTimeout as delay } from "node:timers/promises";
import { createMessageConnection, SocketMessageReader, SocketMessageWriter } from "./vendor/vscode-jsonrpc/lib/node/main.js";
delete process.env.TS_API_TIMING_DIR;
let sequence = 0;
async function run(label, count, disconnect) {
const pipe = process.platform === "win32"
? `\\\\.\\pipe\\ts-api-close-repro-${process.pid}-${++sequence}`
: `/tmp/ts-api-close-repro-${process.pid}-${++sequence}.sock`;
let signalHandled;
const handled = new Promise(resolve => { signalHandled = resolve; });
let peer;
const server = createServer(socket => {
peer = socket;
const rpc = createMessageConnection(new SocketMessageReader(socket), new SocketMessageWriter(socket));
rpc.onRequest("initialize", () => ({ currentDirectory: process.cwd(), caseSensitivity: 1 }));
const handle = method => {
signalHandled(method);
if (disconnect) {
socket.destroy();
return new Promise(() => {});
}
return { options: {}, fileNames: [], errors: [] };
};
rpc.onRequest("parseCommandLine", () => handle("parseCommandLine"));
rpc.onRequest("batchRequests", () => handle("batchRequests"));
rpc.listen();
});
server.listen(pipe);
await once(server, "listening");
const client = createClient({ pipe });
try {
await client.apiRequest("initialize", null);
const requests = Array.from({ length: count }, () => client.apiRequest("parseCommandLine", { commandLine: [] }));
const tracked = requests.map(p => p.then(
() => ({ state: "fulfilled" }),
e => ({ state: "rejected", message: String(e) }),
));
const method = await Promise.race([handled, delay(2000).then(() => { throw new Error("server never received request"); })]);
if (disconnect) {
await delay(150);
const pending = await Promise.race([Promise.all(tracked), delay(300).then(() => "pending")]);
const next = await Promise.race([
client.apiRequest("parseCommandLine", { commandLine: [] }).then(
() => ({ state: "fulfilled" }),
e => ({ state: "rejected", message: String(e) }),
),
delay(1000).then(() => ({ state: "timed out" })),
]);
await client.close();
const afterClose = await Promise.all(tracked);
console.log(JSON.stringify({ label, method, pending, next, afterClose }));
}
else {
const result = await Promise.race([Promise.all(tracked), delay(2000).then(() => { throw new Error("normal request timed out"); })]);
await client.close();
console.log(JSON.stringify({ label, method, result }));
}
}
finally {
await client.close();
peer?.destroy();
await new Promise(resolve => server.close(resolve));
}
}
await run("normal single request", 1, false);
await run("disconnect during single request", 1, true);
await run("disconnect during batched requests", 2, true);🙁 Actual behavior
- The normal single request fulfills.
- After the server receives a single request and closes the socket, the request is still pending after the observation window.
- Two requests combined into
batchRequestsalso remain pending after disconnect. - A new request made after disconnect rejects with
Connection is closed. - Only the explicit
client.close()at the end makes the earlier requests reject withPending response rejected since connection got disposed.
The script prints "pending":"pending" for both disconnect cases, then rejected entries in afterClose. Without an explicit close or an application-level timeout, the earlier await has no response or rejection to finish it. The finite observation window demonstrates the behavior; source inspection explains why it persists.
🙂 Expected behavior
When the transport closes, outstanding requests that cannot receive an answer should reject without requiring a separate explicit close. Already-received complete responses should still be dispatched successfully rather than discarded during cleanup. Queued requests, batched requests, and initialization should not remain pending indefinitely.
Additional information about the issue
In the async client, closing the underlying JSON-RPC connection emits onClose, but the vendored connection rejects its pending response promises only when disposed. Ordinary request handling does not arrange that cleanup. A timing-specific close listener exists during explicit close, which covers a narrower case.
Related work: #64846 handles the same underlying mechanism while collecting server timing during explicit close. This report concerns unexpected disconnects during ordinary requests.
Proposed fix: draft PR #65088. Its tests also cover spawned-process disconnects, queued/manual batches, stale connection closures after retries, complete and fragmented responses, malformed/truncated frames, and timing cleanup.
AI assistance: Codex with Terra max sub-agents was used to investigate the source, prepare the reproduction, and author the draft patch. The reproduction above was rerun against the unpatched source before filing. Human review of the patch remains pending.
Activity