Skip to content

Async API requests remain pending after the RPC connection closes #65089

Open

Description

@wsjwu58-cmd

🔎 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.mjs

The 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 batchRequests also 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 with Pending 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.

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

    BugA bug in TypeScript

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions