connection-manager.ts 14 KB

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