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
* 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<string, PendingRpc>();
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<T> {
return this.whenOpen().then(
() =>
new Promise<T>((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<T> =>
this.ensureLive().then(
() =>
new Promise<T>((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<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). */
@@ -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":