SIGN IN SIGN UP

fix(ws): make WsClient.connect() re-entrant so boot opens one socket, not five (#26)

`connect()` checks its single-socket guard on `this.ws`, but `this.ws` is only
assigned AFTER the `/auth/ws-ticket` fetch awaits. AuthedApp's mount effect calls
bootMessagesStream, bootParticipants, bootConversations, bootWhispers and
bootComputers back-to-back in one synchronous tick, and each calls `ws.connect()`
exactly once — each store has its OWN module-local `wsBound` flag, so none of
them suppresses another.

All five therefore pass the guard while `this.ws` is still null, each fetches its
own single-use ticket, and each constructs a WebSocket. `this.ws` keeps the last
one; the other four stay OPEN, and every one of their `onmessage` handlers fans
into the same shared `listeners` set.

`applyEvent` for `message.delta` is an accumulator, not idempotent — the body is
built as `(cur?.body ?? '') + e.delta` — so every streaming agent reply rendered
each chunk five times ("HelloHelloHelloHelloHello there there there…") until the
reply completed and the final message replaced it. Each `hello` frame also drove
five concurrent reloadConversation + conversations.reload + participants.refresh
storms, and the server carried five WS sessions per client.

Memoize the in-flight attempt so concurrent callers ride the first one — the same
coalescing idiom already used by orchestrator.ts's ensurePod/inFlight and
LinkPreview's inflight map. The memo clears once the attempt settles, so a later
connect still gets a fresh socket.

Also make `onclose` socket-identity-aware. `reconnect()` closes the old socket
and opens its replacement immediately, but the close event lands a tick later:
without the identity check that late event nulls out the reference to a healthy
LIVE socket, so `isOpen()` starts reporting false and `send()` silently drops
typing frames, and it schedules another socket on top — back to two sockets
sharing one listener set. This also removes the race where `reconnect()` clears
`intentionalClose` synchronously before the async `onclose` reads it.

Measured with a stub of the ticket fetch + WebSocket, firing the same five
same-tick connects: 5 sockets before, 1 after, with a later reconnect still
opening one.
X
Xialie Zhuang committed
a9d6f7cfa312375ca0218867adca3b806e416964
Parent: a50b27d
Committed by GitHub <noreply@github.com> on 8/19/2026, 8:00:01 PM