connection-manager.ts 15 KB

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