diff --git a/apps/web/src/lib/ws.ts b/apps/web/src/lib/ws.ts index 1a9555b..566627d 100644 --- a/apps/web/src/lib/ws.ts +++ b/apps/web/src/lib/ws.ts @@ -142,12 +142,21 @@ export class RealtimeClient { : websocketUrl(); const socket = new WebSocket(url); this.socket = socket; + // Record the token of THIS attempt immediately, not only on open. While the + // socket is CONNECTING, ``connectedToken`` must already equal the token we + // are dialling, so concurrent callers (the provider's poll, another RPC) + // wait on ``openPromise`` instead of tearing the socket down and dialling a + // second one — which is what produced "closed before established" + a + // reconnect storm. + this.connectedToken = token; this.openPromise = new Promise((resolve, reject) => { let settled = false; socket.onopen = () => { + // This socket may have been replaced (token change) before its handshake + // finished; if so, its late "open" belongs to a link we already dropped. + if (this.socket !== socket) return; settled = true; - this.connectedToken = token; this.lastActivityAt = Date.now(); this.reconnectAttempts = 0; this.setStatus("live"); @@ -160,8 +169,17 @@ export class RealtimeClient { } resolve(); }; - socket.onmessage = (message) => this.handleFrame(message.data); + socket.onmessage = (message) => { + // Ignore frames from a socket that was already replaced (e.g. a token + // change opened a new one): its late frames belong to a dead link. + if (this.socket !== socket) return; + this.handleFrame(message.data); + }; socket.onclose = () => { + // A replaced socket's late close must not clobber its successor: without + // this guard, closing the old socket nulls ``this.socket``/token and + // fires a spurious reconnect -- the "closed before established" storm. + if (this.socket !== socket) return; if (!settled) { reject( new ApiError(0, "connection_closed", "اتصال پیش از برقراری قطع شد."),