Found 2026-07-30 during agent-canvas's live-workspace delivery, against the 0.6.0 dist (real-browser observation, Chrome devtools WS panel, plus source trace). Verified still present on current main (post-#36, 2026-08-26).
Symptom
Disposing a transport (close()) while its open() is still in flight leaves the WebSocket stuck as pending (CONNECTING) in devtools, indefinitely. Repeated workspace switches, and React StrictMode's dev mount→unmount→remount, accumulate several pending sockets that never clean up.
Root cause
connect() assigns the socket only after the handshake resolves; close() closes through that field (this.ws?.close()). When close() runs while open() is in flight, this.ws is null and the close is a no-op. The one path that disposes an in-flight socket is the close-epoch discard inside the connect body — but that only runs when open() resolves. If the handshake is slow or never completes (a dev proxy that doesn't upgrade promptly, a hung upgrade, a half-open LB connection), the discard never runs and the socket leaks in CONNECTING forever — close() never had a handle to abort it.
Expected
close() can abort a still-connecting socket. Options, in rough preference order:
- The transport retains a handle to the in-flight socket so
close() can close it regardless of readyState. With the pluggable async open() this likely means changing the open contract: open() returns the socket plus a whenOpen promise, or open accepts an AbortSignal the transport aborts on close(), or the transport owns socket creation and open only signals readiness.
- At minimum, document that a custom
open must make its socket abortable on close().
Impact
Medium. Deterministic socket leak on any dispose-before-open. Benign in production (handshakes complete in ms, the discard path fires), but user-visible in dev and a real leak wherever a handshake can hang. A consumer worked around it in ~15 lines at the open seam; every consumer that disposes transports eagerly needs the same guard.
Acceptance criteria
close() during an in-flight open() aborts/closes that socket (or its eventual resolution closes it immediately, including the never-resolves case via abort).
- If the
open contract changes, it's a documented, typed change — README + changelog — and the default browser open implements it.
- Test pins: close-during-handshake leaves no CONNECTING socket behind (observable via a fake
open that exposes its socket).
Same in-flight-open window as the connect()-contract issue; fix them coherently (one PR is fine), without bypassing ADR-0016 reconnect policy.
Found 2026-07-30 during agent-canvas's live-workspace delivery, against the 0.6.0 dist (real-browser observation, Chrome devtools WS panel, plus source trace). Verified still present on current
main(post-#36, 2026-08-26).Symptom
Disposing a transport (
close()) while itsopen()is still in flight leaves the WebSocket stuck aspending(CONNECTING) in devtools, indefinitely. Repeated workspace switches, and React StrictMode's dev mount→unmount→remount, accumulate several pending sockets that never clean up.Root cause
connect()assigns the socket only after the handshake resolves;close()closes through that field (this.ws?.close()). Whenclose()runs whileopen()is in flight,this.wsisnulland the close is a no-op. The one path that disposes an in-flight socket is the close-epoch discard inside the connect body — but that only runs whenopen()resolves. If the handshake is slow or never completes (a dev proxy that doesn't upgrade promptly, a hung upgrade, a half-open LB connection), the discard never runs and the socket leaks in CONNECTING forever —close()never had a handle to abort it.Expected
close()can abort a still-connecting socket. Options, in rough preference order:close()can close it regardless ofreadyState. With the pluggable asyncopen()this likely means changing theopencontract:open()returns the socket plus awhenOpenpromise, oropenaccepts anAbortSignalthe transport aborts onclose(), or the transport owns socket creation andopenonly signals readiness.openmust make its socket abortable onclose().Impact
Medium. Deterministic socket leak on any dispose-before-open. Benign in production (handshakes complete in ms, the discard path fires), but user-visible in dev and a real leak wherever a handshake can hang. A consumer worked around it in ~15 lines at the
openseam; every consumer that disposes transports eagerly needs the same guard.Acceptance criteria
close()during an in-flightopen()aborts/closes that socket (or its eventual resolution closes it immediately, including the never-resolves case via abort).opencontract changes, it's a documented, typed change — README + changelog — and the default browseropenimplements it.openthat exposes its socket).Same in-flight-open window as the connect()-contract issue; fix them coherently (one PR is fine), without bypassing ADR-0016 reconnect policy.