Found 2026-07-28 during agent-canvas's per-document-DO rework, against the 0.6.0 dist. Verified still present on current main (post-#36, 2026-08-26).
Symptom
subscribe called around connect/close boundaries rejects with transport not connected, and nothing inside the library queues or retries it. Two windows hit it:
- Before first connect completes. A collection created over a transport and subscribed immediately (the natural React shape:
createCollection(doCollectionOptions(...)) + preload() in the same render pass) races the WebSocket handshake. If the subscribe frame loses, it throws — 9 uncaught promise rejections per page load in the reporting app — and the collection stays permanently empty: preload() never resolves usefully, and no re-subscribe happens on the eventual open. Deterministic (3/3) on cold page loads in a real product; navigate-away-and-back "fixed" it because the second visit found the pooled transport already connected.
- Across
close(). React StrictMode's mount→unmount→remount does this every dev load.
Root cause (sharper than the symptom)
subscribe DOES await connect() — the defect is that connect() can resolve having adopted nothing. The close-epoch check discards a socket whose open() was in flight when close() landed, then returns — resolving the connect promise with no socket installed (src/client/transport.ts:300 on current main). The sendFrame after the await then throws, the subscription is registered with no frame ever on the wire, and the collection's promise floats.
Fix surface: connect()'s contract — it must not resolve disconnected (either re-dial or reject) — not a queue in subscribe.
Two adjacent residues, same fix surface
intentionallyClosed latches with no reset. Current main sets it true in close() (transport.ts:425) and never clears it — not even in a later connect(). A transport revived after close() (connection pools do this) loses auto-reconnect forever (measured: a 1006 close on a revived transport, no re-dial within 800ms).
- The latch also silences
onClosed. The unexpected-close listener returns early when intentionallyClosed is set, skipping both scheduleReconnect and the terminal-close notification — so an app that wires onClosed to learn about a server's deliberate terminal close (e.g. 4403 "removed from this workspace") hears nothing on any revived transport. Proven by control experiment in a real browser: resetting only the latch at runtime made the same server close deliver onClosed end to end.
Suggested contract: connect() clears intentionallyClosed — dialing is the clearest possible statement of intent — which fixes the reconnect loss and the onClosed silence together.
Acceptance criteria
await connect() never resolves with this.ws === null unless the transport was closed and stays closed — in which case it must reject (typed, catchable), not resolve.
subscribe racing close()/connect() either lands the frame on the next connection or rejects with a typed error; no unhandled rejection, no silently-empty collection.
- A
connect() after close() restores auto-reconnect and onClosed delivery (the latch is cleared by dialing).
- Tests pin each: subscribe-before-open, close-during-handshake then re-connect, revived-transport reconnect on 1006, revived-transport
onClosed on a terminal code.
Related: the CONNECTING-socket leak (same in-flight-open window) filed separately; cross-link when both exist. ADR-0016 governs reconnect policy — a fix here must not bypass it.
Found 2026-07-28 during agent-canvas's per-document-DO rework, against the 0.6.0 dist. Verified still present on current
main(post-#36, 2026-08-26).Symptom
subscribecalled around connect/close boundaries rejects withtransport not connected, and nothing inside the library queues or retries it. Two windows hit it:createCollection(doCollectionOptions(...))+preload()in the same render pass) races the WebSocket handshake. If the subscribe frame loses, it throws — 9 uncaught promise rejections per page load in the reporting app — and the collection stays permanently empty:preload()never resolves usefully, and no re-subscribe happens on the eventual open. Deterministic (3/3) on cold page loads in a real product; navigate-away-and-back "fixed" it because the second visit found the pooled transport already connected.close(). React StrictMode's mount→unmount→remount does this every dev load.Root cause (sharper than the symptom)
subscribeDOES awaitconnect()— the defect is thatconnect()can resolve having adopted nothing. The close-epoch check discards a socket whoseopen()was in flight whenclose()landed, thenreturns — resolving the connect promise with no socket installed (src/client/transport.ts:300on current main). ThesendFrameafter the await then throws, the subscription is registered with no frame ever on the wire, and the collection's promise floats.Fix surface:
connect()'s contract — it must not resolve disconnected (either re-dial or reject) — not a queue insubscribe.Two adjacent residues, same fix surface
intentionallyClosedlatches with no reset. Current main sets ittrueinclose()(transport.ts:425) and never clears it — not even in a laterconnect(). A transport revived afterclose()(connection pools do this) loses auto-reconnect forever (measured: a 1006 close on a revived transport, no re-dial within 800ms).onClosed. The unexpected-close listener returns early whenintentionallyClosedis set, skipping bothscheduleReconnectand the terminal-close notification — so an app that wiresonClosedto learn about a server's deliberate terminal close (e.g. 4403 "removed from this workspace") hears nothing on any revived transport. Proven by control experiment in a real browser: resetting only the latch at runtime made the same server close deliveronClosedend to end.Suggested contract:
connect()clearsintentionallyClosed— dialing is the clearest possible statement of intent — which fixes the reconnect loss and theonClosedsilence together.Acceptance criteria
await connect()never resolves withthis.ws === nullunless the transport was closed and stays closed — in which case it must reject (typed, catchable), not resolve.subscriberacingclose()/connect()either lands the frame on the next connection or rejects with a typed error; no unhandled rejection, no silently-empty collection.connect()afterclose()restores auto-reconnect andonCloseddelivery (the latch is cleared by dialing).onClosedon a terminal code.Related: the CONNECTING-socket leak (same in-flight-open window) filed separately; cross-link when both exist. ADR-0016 governs reconnect policy — a fix here must not bypass it.