Summary
When better-sse serves Hono through the Fetch API (the setup in the README), a client that goes away mid-stream leaves the session writing to a dead TransformStream. FetchConnection.sendChunk and cleanup fire writer.write() / writer.close() without catching, so the next keepalive (or broadcast) rejects, and the server process crashes.
Reproduction
Server, from the README's Hono section:
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { createResponse } from "better-sse";
const app = new Hono();
app.get("/events", (c) =>
createResponse(c.req.raw, { keepAlive: 1000 }, () => {}),
);
serve(app);
Client:
curl -N http://localhost:3000/events &
sleep 2
kill %1
Wait one keepalive interval and the server exits with an unhandled rejection. The rejection reason is undefined (the stream's cancel reason), so the log shows no stack and no message. A test we wrote reproduces the same thing without a network: create the response, call response.body.getReader().cancel(), then session.push(...).
Root cause
Two things in src/adapters/FetchConnection.ts:
sendChunk and cleanup leave the promise floating:
sendChunk = (chunk) => {
const encoded = FetchConnection.encoder.encode(chunk);
this.writer.write(encoded); // floating promise
};
cleanup = () => {
this.writer.close(); // floating promise
};
- The session never learns the stream died. Disconnect detection is the request signal's abort event, but cancelling the response body doesn't abort the request signal. The session stays
isConnected === true, keepalives keep writing to the dead stream once per interval, and the session is never deregistered from any Channel it's in.
Expected Behavior
- A failed write is treated as a disconnect.
- Cancelling the response body (closed tab,
EventSource reconnect) aborts the request signal, clears the keepalive timer, and deregisters the session from its channels.
Workaround
We patched better-sse to do:
sendChunk = (chunk) => {
const encoded = FetchConnection.encoder.encode(chunk);
void this.writer.write(encoded).catch((err) => {
this.controller.abort(err);
});
};
cleanup = () => {
void this.writer.close().catch(() => {
this.controller.abort();
});
};
this.controller is an AbortController whose signal backs a wrapped this.request, so the abort routes through the existing disconnect path: the session is marked disconnected, the keepalive timer is cleared, and the channel deregisters it.
Thank you!
Summary
When better-sse serves Hono through the Fetch API (the setup in the README), a client that goes away mid-stream leaves the session writing to a dead
TransformStream.FetchConnection.sendChunkandcleanupfirewriter.write()/writer.close()without catching, so the next keepalive (or broadcast) rejects, and the server process crashes.Reproduction
Server, from the README's Hono section:
Client:
Wait one keepalive interval and the server exits with an unhandled rejection. The rejection reason is
undefined(the stream's cancel reason), so the log shows no stack and no message. A test we wrote reproduces the same thing without a network: create the response, callresponse.body.getReader().cancel(), thensession.push(...).Root cause
Two things in
src/adapters/FetchConnection.ts:sendChunkandcleanupleave the promise floating:isConnected === true, keepalives keep writing to the dead stream once per interval, and the session is never deregistered from anyChannelit's in.Expected Behavior
EventSourcereconnect) aborts the request signal, clears the keepalive timer, and deregisters the session from its channels.Workaround
We patched
better-sseto do:this.controlleris anAbortControllerwhose signal backs a wrappedthis.request, so the abort routes through the existing disconnect path: the session is marked disconnected, the keepalive timer is cleared, and the channel deregisters it.Thank you!