[web] add external 15s heartbeat to socket.io connection (#22853)

* [web] add external 15s heartbeat to socket.io connection

* [web] extend debugging context for client errors

- include performance.now() timestamp for correlating other timings
- include connectionState, especially for new externalHeartbeat detail
- include spellCheckLanguage to check on client-side spelling impact

* [web] remove unnecessary hook dependency

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

* Refactor externalHeartbeat

* Add connectionManager to context

* Clear the interval earlier, and on connect

* [web] refactor handling of socket debugging info

* [web] add split-test for external socket heartbeat

* [web] fully remove connectionManager from connection context

---------

Co-authored-by: Alf Eaton <alf.eaton@overleaf.com>
GitOrigin-RevId: fbebe64f8aa207eb4fd4a8f27d522d1cac35f9d4
This commit is contained in:
Jakob Ackermann
2025-01-27 13:46:49 +00:00
committed by Copybot
parent 6ae773a565
commit 15ca1d003a
6 changed files with 125 additions and 20 deletions
@@ -333,6 +333,7 @@ const _ProjectController = {
const splitTests = [
!anonymous && 'bib-file-tpr-prompt',
'compile-log-events',
'external-socket-heartbeat',
'full-project-search',
'math-preview',
'null-test-share-modal',
@@ -1,8 +1,14 @@
import { ConnectionError, ConnectionState } from './types/connection-state'
import {
ConnectionError,
ConnectionState,
ExternalHeartbeat,
SocketDebuggingInfo,
} from './types/connection-state'
import SocketIoShim from '../../../ide/connection/SocketIoShim'
import getMeta from '../../../utils/meta'
import { Socket } from '@/features/ide-react/connection/types/socket'
import { debugConsole } from '@/utils/debugging'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
const ONE_HOUR_IN_MS = 1000 * 60 * 60
const TWO_MINUTES_IN_MS = 2 * 60 * 1000
@@ -18,6 +24,8 @@ const MAX_RECONNECT_GRACEFULLY_INTERVAL_MS = 45 * 1000
const MAX_RETRY_CONNECT = 5
const externalSocketHeartbeat = isSplitTestEnabled('external-socket-heartbeat')
const initialState: ConnectionState = {
readyState: WebSocket.CLOSED,
forceDisconnected: false,
@@ -43,6 +51,12 @@ export class ConnectionManager extends EventTarget {
private reconnectCountdownInterval = 0
readonly socket: Socket
private userIsLeavingPage = false
private externalHeartbeatInterval?: number
private externalHeartbeat: ExternalHeartbeat = {
currentStart: 0,
lastSuccess: 0,
lastLatency: 0,
}
constructor() {
super()
@@ -61,14 +75,18 @@ export class ConnectionManager extends EventTarget {
getMeta('ol-wsUrl') || '/socket.io',
window.origin
)
const query = new URLSearchParams({
projectId: getMeta('ol-project_id'),
})
if (externalSocketHeartbeat) {
query.set('esh', '1')
}
const socket = SocketIoShim.connect(parsedURL.origin, {
resource: parsedURL.pathname.slice(1),
'auto connect': false,
'connect timeout': 30 * 1000,
'force new connection': true,
query: new URLSearchParams({
projectId: getMeta('ol-project_id'),
}).toString(),
query: query.toString(),
reconnect: false,
}) as unknown as Socket
this.socket = socket
@@ -86,6 +104,7 @@ export class ConnectionManager extends EventTarget {
return
}
socket.on('connect', () => this.onConnect())
socket.on('disconnect', () => this.onDisconnect())
socket.on('error', () => this.onConnectError())
socket.on('connect_failed', () => this.onConnectError())
@@ -112,6 +131,17 @@ export class ConnectionManager extends EventTarget {
this.ensureIsConnected()
}
getSocketDebuggingInfo(): SocketDebuggingInfo {
return {
client_id: this.socket.socket?.sessionid,
transport: this.socket.socket?.transport?.name,
publicId: this.socket.publicId,
lastUserActivity: this.lastUserActivity,
connectionState: this.state,
externalHeartbeat: this.externalHeartbeat,
}
}
private changeState(state: ConnectionState) {
const previousState = this.state
this.state = state
@@ -198,8 +228,24 @@ export class ConnectionManager extends EventTarget {
}
}
private onConnect() {
if (externalSocketHeartbeat) {
if (this.externalHeartbeatInterval) {
window.clearInterval(this.externalHeartbeatInterval)
}
this.externalHeartbeatInterval = window.setInterval(
() => this.sendExternalHeartbeat(),
15_000
)
}
}
private onDisconnect() {
this.connectionAttempt = null
if (this.externalHeartbeatInterval) {
window.clearInterval(this.externalHeartbeatInterval)
}
this.externalHeartbeat.currentStart = 0
this.changeState({
...this.state,
readyState: WebSocket.CLOSED,
@@ -401,4 +447,20 @@ export class ConnectionManager extends EventTarget {
this.tryReconnect()
}
}
private sendExternalHeartbeat() {
const t0 = performance.now()
this.socket.emit('debug.getHostname', () => {
if (this.externalHeartbeat.currentStart !== t0) {
return
}
const t1 = performance.now()
this.externalHeartbeat = {
currentStart: 0,
lastSuccess: t1,
lastLatency: t1 - t0,
}
})
this.externalHeartbeat.currentStart = t0
}
}
@@ -18,3 +18,18 @@ export type ConnectionState = {
lastConnectionAttempt: number
error: '' | ConnectionError
}
export type ExternalHeartbeat = {
currentStart: number
lastSuccess: number
lastLatency: number
}
export type SocketDebuggingInfo = {
client_id?: string
publicId?: string
transport?: string
lastUserActivity: number
connectionState: ConnectionState
externalHeartbeat: ExternalHeartbeat
}
@@ -7,7 +7,10 @@ import {
useCallback,
useMemo,
} from 'react'
import { ConnectionState } from '../connection/types/connection-state'
import {
ConnectionState,
SocketDebuggingInfo,
} from '../connection/types/connection-state'
import {
ConnectionManager,
StateChangeEvent,
@@ -25,6 +28,7 @@ type ConnectionContextValue = {
tryReconnectNow: () => void
registerUserActivity: () => void
disconnect: () => void
getSocketDebuggingInfo: () => SocketDebuggingInfo
}
export const ConnectionContext = createContext<
@@ -75,6 +79,11 @@ export const ConnectionProvider: FC = ({ children }) => {
connectionManager.disconnect()
}, [connectionManager])
const getSocketDebuggingInfo = useCallback(
() => connectionManager.getSocketDebuggingInfo(),
[connectionManager]
)
// Reload the page on force disconnect. Doing this in React-land means that we
// can use useLocation(), which provides mockable location methods
useEffect(() => {
@@ -103,6 +112,7 @@ export const ConnectionProvider: FC = ({ children }) => {
tryReconnectNow,
registerUserActivity,
disconnect,
getSocketDebuggingInfo,
}),
[
connectionManager.socket,
@@ -113,6 +123,7 @@ export const ConnectionProvider: FC = ({ children }) => {
secondsUntilReconnect,
tryReconnectNow,
disconnect,
getSocketDebuggingInfo,
]
)
@@ -100,7 +100,7 @@ export const IdeReactProvider: FC = ({ children }) => {
// been called
const [projectJoined, setProjectJoined] = useState(false)
const { socket } = useConnectionContext()
const { socket, getSocketDebuggingInfo } = useConnectionContext()
const reportError = useCallback(
(error: any, meta?: Record<string, any>) => {
@@ -108,11 +108,12 @@ export const IdeReactProvider: FC = ({ children }) => {
...meta,
user_id: getMeta('ol-user_id'),
project_id: projectId,
client_id: socket.socket?.sessionid,
transport: socket.socket?.transport?.name,
client_now: new Date(),
performance_now: performance.now(),
release,
client_load: LOADED_AT,
spellCheckLanguage: scopeStore.get('project.spellCheckLanguage'),
...getSocketDebuggingInfo(),
}
const errorObj: Record<string, any> = {}
@@ -130,7 +131,7 @@ export const IdeReactProvider: FC = ({ children }) => {
},
})
},
[socket.socket, release, projectId]
[release, projectId, getSocketDebuggingInfo, scopeStore]
)
// Populate scope values when joining project, then fire project:joined event
@@ -166,9 +166,8 @@ export const ScopeDecorator = (
}
const ConnectionProvider: FC = ({ children }) => {
const [value] = useState(() => ({
socket: window._ide.socket as Socket,
connectionState: {
const [value] = useState(() => {
const connectionState: ConnectionState = {
readyState: WebSocket.OPEN,
forceDisconnected: false,
inactiveDisconnect: false,
@@ -176,14 +175,30 @@ const ConnectionProvider: FC = ({ children }) => {
forcedDisconnectDelay: 0,
lastConnectionAttempt: 0,
error: '',
} as ConnectionState,
isConnected: true,
isStillReconnecting: false,
secondsUntilReconnect: () => 0,
tryReconnectNow: () => {},
registerUserActivity: () => {},
disconnect: () => {},
}))
}
return {
socket: window._ide.socket as Socket,
connectionState,
isConnected: true,
isStillReconnecting: false,
secondsUntilReconnect: () => 0,
tryReconnectNow: () => {},
registerUserActivity: () => {},
disconnect: () => {},
getSocketDebuggingInfo: () => ({
client_id: 'fakeClientId',
transport: 'fakeTransport',
publicId: 'fakePublicId',
lastUserActivity: 0,
connectionState,
externalHeartbeat: {
currentStart: 0,
lastSuccess: 0,
lastLatency: 0,
},
}),
}
})
return (
<ConnectionContext.Provider value={value}>