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:
+87
-39
@@ -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":
|
||||||
|
|||||||
Reference in New Issue
Block a user