Skip to content

client: connect() can resolve disconnected; intentionallyClosed never resets — subscribe throws, reconnect and onClosed silenced #37

Description

@grrowl

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:

  1. 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.
  2. 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

  1. 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).
  2. 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.

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