connection-manager.ts 13 KB

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