From dda94cdfbc402f3e8916c2b65ec066679a240fda Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Wed, 26 Mar 2025 09:36:41 +0000 Subject: [PATCH] [web] ensure that only a single socket.io transport is connected (#24422) GitOrigin-RevId: 9397b0c85f0a889385d4761945e976ada7aa537b --- .../connection/connection-manager.ts | 9 +-- .../ide-react/connection/types/socket.ts | 2 +- .../js/ide/connection/SocketIoShim.js | 59 ++++++++++++++++++- 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/services/web/frontend/js/features/ide-react/connection/connection-manager.ts b/services/web/frontend/js/features/ide-react/connection/connection-manager.ts index a72142f4d5..8475c99779 100644 --- a/services/web/frontend/js/features/ide-react/connection/connection-manager.ts +++ b/services/web/frontend/js/features/ide-react/connection/connection-manager.ts @@ -25,8 +25,6 @@ const MAX_RECONNECT_GRACEFULLY_INTERVAL_MS = getMeta( 'ol-maxReconnectGracefullyIntervalMs' ) -const BEFORE_RECONNECT = 'beforeReconnect' - const MAX_RETRY_CONNECT = 5 const RETRY_WEBSOCKET = 3 @@ -117,7 +115,7 @@ export class ConnectionManager extends EventTarget { } socket.on('connect', () => this.onConnect()) - socket.on('disconnect', (reason: string) => this.onDisconnect(reason)) + socket.on('disconnect', () => this.onDisconnect()) socket.on('error', err => this.onConnectError(err)) socket.on('connect_failed', err => this.onConnectError(err)) socket.on('joinProjectResponse', body => this.onJoinProjectResponse(body)) @@ -276,8 +274,7 @@ export class ConnectionManager extends EventTarget { this.websocketFailureCount = 0 } - private onDisconnect(reason: string) { - if (reason === BEFORE_RECONNECT) return // triggered from reconnect, ignore. + private onDisconnect() { this.connectionAttempt = null if (this.externalHeartbeatInterval) { window.clearInterval(this.externalHeartbeatInterval) @@ -446,7 +443,7 @@ export class ConnectionManager extends EventTarget { if (this.socket.socket.connecting || this.socket.socket.connected) { // Ensure the old transport has been cleaned up. // Socket.disconnect() does not accept a parameter. Go one level deeper. - this.socket.socket.onDisconnect(BEFORE_RECONNECT) + this.socket.forceDisconnectWithoutEvent() } this.socket.socket.connect() } diff --git a/services/web/frontend/js/features/ide-react/connection/types/socket.ts b/services/web/frontend/js/features/ide-react/connection/types/socket.ts index 75d43cbcf0..7f5d889c94 100644 --- a/services/web/frontend/js/features/ide-react/connection/types/socket.ts +++ b/services/web/frontend/js/features/ide-react/connection/types/socket.ts @@ -34,7 +34,6 @@ export type Socket = { connected: boolean connecting: boolean connect(): void - onDisconnect(reason: string): void disconnect(): void sessionid: string transport?: { @@ -43,4 +42,5 @@ export type Socket = { transports: string[] } disconnect(): void + forceDisconnectWithoutEvent(): void } diff --git a/services/web/frontend/js/ide/connection/SocketIoShim.js b/services/web/frontend/js/ide/connection/SocketIoShim.js index b2dc23cab4..40ada0485a 100644 --- a/services/web/frontend/js/ide/connection/SocketIoShim.js +++ b/services/web/frontend/js/ide/connection/SocketIoShim.js @@ -11,11 +11,12 @@ class SocketShimBase { constructor(socket) { this._socket = socket } + + forceDisconnectWithoutEvent() {} } const transparentMethods = [ 'connect', 'disconnect', - 'onDisconnect', 'emit', 'on', 'removeListener', @@ -64,6 +65,62 @@ class SocketShimV0 extends SocketShimBase { constructor(socket) { super(socket) this.socket = this._socket.socket + const self = this + Object.defineProperty(this.socket, 'transport', { + get() { + return self._transport + }, + set(v) { + self.forceDisconnectWithoutEvent() + self._transport = v + }, + }) + } + + forceDisconnectWithoutEvent() { + clearTimeout(this.socket.heartbeatTimeoutTimer) + if (this._transport) this.forceCloseTransport(this._transport) + } + + forceCloseTransport(transport) { + transport.clearTimeouts() + if (transport instanceof io.Transport.websocket) { + // retry closing + transport.websocket.onopen = transport.websocket.onmessage = () => + transport.websocket.close() + // mute close/error handler + transport.websocket.onclose = transport.websocket.onerror = () => {} + // disconnect + try { + transport.websocket.close() + } catch {} + } else if (transport instanceof io.Transport['xhr-polling']) { + // mute data/close handler and block new polling GET requests + transport.onData = transport.onClose = transport.get = () => {} + // abort pending long-polling/POST request + for (const xhr of [transport.xhr, transport.sendXHR]) { + if (!xhr) continue // not pending + // mute xhr callbacks + xhr.onreadystatechange = xhr.onload = xhr.onerror = () => {} + try { + xhr.abort() + } catch {} + } + transport.xhr = transport.sendXHR = null + // Mark long-polling client as disconnected to avoid "ghost" connected client. + fetch(transport.prepareUrl() + '/?disconnect=1', { + // Let the request continue after navigating away from or reloading the page. + keepalive: true, + }) + // Avoid leaving a dangling response on the wire. + .then(res => res.text()) + .catch(() => {}) + } else { + try { + transport.close() + } catch {} + debugConsole.warn('unexpected socket.io transport', transport) + } } }