Skip to content

Harden the workbench SSE stream: unhandled rejections, dead streams, ordering - #496

Merged
TheGreatAxios merged 4 commits into
mainfrom
cl-7197-workbench-events-fixes
Aug 30, 2026
Merged

TheGreatAxios merged 4 commits into
mainfrom
cl-7197-workbench-events-fixes

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Contributor

Summary

Six defects in bridgeWorkbenchStream's delivery path (packages/chat/src/workbench-events.ts, wired from packages/chat/src/routes.ts):

  • authorize() sat outside the try block and both subscriptions invoked delivery as a floating void deliver(event) — a transient resolver error became an unhandled rejection per delivered event.
  • The write-failure teardown never called stream.close(), so the client's EventSource never saw an error and never reconnected; the route itself parked on a promise that never resolved.
  • A bare catch {} around the platform subscribe swallowed failures with no reportError.
  • Deliveries weren't sequenced, so two events could land out of publication order depending on when their authorize() calls resolved.
  • The presence snapshot fired as a floating promise after both subscriptions installed, letting a racing event get written first and the snapshot then overwrite the client's already-applied delta.
  • No periodic keepalive — idle connections could die silently behind proxies.

Changes

  • Every write (presence snapshot, event, keepalive) now funnels through a single chained-promise queue so writes land in enqueue order regardless of authorize() timing, bounded by MAX_QUEUED_DELIVERIES to close a stream that's fallen too far behind rather than buffer it unboundedly.
  • The presence snapshot is enqueued before either subscription installs, so nothing can write ahead of it.
  • authorize() and every write path report through reportError (with operation/roomId context) and close the stream on failure.
  • bridgeWorkbenchStream now returns { teardown, closed }; the route awaits closed instead of a promise that never resolves, and wires stream.onAbort(teardown).
  • A periodic keepalive (25s default, configurable for tests) keeps idle connections alive behind proxies, skipping while the queue is already busy.

Scope note

packages/presence/src/routes.ts has an identical-shaped leak; that's CL-7212 and is untouched here.

Test plan

  • cd packages/chat && bun run typecheck — clean
  • bun test — 727 pass, 0 fail
  • bunx prettier --check on touched files — clean
  • CI (workspace-wide check)

https://linear.app/abklabs/issue/CL-7197/harden-the-workbench-sse-stream-unhandled-rejections-dead-streams

Covers the six workbench-events defects: unhandled rejections from a
rejecting authorize(), a dead stream after a failed write, the bare
catch swallowing the platform-subscribe failure, out-of-order
deliveries under a slow authorize(), the presence-snapshot/delta race,
and the periodic keepalive.
…own, add keepalive

Six defects lived in bridgeWorkbenchStream's delivery path: authorize()
sat outside the try block and deliveries were floating `void deliver()`
calls, so a transient resolver error became an unhandled rejection per
event; the write-failure teardown never closed the stream, so the
client's EventSource never saw an error and never reconnected, and the
route itself parked on a promise that never resolved; a bare `catch {}`
around the platform subscribe swallowed failures with no reportError;
deliveries weren't sequenced, so two events could land out of
publication order depending on when their authorize() calls resolved;
and the presence snapshot fired as a floating promise after both
subscriptions were installed, letting a racing event overwrite it on
the client.

Every write (snapshot, event, keepalive) now funnels through a single
chained-promise queue so writes land in enqueue order regardless of
authorize() timing, bounded by MAX_QUEUED_DELIVERIES to close a stream
that has fallen too far behind rather than buffer it unboundedly. The
presence snapshot is enqueued before either subscription installs, so
nothing can write ahead of it. authorize() and every write path report
through reportError and close the stream on failure. bridgeWorkbenchStream
now returns { teardown, closed }; the route awaits closed instead of a
promise that never resolves. A periodic keepalive keeps idle
connections alive behind proxies.
…eSSE

Hono's StreamingApi.write swallows writer errors internally and never
rejects, so writeSSE can never throw in production; the write-failure
branch in deliverEvent only fires for a stream implementation that
does throw (tests today, conceivably a future Hono release). The
disconnect signal that actually fires for a real client is
stream.onAbort, wired by the route. Correct the comments and the test
name that implied write-failure detection was the live mechanism
catching a disconnected client.
@TheGreatAxios

Copy link
Copy Markdown
Contributor Author

Caveat worth flagging for review: Hono's StreamingApi.write() (which writeSSE calls) wraps the writer.write() call in a bare try/catch and never rethrows (verified against the pinned hono@4.13.3 source). So writeSSE can never reject in production, and the write-failure branch in deliverEvent (unsubscribe + close + report) is dead code against the real dependency today — it only fires for a stream implementation that does throw (the unit tests). The actual disconnect signal that fires for a real client remains stream.onAbort, wired unchanged in routes.ts.

This doesn't regress anything (the pre-existing code had the same limitation) and the other five defects (unhandled rejections from authorize(), the route parking forever, the bare catch{} around platform subscribe, unserialized delivery ordering, and the presence-snapshot race) are all fixed independent of this. Comments and one test name were corrected to describe this accurately rather than implying write-failure detection is what catches a disconnected client in practice.

@TheGreatAxios
TheGreatAxios merged commit de31aa6 into main Aug 30, 2026
9 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant