From 22034fd90afb503f509773a8cc958a18a109d6e5 Mon Sep 17 00:00:00 2001 From: mjalmoudy94 Date: Tue, 13 Oct 2026 14:23:40 +0330 Subject: [PATCH] fix(web): make WebSocket RPC self-heal against half-open sockets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A login over the socket could hang the full 60s timeout when the reverse proxy allowed the WebSocket handshake but dropped the server->client direction without closing the socket (so onclose never fired and the client saw a live-but-unresponsive link). - Track last inbound frame time (any frame, incl. hello/pong) as proof the link is bidirectional. - ensureLive(): before sending an RPC, replace a socket that has been silent for >30s (SOCKET_STALE_MS). - On timeout, recycle the connection and retry once on a fresh socket instead of hard-failing; only idempotent-damage-free flows are affected since the first attempt is torn down first. Reduces the observed "درخواست از طریق وب‌سوکت زمان‌بر شد" on login to a single reconnect+retry rather than a hard 60s wait. --- apps/web/src/lib/ws.ts | 126 ++++++++++++++++++++++++++++------------- 1 file changed, 87 insertions(+), 39 deletions(-) diff --git a/apps/web/src/lib/ws.ts b/apps/web/src/lib/ws.ts index 3645b0c..1a9555b 100644 --- a/apps/web/src/lib/ws.ts +++ b/apps/web/src/lib/ws.ts @@ -48,6 +48,10 @@ const PONG_TIMEOUT_MS = 10_000; /** Upper bound on a single RPC before it is failed locally. Long enough for a * slow DB call, short enough that the UI does not hang forever. */ const RPC_TIMEOUT_MS = 60_000; +/** A socket that has not received any inbound frame for this long is presumed + * half-open: a proxy dropped the server→client direction without closing the + * socket, so it will never answer. We reconnect before sending on it. */ +const SOCKET_STALE_MS = 30_000; type EventListener = (event: WsEvent) => void; type StatusListener = (status: RealtimeStatus) => void; @@ -76,6 +80,8 @@ export class RealtimeClient { /** In-flight RPCs keyed by their client-generated id. */ private readonly pendingRpc = new Map(); private rpcSeq = 0; + /** Timestamp of the last frame we received; proves the link is bidirectional. */ + private lastActivityAt = 0; constructor(private readonly getToken: () => string | null = getStoredToken) {} @@ -142,6 +148,7 @@ export class RealtimeClient { socket.onopen = () => { settled = true; this.connectedToken = token; + this.lastActivityAt = Date.now(); this.reconnectAttempts = 0; this.setStatus("live"); this.startHeartbeat(); @@ -194,45 +201,84 @@ export class RealtimeClient { body: unknown, options: { signal?: AbortSignal } = {}, ): Promise { - return this.whenOpen().then( - () => - new Promise((resolve, reject) => { - const id = `r${++this.rpcSeq}-${Date.now().toString(36)}`; - const timer = setTimeout(() => { - this.pendingRpc.delete(id); - reject( - new ApiError( - 0, - "timeout", - "درخواست از طریق وب‌سوکت زمان‌بر شد.", - ), - ); - }, RPC_TIMEOUT_MS); - this.pendingRpc.set(id, { - resolve: resolve as (value: unknown) => void, - reject, - timer, - }); - this.send({ action: "rpc", id, method, path, body }); - const signal = options.signal; - if (signal) { - signal.addEventListener( - "abort", - () => { - const pending = this.pendingRpc.get(id); - if (pending) { - clearTimeout(pending.timer); - this.pendingRpc.delete(id); - pending.reject( - new ApiError(0, "aborted", "درخواست لغو شد."), - ); - } - }, - { once: true }, - ); - } - }), - ); + const run = (allowRetry: boolean): Promise => + this.ensureLive().then( + () => + new Promise((resolve, reject) => { + const id = `r${++this.rpcSeq}-${Date.now().toString(36)}`; + const timer = setTimeout(() => { + this.pendingRpc.delete(id); + if (allowRetry) { + // The link looked alive but never answered: it is probably + // half-open (a proxy dropped the server→client direction without + // closing the socket, so onclose never fires). Recycle it and + // retry once on a fresh connection so login/load does not hard-fail. + this.forceReconnect(); + run(false).then(resolve, reject); + return; + } + reject( + new ApiError( + 0, + "timeout", + "درخواست از طریق وب‌سوکت زمان‌بر شد.", + ), + ); + }, RPC_TIMEOUT_MS); + this.pendingRpc.set(id, { + resolve: resolve as (value: unknown) => void, + reject, + timer, + }); + this.send({ action: "rpc", id, method, path, body }); + const signal = options.signal; + if (signal) { + signal.addEventListener( + "abort", + () => { + const pending = this.pendingRpc.get(id); + if (pending) { + clearTimeout(pending.timer); + this.pendingRpc.delete(id); + pending.reject( + new ApiError(0, "aborted", "درخواست لغو شد."), + ); + } + }, + { once: true }, + ); + } + }), + ); + return run(true); + } + + /** Open (reconnecting first if the socket has gone silent) and resolve when + * ready to send. A socket with no inbound frame for ``SOCKET_STALE_MS`` is + * treated as half-open and replaced before we send on it. */ + private ensureLive(): Promise { + const token = this.getToken(); + const stale = + !!this.socket && + this.socket.readyState === WebSocket.OPEN && + Date.now() - this.lastActivityAt > SOCKET_STALE_MS; + if (stale) this.openWith(token); + return this.whenOpen(); + } + + /** Tear down the current socket unconditionally and open a fresh one. Used + * when a request times out on a link that never reported a close. */ + private forceReconnect(): void { + this.closedByUs = false; + if (this.socket) { + try { + this.socket.close(); + } catch { + /* already gone */ + } + this.socket = null; + } + this.openWith(this.getToken()); } /** Resolve once the socket is OPEN for the current token (opening if needed). */ @@ -304,6 +350,8 @@ export class RealtimeClient { } catch { return; } + // Any inbound frame proves the server→client direction is alive. + this.lastActivityAt = Date.now(); switch (frame.type) { case "event":