connection-manager.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531
  1. import {
  2. ConnectionError,
  3. ConnectionState,
  4. ExternalHeartbeat,
  5. SocketDebuggingInfo,
  6. } from './types/connection-state'
  7. import SocketIoShim from '../../../ide/connection/SocketIoShim'
  8. import getMeta from '../../../utils/meta'
  9. import { Socket } from '@/features/ide-react/connection/types/socket'
  10. import { debugConsole } from '@/utils/debugging'
  11. import { isSplitTestEnabled } from '@/utils/splitTestUtils'
  12. const ONE_HOUR_IN_MS = 1000 * 60 * 60
  13. const TWO_MINUTES_IN_MS = 2 * 60 * 1000
  14. const DISCONNECT_AFTER_MS = ONE_HOUR_IN_MS * 24
  15. const CONNECTION_ERROR_RECONNECT_DELAY = 1000
  16. const USER_ACTIVITY_RECONNECT_NOW_DELAY = 1000
  17. const USER_ACTIVITY_RECONNECT_DELAY = 5000
  18. const JOIN_PROJECT_RATE_LIMITED_DELAY = 15 * 1000
  19. const BACK_OFF_RECONNECT_OFFLINE = 5000
  20. const RECONNECT_GRACEFULLY_RETRY_INTERVAL_MS = 5000
  21. const MAX_RECONNECT_GRACEFULLY_INTERVAL_MS = getMeta(
  22. 'ol-maxReconnectGracefullyIntervalMs'
  23. )
  24. const MAX_RETRY_CONNECT = 5
  25. const RETRY_WEBSOCKET = 3
  26. const externalSocketHeartbeat = isSplitTestEnabled('external-socket-heartbeat')
  27. const initialState: ConnectionState = {
  28. readyState: WebSocket.CLOSED,
  29. forceDisconnected: false,
  30. inactiveDisconnect: false,
  31. lastConnectionAttempt: 0,
  32. reconnectAt: null,
  33. forcedDisconnectDelay: 0,
  34. error: '',
  35. }
  36. export class StateChangeEvent extends CustomEvent<{
  37. state: ConnectionState
  38. previousState: ConnectionState
  39. }> {}
  40. export class ConnectionManager extends EventTarget {
  41. state: ConnectionState = initialState
  42. private connectionAttempt: number | null = null
  43. private gracefullyReconnectUntil = 0
  44. private lastUserActivity: number
  45. private protocolVersion = -1
  46. private readonly idleDisconnectInterval: number
  47. private reconnectCountdownInterval = 0
  48. private websocketFailureCount = 0
  49. readonly socket: Socket
  50. private userIsLeavingPage = false
  51. private externalHeartbeatInterval?: number
  52. private externalHeartbeat: ExternalHeartbeat = {
  53. currentStart: 0,
  54. lastSuccess: 0,
  55. lastLatency: 0,
  56. }
  57. constructor() {
  58. super()
  59. this.lastUserActivity = performance.now()
  60. this.idleDisconnectInterval = window.setInterval(() => {
  61. this.disconnectIfIdleSince(DISCONNECT_AFTER_MS)
  62. }, ONE_HOUR_IN_MS)
  63. window.addEventListener('online', () => this.onOnline())
  64. window.addEventListener('beforeunload', () => {
  65. this.userIsLeavingPage = true
  66. if (this.socket.socket.transport?.name === 'xhr-polling') {
  67. // Websockets will close automatically.
  68. this.socket.socket.disconnect()
  69. }
  70. })
  71. const parsedURL = new URL(
  72. getMeta('ol-wsUrl') || '/socket.io',
  73. window.origin
  74. )
  75. const query = new URLSearchParams({
  76. projectId: getMeta('ol-project_id'),
  77. })
  78. if (externalSocketHeartbeat) {
  79. query.set('esh', '1')
  80. query.set('ssp', '1') // with server-side ping
  81. }
  82. const socket = SocketIoShim.connect(parsedURL.origin, {
  83. resource: parsedURL.pathname.slice(1),
  84. 'auto connect': false,
  85. 'connect timeout': 30 * 1000,
  86. 'force new connection': true,
  87. query: query.toString(),
  88. reconnect: false,
  89. }) as unknown as Socket
  90. this.socket = socket
  91. // bail out if socket.io failed to load (e.g. the real-time server is down)
  92. if (typeof window.io !== 'object') {
  93. this.switchToWsFallbackIfPossible()
  94. debugConsole.error(
  95. 'Socket.io javascript not loaded. Please check that the real-time service is running and accessible.'
  96. )
  97. this.changeState({
  98. ...this.state,
  99. error: 'io-not-loaded',
  100. })
  101. return
  102. }
  103. socket.on('connect', () => this.onConnect())
  104. socket.on('disconnect', () => this.onDisconnect())
  105. socket.on('error', err => this.onConnectError(err))
  106. socket.on('connect_failed', err => this.onConnectError(err))
  107. socket.on('joinProjectResponse', body => this.onJoinProjectResponse(body))
  108. socket.on('connectionRejected', err => this.onConnectionRejected(err))
  109. socket.on('reconnectGracefully', () => this.onReconnectGracefully())
  110. socket.on('forceDisconnect', (_, delay) => this.onForceDisconnect(delay))
  111. socket.on(
  112. 'serverPing',
  113. (counter, timestamp, serverTransport, serverSessionId) =>
  114. this.sendPingResponse(
  115. counter,
  116. timestamp,
  117. serverTransport,
  118. serverSessionId
  119. )
  120. )
  121. this.tryReconnect()
  122. }
  123. close(error: ConnectionError) {
  124. this.onForceDisconnect(0, error)
  125. }
  126. tryReconnectNow() {
  127. this.tryReconnectWithBackoff(USER_ACTIVITY_RECONNECT_NOW_DELAY)
  128. }
  129. // Called when document is clicked or the editor cursor changes
  130. registerUserActivity() {
  131. this.lastUserActivity = performance.now()
  132. this.userIsLeavingPage = false
  133. this.ensureIsConnected()
  134. }
  135. getSocketDebuggingInfo(): SocketDebuggingInfo {
  136. return {
  137. client_id: this.socket.socket?.sessionid,
  138. transport: this.socket.socket?.transport?.name,
  139. publicId: this.socket.publicId,
  140. lastUserActivity: this.lastUserActivity,
  141. connectionState: this.state,
  142. externalHeartbeat: this.externalHeartbeat,
  143. }
  144. }
  145. private changeState(state: ConnectionState) {
  146. const previousState = this.state
  147. this.state = state
  148. debugConsole.log('[ConnectionManager] changed state', {
  149. previousState,
  150. state,
  151. })
  152. this.dispatchEvent(
  153. new StateChangeEvent('statechange', { detail: { state, previousState } })
  154. )
  155. }
  156. private switchToWsFallbackIfPossible() {
  157. const search = new URLSearchParams(window.location.search)
  158. if (getMeta('ol-wsUrl') && search.get('ws') !== 'fallback') {
  159. // if we tried to boot from a custom real-time backend and failed,
  160. // try reloading and falling back to the siteUrl
  161. search.set('ws', 'fallback')
  162. window.location.search = search.toString()
  163. return true
  164. }
  165. return false
  166. }
  167. private onOnline() {
  168. if (!this.state.inactiveDisconnect) this.ensureIsConnected()
  169. }
  170. private onConnectionRejected(err: any) {
  171. switch (err?.message) {
  172. case 'retry': // pending real-time shutdown
  173. this.startAutoReconnectCountdown(0)
  174. break
  175. case 'rate-limit hit when joining project': // rate-limited
  176. this.changeState({
  177. ...this.state,
  178. error: 'rate-limited',
  179. })
  180. break
  181. case 'not authorized': // not logged in
  182. case 'invalid session': // expired session
  183. this.changeState({
  184. ...this.state,
  185. error: 'not-logged-in',
  186. forceDisconnected: true,
  187. })
  188. break
  189. case 'project not found': // project has been deleted
  190. this.changeState({
  191. ...this.state,
  192. error: 'project-deleted',
  193. forceDisconnected: true,
  194. })
  195. break
  196. default:
  197. this.changeState({
  198. ...this.state,
  199. error: 'unable-to-join',
  200. })
  201. break
  202. }
  203. }
  204. private onConnectError(err: any) {
  205. if (
  206. this.socket.socket.transport?.name === 'websocket' &&
  207. err instanceof Event &&
  208. err.target instanceof WebSocket
  209. ) {
  210. this.websocketFailureCount++
  211. }
  212. if (this.connectionAttempt === null) return // ignore errors once connected.
  213. if (this.connectionAttempt++ < MAX_RETRY_CONNECT) {
  214. setTimeout(
  215. () => {
  216. if (this.canReconnect()) this.socket.socket.connect()
  217. },
  218. // slow down when potentially offline
  219. (navigator.onLine ? 0 : BACK_OFF_RECONNECT_OFFLINE) +
  220. // add jitter to spread reconnects
  221. this.connectionAttempt *
  222. (1 + Math.random()) *
  223. CONNECTION_ERROR_RECONNECT_DELAY
  224. )
  225. } else {
  226. if (!this.switchToWsFallbackIfPossible()) {
  227. this.disconnect()
  228. this.changeState({
  229. ...this.state,
  230. error: 'unable-to-connect',
  231. })
  232. }
  233. }
  234. }
  235. private onConnect() {
  236. if (externalSocketHeartbeat) {
  237. if (this.externalHeartbeatInterval) {
  238. window.clearInterval(this.externalHeartbeatInterval)
  239. }
  240. if (this.socket.socket.transport?.name === 'websocket') {
  241. // Do not enable external heartbeat on polling transports.
  242. this.externalHeartbeatInterval = window.setInterval(
  243. () => this.sendExternalHeartbeat(),
  244. 15_000
  245. )
  246. }
  247. }
  248. // Reset on success regardless of transport. We want to upgrade back to websocket on reconnect.
  249. this.websocketFailureCount = 0
  250. }
  251. private onDisconnect() {
  252. this.connectionAttempt = null
  253. if (this.externalHeartbeatInterval) {
  254. window.clearInterval(this.externalHeartbeatInterval)
  255. }
  256. this.externalHeartbeat.currentStart = 0
  257. this.changeState({
  258. ...this.state,
  259. readyState: WebSocket.CLOSED,
  260. })
  261. if (this.disconnectIfIdleSince(DISCONNECT_AFTER_MS)) return
  262. if (this.state.error === 'rate-limited') {
  263. this.tryReconnectWithBackoff(JOIN_PROJECT_RATE_LIMITED_DELAY)
  264. } else {
  265. this.startAutoReconnectCountdown(0)
  266. }
  267. }
  268. private onForceDisconnect(
  269. delay: number,
  270. error: ConnectionError = 'maintenance'
  271. ) {
  272. clearInterval(this.idleDisconnectInterval)
  273. clearTimeout(this.reconnectCountdownInterval)
  274. window.removeEventListener('online', this.onOnline)
  275. window.setTimeout(() => this.disconnect(), 1000 * delay)
  276. this.changeState({
  277. ...this.state,
  278. forceDisconnected: true,
  279. forcedDisconnectDelay: delay,
  280. error,
  281. })
  282. }
  283. private onJoinProjectResponse({
  284. protocolVersion,
  285. publicId,
  286. }: {
  287. protocolVersion: number
  288. publicId: string
  289. }) {
  290. if (
  291. this.protocolVersion !== -1 &&
  292. this.protocolVersion !== protocolVersion
  293. ) {
  294. this.onForceDisconnect(0, 'protocol-changed')
  295. return
  296. }
  297. this.protocolVersion = protocolVersion
  298. this.socket.publicId = publicId
  299. this.connectionAttempt = null
  300. this.changeState({
  301. ...this.state,
  302. readyState: WebSocket.OPEN,
  303. error: '',
  304. reconnectAt: null,
  305. })
  306. }
  307. private onReconnectGracefully() {
  308. // Disconnect idle users a little earlier than the 24h limit.
  309. if (this.disconnectIfIdleSince(DISCONNECT_AFTER_MS * 0.75)) return
  310. if (this.gracefullyReconnectUntil) return
  311. this.gracefullyReconnectUntil =
  312. performance.now() + MAX_RECONNECT_GRACEFULLY_INTERVAL_MS
  313. this.tryReconnectGracefully()
  314. }
  315. private canReconnect(): boolean {
  316. if (this.state.readyState === WebSocket.OPEN) return false // no need to reconnect
  317. if (this.state.forceDisconnected) return false // reconnecting blocked
  318. return true
  319. }
  320. private isReconnectingSoon(ms: number): boolean {
  321. if (!this.state.reconnectAt) return false
  322. return this.state.reconnectAt - performance.now() <= ms
  323. }
  324. private hasReconnectedRecently(ms: number): boolean {
  325. return performance.now() - this.state.lastConnectionAttempt < ms
  326. }
  327. private isUserInactiveSince(since: number): boolean {
  328. return performance.now() - this.lastUserActivity > since
  329. }
  330. private disconnectIfIdleSince(threshold: number): boolean {
  331. if (!this.isUserInactiveSince(threshold)) return false
  332. const previouslyClosed = this.state.readyState === WebSocket.CLOSED
  333. this.changeState({
  334. ...this.state,
  335. readyState: WebSocket.CLOSED,
  336. inactiveDisconnect: true,
  337. })
  338. if (!previouslyClosed) {
  339. this.socket.disconnect()
  340. }
  341. return true
  342. }
  343. private disconnect() {
  344. this.changeState({
  345. ...this.state,
  346. readyState: WebSocket.CLOSED,
  347. })
  348. this.socket.disconnect()
  349. }
  350. private ensureIsConnected() {
  351. if (this.state.readyState === WebSocket.OPEN) return
  352. this.tryReconnectWithBackoff(
  353. this.state.error === 'rate-limited'
  354. ? JOIN_PROJECT_RATE_LIMITED_DELAY
  355. : USER_ACTIVITY_RECONNECT_DELAY
  356. )
  357. }
  358. private startAutoReconnectCountdown(backoff: number) {
  359. if (this.userIsLeavingPage) return
  360. if (!this.canReconnect()) return
  361. let countdown
  362. if (this.isUserInactiveSince(TWO_MINUTES_IN_MS)) {
  363. countdown = 60 + Math.floor(Math.random() * 2 * 60)
  364. } else {
  365. countdown = 3 + Math.floor(Math.random() * 7)
  366. }
  367. const ms = backoff + countdown * 1000
  368. if (this.isReconnectingSoon(ms)) return
  369. this.changeState({
  370. ...this.state,
  371. reconnectAt: performance.now() + ms,
  372. })
  373. clearTimeout(this.reconnectCountdownInterval)
  374. this.reconnectCountdownInterval = window.setTimeout(() => {
  375. if (this.isReconnectingSoon(0)) {
  376. this.tryReconnect()
  377. }
  378. }, ms)
  379. }
  380. private tryReconnect() {
  381. this.gracefullyReconnectUntil = 0
  382. this.changeState({
  383. ...this.state,
  384. reconnectAt: null,
  385. })
  386. if (!this.canReconnect()) return
  387. this.connectionAttempt = 0
  388. this.changeState({
  389. ...this.state,
  390. readyState: WebSocket.CONNECTING,
  391. error: '',
  392. inactiveDisconnect: false,
  393. lastConnectionAttempt: performance.now(),
  394. })
  395. this.addReconnectListeners()
  396. this.socket.socket.transports = ['xhr-polling']
  397. if (this.websocketFailureCount < RETRY_WEBSOCKET) {
  398. this.socket.socket.transports.unshift('websocket')
  399. }
  400. if (this.socket.socket.connecting || this.socket.socket.connected) {
  401. // Ensure the old transport has been cleaned up.
  402. // Socket.disconnect() does not accept a parameter. Go one level deeper.
  403. this.socket.forceDisconnectWithoutEvent()
  404. }
  405. this.socket.socket.connect()
  406. }
  407. private addReconnectListeners() {
  408. const handleFailure = () => {
  409. removeSocketListeners()
  410. this.startAutoReconnectCountdown(
  411. // slow down when potentially offline
  412. navigator.onLine ? 0 : BACK_OFF_RECONNECT_OFFLINE
  413. )
  414. }
  415. const handleSuccess = () => {
  416. removeSocketListeners()
  417. }
  418. const removeSocketListeners = () => {
  419. this.socket.removeListener('error', handleFailure)
  420. this.socket.removeListener('connect', handleSuccess)
  421. }
  422. this.socket.on('error', handleFailure)
  423. this.socket.on('connect', handleSuccess)
  424. }
  425. private tryReconnectGracefully() {
  426. if (
  427. this.state.readyState === WebSocket.CLOSED ||
  428. !this.gracefullyReconnectUntil
  429. )
  430. return
  431. if (
  432. this.gracefullyReconnectUntil < performance.now() ||
  433. this.isUserInactiveSince(RECONNECT_GRACEFULLY_RETRY_INTERVAL_MS)
  434. ) {
  435. this.disconnect()
  436. this.tryReconnect()
  437. } else {
  438. setTimeout(() => {
  439. this.tryReconnectGracefully()
  440. }, RECONNECT_GRACEFULLY_RETRY_INTERVAL_MS)
  441. }
  442. }
  443. private tryReconnectWithBackoff(backoff: number) {
  444. if (this.hasReconnectedRecently(backoff)) {
  445. this.startAutoReconnectCountdown(backoff)
  446. } else {
  447. this.tryReconnect()
  448. }
  449. }
  450. private sendExternalHeartbeat() {
  451. const t0 = performance.now()
  452. this.socket.emit('debug.getHostname', () => {
  453. if (this.externalHeartbeat.currentStart !== t0) {
  454. return
  455. }
  456. const t1 = performance.now()
  457. this.externalHeartbeat = {
  458. currentStart: 0,
  459. lastSuccess: t1,
  460. lastLatency: t1 - t0,
  461. }
  462. })
  463. this.externalHeartbeat.currentStart = t0
  464. }
  465. private sendPingResponse(
  466. counter?: number,
  467. timestamp?: number,
  468. serverTransport?: string,
  469. serverSessionId?: string
  470. ) {
  471. const clientTransport = this.socket.socket.transport?.name
  472. const clientSessionId = this.socket.socket.sessionid
  473. this.socket.emit(
  474. 'clientPong',
  475. counter,
  476. timestamp,
  477. serverTransport,
  478. serverSessionId,
  479. clientTransport,
  480. clientSessionId
  481. )
  482. }
  483. }