[web] editor connection fixes (#23807)

* [haproxy] add support for xhr-polling in real-time

* [web] try 3 WebSockets then fallback to xhr-polling when (re-)connecting

* [web] ensure old socket.io transport has been closed when reconnecting

* [web] close xhr-polling transport before leaving the page

This will de-register the client, leave the project and clean up the
connected clients tracking ASAP.

* [web] rename constant to avoid confusion

Co-authored-by: Alf Eaton <alf.eaton@overleaf.com>

---------

Co-authored-by: Alf Eaton <alf.eaton@overleaf.com>
GitOrigin-RevId: 55e500c341145947e5589875438fc796394b6ae4
This commit is contained in:
Jakob Ackermann
2025-02-25 09:04:58 +00:00
committed by Copybot
co-authored by Alf Eaton
parent 82f2e47476
commit 4d857b7937
3 changed files with 30 additions and 2 deletions
@@ -22,7 +22,10 @@ const JOIN_PROJECT_RATE_LIMITED_DELAY = 15 * 1000
const RECONNECT_GRACEFULLY_RETRY_INTERVAL_MS = 5000
const MAX_RECONNECT_GRACEFULLY_INTERVAL_MS = 45 * 1000
const BEFORE_RECONNECT = 'beforeReconnect'
const MAX_RETRY_CONNECT = 5
const RETRY_WEBSOCKET = 3
const externalSocketHeartbeat = isSplitTestEnabled('external-socket-heartbeat')
@@ -49,6 +52,7 @@ export class ConnectionManager extends EventTarget {
private protocolVersion = -1
private readonly idleDisconnectInterval: number
private reconnectCountdownInterval = 0
private websocketFailureCount = 0
readonly socket: Socket
private userIsLeavingPage = false
private externalHeartbeatInterval?: number
@@ -69,6 +73,10 @@ export class ConnectionManager extends EventTarget {
window.addEventListener('online', () => this.onOnline())
window.addEventListener('beforeunload', () => {
this.userIsLeavingPage = true
if (this.socket.socket.transport?.name === 'xhr-polling') {
// Websockets will close automatically.
this.socket.socket.disconnect()
}
})
const parsedURL = new URL(
@@ -106,7 +114,7 @@ export class ConnectionManager extends EventTarget {
}
socket.on('connect', () => this.onConnect())
socket.on('disconnect', () => this.onDisconnect())
socket.on('disconnect', (reason: string) => this.onDisconnect(reason))
socket.on('error', () => this.onConnectError())
socket.on('connect_failed', () => this.onConnectError())
socket.on('joinProjectResponse', body => this.onJoinProjectResponse(body))
@@ -210,6 +218,9 @@ export class ConnectionManager extends EventTarget {
}
private onConnectError() {
if (this.socket.socket.transport?.name === 'websocket') {
this.websocketFailureCount++
}
if (this.connectionAttempt === null) return // ignore errors once connected.
if (this.connectionAttempt++ < MAX_RETRY_CONNECT) {
setTimeout(
@@ -242,9 +253,12 @@ export class ConnectionManager extends EventTarget {
15_000
)
}
// Reset on success regardless of transport. We want to upgrade back to websocket on reconnect.
this.websocketFailureCount = 0
}
private onDisconnect() {
private onDisconnect(reason: string) {
if (reason === BEFORE_RECONNECT) return // triggered from reconnect, ignore.
this.connectionAttempt = null
if (this.externalHeartbeatInterval) {
window.clearInterval(this.externalHeartbeatInterval)
@@ -406,6 +420,15 @@ export class ConnectionManager extends EventTarget {
})
this.addReconnectListeners()
this.socket.socket.transports = ['xhr-polling']
if (this.websocketFailureCount < RETRY_WEBSOCKET) {
this.socket.socket.transports.unshift('websocket')
}
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.socket.connect()
}
@@ -22,11 +22,15 @@ export type Socket = {
): void
socket: {
connected: boolean
connecting: boolean
connect(): void
onDisconnect(reason: string): void
disconnect(): void
sessionid: string
transport?: {
name: string
}
transports: string[]
}
disconnect(): void
}
@@ -15,6 +15,7 @@ class SocketShimBase {
const transparentMethods = [
'connect',
'disconnect',
'onDisconnect',
'emit',
'on',
'removeListener',