Skip to content

fix(bb-realtime): auto-reconnect + resubscribe production WebSocket on unexpected close - #497

Open
soberm wants to merge 9 commits into
mainfrom
fix/realtime-prod-reconnect
Open

fix(bb-realtime): auto-reconnect + resubscribe production WebSocket on unexpected close#497
soberm wants to merge 9 commits into
mainfrom
fix/realtime-prod-reconnect

Conversation

@soberm

@soberm soberm commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Problem

Issue #, if available: internal — useChat WebSocket timeout handling for long agent turns

The production Realtime middleware (packages/bb-realtime/src/aws-middleware.ts) did not reconnect after an unexpected WebSocket close — only the local mock did. API Gateway WebSockets enforce a 2h max connection duration and a 10-min idle timeout, so a long-running agent turn (now up to 8h on AgentCore) could silently lose its stream when the socket dropped, with no client-side recovery.

This is PR 1 of 2 (the transport-layer fix). A stacked PR on top wires useChat (bb-agent) to re-sync conversation state from the DB on reconnect and reset loading on send-path failures.

Changes

Brings production to parity with the mock's recovery model:

  • Auto-reconnect on unexpected close (not on a normal 1000/1005 close or a client-initiated unsubscribe) with exponential backoff min(1000 * 2^(n-1), 30_000), capped at MAX_RECONNECT = 5.
  • Resubscribe all stored channels with their replayed tokens on the new socket; keep-alive ping timer re-established on reconnect (covers the 10-min idle limit across reconnects).
  • New optional onReconnect() on SubscribeOptions, fired once after a successful resubscribe (after the drop's onDisconnect). Purely additive — existing subscribe(handler) / onDisconnect callers are unaffected.

Potentially sensitive parts (all covered by tests + reviewed):

  • onDisconnect fires on every drop (handlers cleared only on terminal close).
  • A stale replayed token (2h TTL vs up to 8h sessions) is surfaced via onDisconnect('error') and drains resubscribePending so surviving channels still fire onReconnect — no silent drop.
  • onerror no longer rejects pendingEstablished; onclose alone decides terminal-reject vs reconnect-preserve, so an in-flight subscribe() during a transient drop resolves on reconnect.
  • Hitting MAX_RECONNECT (or a channel-less reconnect) tears the connection down so a later subscribe() rebuilds — no zombie pool entry.

Validation

  • New unit suite packages/bb-realtime/src/reconnect.test.ts (node:test, self-contained FakeWebSocket + mock timers) — 87/87 bb-realtime tests pass. Covers: auto-reconnect after 1006 with retry cap; resubscribe with replayed token; onReconnect ordering; keep-alive re-arm; and the failure paths — every-drop notify, flapping-socket cap, stale-token surfacing, onerror preserves in-flight established, post-cap rebuild.
  • npm run build ✅ · npm run lint:deps ✅ · biome lint ✅ (0 errors) · API report regenerated (SubscribeOptions gains onReconnect?). Changeset added (@aws-blocks/bb-realtime minor).
  • Two review cycles completed (4 blocking findings resolved → APPROVE).
  • Sandbox e2e: a true API Gateway 2h/idle close can't be cheaply simulated; the reconnect state machine is proven by the unit suite. A sandbox e2e confirming subscribe/publish end-to-end post-change is tracked for the stacked PR.

Maintainer notes

  • Behavior change on a 0.x package → changeset is minor per the repo's 0.x convention. Please eyeball MAX_RECONNECT=5 / backoff adequacy for an 8h session crossing the 2h boundary (~4 forced reconnects) plus transient drops.

Checklist

  • PR description included
  • Tests are changed or added
  • Relevant documentation is changed or added (API.md regenerated; onReconnect JSDoc added)

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

…n unexpected close

The production Realtime middleware (aws-middleware.ts) previously did not
reconnect after an unexpected WebSocket close — only the local mock did.
API Gateway WebSockets have hard limits (2h max connection duration, 10-min
idle timeout), so a long-running agent turn (now up to 8h on AgentCore) could
silently lose its stream when the socket dropped, with no client recovery.

Production now mirrors the mock's recovery model:
- Auto-reconnect on unexpected close (not on normal 1000/1005 or client
  unsubscribe) with exponential backoff (min(1000*2^(n-1), 30_000)), capped at
  MAX_RECONNECT=5.
- Resubscribe all stored channels with their replayed tokens on the new socket.
- Re-establish the keep-alive ping timer on reconnect.
- Pending establishment promises are kept intact across a transient drop so the
  reconnect resolves them.

Adds an optional onReconnect() callback to SubscribeOptions, fired after a
successful resubscribe (after the drop's onDisconnect). Purely additive:
existing subscribe(handler) and onDisconnect callers are unaffected.
@changeset-bot

changeset-bot Bot commented Sep 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: dc4f75e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@aws-blocks/bb-realtime Minor
@aws-blocks/bb-agent Patch
@aws-blocks/blocks Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

…ify, stale-token surfacing, no zombie connection

Resolves code-review findings on the production reconnect path:

- onDisconnect now fires on EVERY unexpected drop (handlers are cleared only
  on a terminal 1000/1005 close, not when scheduling a reconnect).
- Stale replayed token on resubscribe (2h token TTL vs up to 8h sessions) is
  now surfaced to onDisconnect('error') and drains resubscribePending so
  surviving channels still fire onReconnect — instead of a silent drop.
- onerror no longer rejects/clears pendingEstablished; onclose alone decides
  terminal-reject vs reconnect-preserve, so an in-flight subscribe() during a
  transient drop resolves on reconnect instead of rejecting.
- Hitting MAX_RECONNECT now tears the connection down (reject pending, terminal
  onDisconnect, clear timers, delete pool entry) so a later subscribe()
  rebuilds — no zombie entry that never reconnects.
- reconnectAttempts resets only after resubscribe is confirmed (not on every
  onopen), so a flapping open-then-close socket still hits the cap; 1006
  onDisconnect double-fire deduped per socket.
- A channel-less reconnect (all tokens stale/unsubscribed) tears down instead
  of marching to the cap.

Adds failure-path unit tests (every-drop notify, flapping cap, stale-token
surfacing, onerror-preserves-pending, post-cap rebuild). 87/87 pass.
…eword landed tests

Address second-cycle review on PR #497:

- Wire onReconnect through the mock transport (mock-middleware.ts) so local dev
  matches production: subscribeTo takes onReconnect, stored in reconnectHandlers,
  destructured in hydrate's subscribe closure, and fired after the mock's onopen
  replays stored channels on reconnect. Closes the T4 mock/runtime fidelity gap.
- Expose subscribeTo's `connection` as a live getter (reads current conn.ws) in
  both middlewares, so `.connection` reflects the reconnected socket instead of
  the stale one captured at subscribe time.
- Reword reconnect.test.ts from pre-implementation "RED / does not exist yet"
  narration to present-tense coverage of the shipped reconnect behavior.
…wn (fixes hung test suite)

The mock middleware's ws.onclose called scheduleReconnect unconditionally, so a
client-initiated close (unsubscribe) or __resetConnectionsForTest teardown
re-armed a reconnect setTimeout and recreated a pooled connection AFTER teardown.
The stale timer kept the Node event loop alive, so `node --test` never exited —
the bb-realtime suite hung after the mock reconnect test (CI "PR Checks" job
stuck >40min; locally `npm test` timed out with "Promise resolution is still
pending but the event loop has already resolved").

Fix (mock-middleware.ts):
- scheduleReconnect now reads the existing pool entry instead of recreating one,
  and short-circuits when the connection is torn down or has zero subscriptions
  (mirrors the production aws-middleware guard) — a stale timer can no longer
  resurrect a deleted connection.
- Add a per-connection `tornDown` flag; __resetConnectionsForTest sets it, clears
  the reconnect timer, and detaches onmessage/onerror/onclose before close(), so
  the async close cannot schedule a new reconnect. Fully quiescent afterward.

Legitimate unexpected-drop reconnect + resubscribe + onReconnect is unchanged
(token-replay and fires-onReconnect tests still pass). Suite now exits cleanly:
89/89 pass, exit 0. aws-middleware.ts already had these guards — untouched.
Adds an over-the-wire e2e (test-apps/comprehensive) that subscribes to a
dedicated channel, forces a mid-stream socket close via sub.connection.close(),
waits for onReconnect, and asserts a message published AFTER the drop is still
delivered on the reconnected socket — exercising the production auto-reconnect +
resubscribe path end-to-end. Cleans up the subscription in a finally block.
Passes locally against the mock middleware.
…nd-trip

The reconnect e2e passed on the mock (which fires onReconnect synchronously on
frame-send) but failed on E2E Sandbox and E2E Production with 'onReconnect did
not fire within 15s' (~15.5s observed). Diagnosis: NOT a bug — the real AWS
ws-server DOES send {type:'subscribe_success', channel} on every successful
resubscribe (index.aws.ts), and the middleware's settleResubscribe/onReconnect
keys on exactly that. It's simply slower than the mock: >=1s reconnect backoff
+ $connect handshake + $default Lambda (possible cold start) + Secrets Manager
token-secret fetch + token validation + DynamoDB put + PostToConnection. That
round-trip exceeds the mock-tuned 15s deadline.

Widen the reconnect deadline to 60s and the post-reconnect delivery window to
30s so the test reflects a realistic AWS cold-start reconnect budget. The mock
still fires onReconnect in well under a second, so local remains fast.
…01 (intent-based classification)

Verified against a real AWS sandbox: the reconnect e2e was failing because the
AWS middleware classified WebSocket close codes {1000,1005} as terminal and only
reconnected on other codes. A client-side sub.connection.close() with no args
sends 1005 ("no status"), which was treated as a clean/terminal close, so the
transport never reconnected and onReconnect never fired (the 60s test wait
expired). The mock reconnects on ANY close (guarded by tornDown/no-subscriptions),
which is why it passed locally — a mock/AWS parity gap.

Fix: classify by INTENT, not close code. Track an explicit intentionalClose flag
set only on client-initiated teardown (last-channel unsubscribe,
__resetConnectionsForTest, MAX_RECONNECT give-up). onclose now reconnects on any
close unless intentionalClose or subscriptions.size===0 — so clean closes
(1000/1005), going-away (1001), and abnormal (1006) all reconnect unless the
client asked to stop. The close-code→reason mapping for onDisconnect
(1001→timeout, 1006→error, else unknown) is preserved. This also de-risks the
case where API Gateway emits a clean 1000/1005 on a forced disconnect.

Empirical: against a real deployed sandbox (996099992135/us-west-2), the reconnect
e2e now passes in ~5.7s (onReconnect fires) vs the prior 60s timeout. Unit: 94/94.
…ite exits cleanly

The comprehensive e2e suite intermittently force-exited with code 1 via the 15s
open-handle backstop ("Open handles prevented clean exit") even when every test
passed. Root cause: the realtime client middleware keeps a module-level pooled
WebSocket (+ keep-alive/reconnect timers) that test.after never closed — killing
the dev server stops the server, not the client pool — so node:test could not
exit and the backstop fired.

Fix: in test.after, before arming the backstop, best-effort dynamic-import the
realtime middleware and call __resetConnectionsForTest(). Reset BOTH the mock
(local) and aws (sandbox/production) middleware modules so it's environment-
agnostic; each is a safe no-op over an empty pool. Dynamic import keeps the
inactive module out of the hydration chain during tests. The backstop is kept as
a safety net.

Local: full suite now exits 0 (tests 390 / pass 389 / fail 0 / skipped 1), no
open-handle message.
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