fix(web): stop authenticating WebSocket reconnect storm
Console evidence: on the authenticated layout, api.me() opened wss://.../api/ws?token=<JWT> and the browser logged "WebSocket is closed before the connection is established", then an uncaught connection_closed rejection — a self-inflicted reconnect storm. Root cause (two halves): 1. connectedToken was only assigned inside onopen, so during the CONNECTING window every other caller (RealtimeProvider's 2s poll, rpc's whenOpen) saw connectedToken !== token and called openWith again, tearing down the socket it had just dialled. 2. Replacing a socket left its late onclose to run onSocketClosed, which nulled this.socket and connectedToken — clobbering the successor and firing a spurious reconnect + rejected openPromise. Fix: - Set connectedToken the moment we commit to a dial (in openWith), so concurrent callers wait on the same openPromise instead of re-dialling. - Guard onopen/onmessage/onclose with `this.socket !== socket` so a replaced socket's late callbacks are ignored and cannot clobber its successor. Anonymous login escaped this (both tokens null); an authenticated connection (a real JWT) triggered it, matching the reported failure.
This commit is contained in:
+20
-2
@@ -142,12 +142,21 @@ export class RealtimeClient {
|
|||||||
: websocketUrl();
|
: websocketUrl();
|
||||||
const socket = new WebSocket(url);
|
const socket = new WebSocket(url);
|
||||||
this.socket = socket;
|
this.socket = socket;
|
||||||
|
// Record the token of THIS attempt immediately, not only on open. While the
|
||||||
|
// socket is CONNECTING, ``connectedToken`` must already equal the token we
|
||||||
|
// are dialling, so concurrent callers (the provider's poll, another RPC)
|
||||||
|
// wait on ``openPromise`` instead of tearing the socket down and dialling a
|
||||||
|
// second one — which is what produced "closed before established" + a
|
||||||
|
// reconnect storm.
|
||||||
|
this.connectedToken = token;
|
||||||
|
|
||||||
this.openPromise = new Promise<void>((resolve, reject) => {
|
this.openPromise = new Promise<void>((resolve, reject) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
|
// This socket may have been replaced (token change) before its handshake
|
||||||
|
// finished; if so, its late "open" belongs to a link we already dropped.
|
||||||
|
if (this.socket !== socket) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
this.connectedToken = token;
|
|
||||||
this.lastActivityAt = Date.now();
|
this.lastActivityAt = Date.now();
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
this.setStatus("live");
|
this.setStatus("live");
|
||||||
@@ -160,8 +169,17 @@ export class RealtimeClient {
|
|||||||
}
|
}
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
socket.onmessage = (message) => this.handleFrame(message.data);
|
socket.onmessage = (message) => {
|
||||||
|
// Ignore frames from a socket that was already replaced (e.g. a token
|
||||||
|
// change opened a new one): its late frames belong to a dead link.
|
||||||
|
if (this.socket !== socket) return;
|
||||||
|
this.handleFrame(message.data);
|
||||||
|
};
|
||||||
socket.onclose = () => {
|
socket.onclose = () => {
|
||||||
|
// A replaced socket's late close must not clobber its successor: without
|
||||||
|
// this guard, closing the old socket nulls ``this.socket``/token and
|
||||||
|
// fires a spurious reconnect -- the "closed before established" storm.
|
||||||
|
if (this.socket !== socket) return;
|
||||||
if (!settled) {
|
if (!settled) {
|
||||||
reject(
|
reject(
|
||||||
new ApiError(0, "connection_closed", "اتصال پیش از برقراری قطع شد."),
|
new ApiError(0, "connection_closed", "اتصال پیش از برقراری قطع شد."),
|
||||||
|
|||||||
Reference in New Issue
Block a user