fix(web): make WebSocket RPC self-heal against half-open sockets

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.
This commit is contained in:
mjalmoudy94
2026-10-13 14:23:40 +03:30
parent 8066e9d055
commit 22034fd90a
+87 -39
View File
@@ -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 /** 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. */ * slow DB call, short enough that the UI does not hang forever. */
const RPC_TIMEOUT_MS = 60_000; 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 EventListener = (event: WsEvent) => void;
type StatusListener = (status: RealtimeStatus) => void; type StatusListener = (status: RealtimeStatus) => void;
@@ -76,6 +80,8 @@ export class RealtimeClient {
/** In-flight RPCs keyed by their client-generated id. */ /** In-flight RPCs keyed by their client-generated id. */
private readonly pendingRpc = new Map<string, PendingRpc>(); private readonly pendingRpc = new Map<string, PendingRpc>();
private rpcSeq = 0; 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) {} constructor(private readonly getToken: () => string | null = getStoredToken) {}
@@ -142,6 +148,7 @@ export class RealtimeClient {
socket.onopen = () => { socket.onopen = () => {
settled = true; settled = true;
this.connectedToken = token; this.connectedToken = token;
this.lastActivityAt = Date.now();
this.reconnectAttempts = 0; this.reconnectAttempts = 0;
this.setStatus("live"); this.setStatus("live");
this.startHeartbeat(); this.startHeartbeat();
@@ -194,45 +201,84 @@ export class RealtimeClient {
body: unknown, body: unknown,
options: { signal?: AbortSignal } = {}, options: { signal?: AbortSignal } = {},
): Promise<T> { ): Promise<T> {
return this.whenOpen().then( const run = (allowRetry: boolean): Promise<T> =>
() => this.ensureLive().then(
new Promise<T>((resolve, reject) => { () =>
const id = `r${++this.rpcSeq}-${Date.now().toString(36)}`; new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => { const id = `r${++this.rpcSeq}-${Date.now().toString(36)}`;
this.pendingRpc.delete(id); const timer = setTimeout(() => {
reject( this.pendingRpc.delete(id);
new ApiError( if (allowRetry) {
0, // The link looked alive but never answered: it is probably
"timeout", // 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();
}, RPC_TIMEOUT_MS); run(false).then(resolve, reject);
this.pendingRpc.set(id, { return;
resolve: resolve as (value: unknown) => void, }
reject, reject(
timer, new ApiError(
}); 0,
this.send({ action: "rpc", id, method, path, body }); "timeout",
const signal = options.signal; "درخواست از طریق وب‌سوکت زمان‌بر شد.",
if (signal) { ),
signal.addEventListener( );
"abort", }, RPC_TIMEOUT_MS);
() => { this.pendingRpc.set(id, {
const pending = this.pendingRpc.get(id); resolve: resolve as (value: unknown) => void,
if (pending) { reject,
clearTimeout(pending.timer); timer,
this.pendingRpc.delete(id); });
pending.reject( this.send({ action: "rpc", id, method, path, body });
new ApiError(0, "aborted", "درخواست لغو شد."), const signal = options.signal;
); if (signal) {
} signal.addEventListener(
}, "abort",
{ once: true }, () => {
); 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<void> {
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). */ /** Resolve once the socket is OPEN for the current token (opening if needed). */
@@ -304,6 +350,8 @@ export class RealtimeClient {
} catch { } catch {
return; return;
} }
// Any inbound frame proves the server→client direction is alive.
this.lastActivityAt = Date.now();
switch (frame.type) { switch (frame.type) {
case "event": case "event":