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:
+49
-1
@@ -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,12 +201,22 @@ export class RealtimeClient {
|
||||
body: unknown,
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): 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)}`;
|
||||
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,
|
||||
@@ -233,6 +250,35 @@ export class RealtimeClient {
|
||||
}
|
||||
}),
|
||||
);
|
||||
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":
|
||||
|
||||
Reference in New Issue
Block a user