connection-manager.ts 15 KB

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