Skip to content

serve-node: client disconnect never aborts the request, leaking streaming producers #354

Description

@Vito-168

Summary

serve-node.ts never connects a client disconnect to the request it hands the router, and
its response writer releases the body reader instead of cancelling it. The result: when a
client drops a streaming response, the producer behind it keeps running for the life of the
process.

This is not hypothetical — it affects a shipped endpoint today, and both ends of the wiring
are already in place. Only the middle is missing.

The two halves

1. toWebRequest builds a Request with no signal (serve-node.ts:105-111)

return new Request(url, {
  method,
  headers,
  body,
  // @ts-expect-error duplex is required for streaming request bodies in Node
  duplex: hasBody ? "half" : undefined,
});

No signal, so request.signal is a fresh AbortSignal that can never fire. Every handler
reading ctx.request.signal gets a signal that stays unaborted forever, however long ago the
client hung up.

2. writeWebResponse releases the reader rather than cancelling it (serve-node.ts:146-149)

} finally {
  reader.releaseLock();
  endResponse(nodeRes);
}

When the loop breaks early because isResponseWritable(nodeRes) went false — i.e. the socket
died — releaseLock() detaches the reader but leaves the underlying stream unclosed, so the
producer is never told to stop. reader.cancel() is what propagates the stop upstream.

Why it's live today

GET /workspace/:id/engine/deepseek-harness/events/:stream already does the right thing on
both sides of this gap:

  • routes/deepseek-harness.ts:121const upstream = await runtime.events(stream, ctx.request.signal);
  • deepseek-harness-runtime.ts:289const response = await fetch(\${baseUrl}/api/events.${stream}`, { signal });`

The route forwards the signal, and the runtime hands it to fetch. The only reason the
cancellation doesn't happen is that the signal it forwards can never fire.

So: every time a client opens that SSE stream and goes away — closes the tab, drops the
connection, times out — the upstream fetch to the Harness event endpoint stays open, and
the WebSocket fallback path (deepseek-harness-runtime.ts:396) keeps its abort listener
attached. Nothing reaps them. They accumulate for as long as the server runs.

Reproducing

bun src/cli.ts --workspace /path/to/workspace &
curl -N http://127.0.0.1:8787/workspace/<id>/engine/deepseek-harness/events/mux &
sleep 2 && kill %2   # client goes away

The upstream subscription is still open. Repeat and they stack up.

Suggested fix

Derive an AbortSignal from the Node socket and pass it into the Request, and cancel the
reader instead of releasing it:

function createDisconnectSignal(nodeReq, nodeRes) {
  const controller = new AbortController();
  const abort = () => {
    if (nodeRes.writableFinished) return;   // normal completion, not a disconnect
    if (!controller.signal.aborted) controller.abort();
  };
  nodeRes.once("close", abort);
  nodeReq.once("aborted", abort);
  return controller.signal;
}

The writableFinished guard matters: close fires on every normal response too, so without
it every completed request would look like a disconnect.

Note that Bun's serve() already provides a working request.signal, so this only affects
the Node path (IPOLLOWORK_RUNTIME=node, and the packaged desktop server).

Happy to send a PR

I have this fixed with three end-to-end tests against a real http.Server — normal request
completes without aborting, client disconnect aborts, and a streaming body gets cancelled
rather than left dangling. Say the word and I'll open it.

For context, I found this while prototyping something larger that streams (#353), but the
bug is independent of that and worth fixing on its own.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions