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:121 — const upstream = await runtime.events(stream, ctx.request.signal);
-
deepseek-harness-runtime.ts:289 — const 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.
Summary
serve-node.tsnever connects a client disconnect to the request it hands the router, andits 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.
toWebRequestbuilds aRequestwith nosignal(serve-node.ts:105-111)No
signal, sorequest.signalis a freshAbortSignalthat can never fire. Every handlerreading
ctx.request.signalgets a signal that stays unaborted forever, however long ago theclient hung up.
2.
writeWebResponsereleases the reader rather than cancelling it (serve-node.ts:146-149)When the loop breaks early because
isResponseWritable(nodeRes)went false — i.e. the socketdied —
releaseLock()detaches the reader but leaves the underlying stream unclosed, so theproducer 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/:streamalready does the right thing onboth sides of this gap:
routes/deepseek-harness.ts:121—const upstream = await runtime.events(stream, ctx.request.signal);deepseek-harness-runtime.ts:289—const response = await fetch(\${baseUrl}/api/events.${stream}`, { signal });`The route forwards the signal, and the runtime hands it to
fetch. The only reason thecancellation 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
fetchto the Harness event endpoint stays open, andthe WebSocket fallback path (
deepseek-harness-runtime.ts:396) keeps its abort listenerattached. Nothing reaps them. They accumulate for as long as the server runs.
Reproducing
The upstream subscription is still open. Repeat and they stack up.
Suggested fix
Derive an
AbortSignalfrom the Node socket and pass it into theRequest, and cancel thereader instead of releasing it:
The
writableFinishedguard matters:closefires on every normal response too, so withoutit every completed request would look like a disconnect.
Note that Bun's
serve()already provides a workingrequest.signal, so this only affectsthe 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 requestcompletes 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.