From e50c86c4514cfd37d69ca65ff23a90350ed579b4 Mon Sep 17 00:00:00 2001 From: mjalmoudy94 Date: Tue, 13 Oct 2026 16:07:37 +0330 Subject: [PATCH] fix(web): stop authenticating WebSocket reconnect storm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Console evidence: on the authenticated layout, api.me() opened wss://.../api/ws?token= and the browser logged "WebSocket is closed before the connection is established", then an uncaught connection_closed rejection — a self-inflicted reconnect storm. Root cause (two halves): 1. connectedToken was only assigned inside onopen, so during the CONNECTING window every other caller (RealtimeProvider's 2s poll, rpc's whenOpen) saw connectedToken !== token and called openWith again, tearing down the socket it had just dialled. 2. Replacing a socket left its late onclose to run onSocketClosed, which nulled this.socket and connectedToken — clobbering the successor and firing a spurious reconnect + rejected openPromise. Fix: - Set connectedToken the moment we commit to a dial (in openWith), so concurrent callers wait on the same openPromise instead of re-dialling. - Guard onopen/onmessage/onclose with `this.socket !== socket` so a replaced socket's late callbacks are ignored and cannot clobber its successor. Anonymous login escaped this (both tokens null); an authenticated connection (a real JWT) triggered it, matching the reported failure. --- apps/web/src/lib/ws.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) 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", "اتصال پیش از برقراری قطع شد."),