Skip to content

Unhandled promise rejection when the client disconnects (Fetch adapter / Hono setup) #120

Description

@samjbobb

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:

  1. 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
};
  1. 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!

Activity

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

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions