ConnectionManager.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
  1. // TODO: This file was created by bulk-decaffeinate.
  2. // Fix any style issues and re-enable lint.
  3. /*
  4. * decaffeinate suggestions:
  5. * DS102: Remove unnecessary code created because of implicit returns
  6. * DS206: Consider reworking classes to avoid initClass
  7. * DS207: Consider shorter variations of null checks
  8. * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  9. */
  10. import SocketIoShim from './SocketIoShim'
  11. import getMeta from '../../utils/meta'
  12. import { debugConsole, debugging } from '@/utils/debugging'
  13. let ConnectionManager
  14. const ONEHOUR = 1000 * 60 * 60
  15. export default ConnectionManager = (function () {
  16. ConnectionManager = class ConnectionManager {
  17. static initClass() {
  18. this.prototype.disconnectAfterMs = ONEHOUR * 24
  19. this.prototype.lastUserAction = new Date()
  20. this.prototype.MIN_RETRY_INTERVAL = 1000 // ms, rate limit on reconnects for user clicking "try now"
  21. this.prototype.BACKGROUND_RETRY_INTERVAL = 5 * 1000
  22. this.prototype.RECONNECT_GRACEFULLY_RETRY_INTERVAL = 5000 // ms
  23. this.prototype.MAX_RECONNECT_GRACEFULLY_INTERVAL = 45 * 1000
  24. }
  25. constructor(ide, $scope) {
  26. this.ide = ide
  27. this.$scope = $scope
  28. if (typeof window.io !== 'object') {
  29. this.switchToWsFallbackIfPossible()
  30. debugConsole.error(
  31. 'Socket.io javascript not loaded. Please check that the real-time service is running and accessible.'
  32. )
  33. this.ide.socket = SocketIoShim.stub()
  34. this.$scope.$apply(() => {
  35. return (this.$scope.state.error =
  36. 'Could not connect to websocket server :(')
  37. })
  38. return
  39. }
  40. setInterval(() => {
  41. return this.disconnectIfInactive()
  42. }, ONEHOUR)
  43. // trigger a reconnect immediately if network comes back online
  44. window.addEventListener('online', () => {
  45. debugConsole.log('[online] browser notified online')
  46. if (!this.connected) {
  47. return this.tryReconnectWithRateLimit({ force: true })
  48. }
  49. })
  50. this.userIsLeavingPage = false
  51. window.addEventListener('beforeunload', () => {
  52. this.userIsLeavingPage = true
  53. }) // Don't return true or it will show a pop up
  54. this.connected = false
  55. this.userIsInactive = false
  56. this.gracefullyReconnecting = false
  57. this.shuttingDown = false
  58. this.$scope.connection = {
  59. debug: debugging,
  60. reconnecting: false,
  61. stillReconnecting: false,
  62. // If we need to force everyone to reload the editor
  63. forced_disconnect: false,
  64. inactive_disconnect: false,
  65. jobId: 0,
  66. }
  67. this.$scope.tryReconnectNow = () => {
  68. // user manually requested reconnection via "Try now" button
  69. return this.tryReconnectWithRateLimit({ force: true })
  70. }
  71. this.$scope.$on('cursor:editor:update', () => {
  72. this.lastUserAction = new Date() // time of last edit
  73. if (!this.connected) {
  74. // user is editing, try to reconnect
  75. return this.tryReconnectWithRateLimit()
  76. }
  77. })
  78. document.querySelector('body').addEventListener('click', e => {
  79. if (
  80. !this.shuttingDown &&
  81. !this.connected &&
  82. e.target.id !== 'try-reconnect-now-button'
  83. ) {
  84. // user is editing, try to reconnect
  85. return this.tryReconnectWithRateLimit()
  86. }
  87. })
  88. // initial connection attempt
  89. this.updateConnectionManagerState('connecting')
  90. const parsedURL = new URL(
  91. getMeta('ol-wsUrl') || '/socket.io',
  92. window.origin
  93. )
  94. const query = new URLSearchParams({
  95. projectId: getMeta('ol-project_id'),
  96. }).toString()
  97. this.ide.socket = SocketIoShim.connect(parsedURL.origin, {
  98. resource: parsedURL.pathname.slice(1),
  99. reconnect: false,
  100. 'connect timeout': 30 * 1000,
  101. 'force new connection': true,
  102. query,
  103. })
  104. // handle network-level websocket errors (e.g. failed dns lookups)
  105. let connectionAttempt = 1
  106. const connectionErrorHandler = err => {
  107. if (
  108. window.wsRetryHandshake &&
  109. connectionAttempt++ < window.wsRetryHandshake
  110. ) {
  111. return setTimeout(
  112. () => this.ide.socket.socket.connect(),
  113. // add jitter to spread reconnects
  114. connectionAttempt * (1 + Math.random()) * 1000
  115. )
  116. }
  117. this.updateConnectionManagerState('error')
  118. debugConsole.log('socket.io error', err)
  119. if (!this.switchToWsFallbackIfPossible()) {
  120. this.connected = false
  121. return this.$scope.$apply(() => {
  122. return (this.$scope.state.error =
  123. "Unable to connect, please view the <u><a href='/learn/Kb/Connection_problems'>connection problems guide</a></u> to fix the issue.")
  124. })
  125. }
  126. }
  127. this.ide.socket.on('error', connectionErrorHandler)
  128. // The "connect" event is the first event we get back. It only
  129. // indicates that the websocket is connected, we still need to
  130. // pass authentication to join a project.
  131. this.ide.socket.on('connect', () => {
  132. // state should be 'connecting'...
  133. // remove connection error handler when connected, avoid unwanted fallbacks
  134. this.ide.socket.removeListener('error', connectionErrorHandler)
  135. debugConsole.log('[socket.io connect] Connected')
  136. this.updateConnectionManagerState('authenticating')
  137. })
  138. // The next event we should get is an authentication response
  139. // from the server, either "joinProjectResponse" or "connectionRejected".
  140. this.ide.socket.on(
  141. 'joinProjectResponse',
  142. ({ publicId, project, permissionsLevel, protocolVersion }) => {
  143. this.ide.socket.publicId = publicId
  144. debugConsole.log('[socket.io bootstrap] ready for joinDoc')
  145. this.connected = true
  146. this.gracefullyReconnecting = false
  147. this.ide.pushEvent('connected')
  148. this.ide.pushEvent('joinProjectResponse')
  149. this.updateConnectionManagerState('joining')
  150. this.$scope.$apply(() => {
  151. if (this.$scope.state.loading) {
  152. this.$scope.state.load_progress = 70
  153. }
  154. })
  155. this.handleJoinProjectResponse({
  156. project,
  157. permissionsLevel,
  158. protocolVersion,
  159. })
  160. }
  161. )
  162. this.ide.socket.on('connectionRejected', err => {
  163. // state should be 'authenticating'...
  164. debugConsole.log(
  165. '[socket.io connectionRejected] session not valid or other connection error'
  166. )
  167. // real-time sends a 'retry' message if the process was shutting down
  168. // real-time sends TooManyRequests if joinProject was rate-limited.
  169. if (err?.message === 'retry' || err?.code === 'TooManyRequests') {
  170. return this.tryReconnectWithRateLimit()
  171. }
  172. if (err?.code === 'ProjectNotFound') {
  173. // A stale browser tab tried to join a deleted project.
  174. // Reloading the page will render a 404.
  175. this.ide
  176. .showGenericMessageModal(
  177. 'Project has been deleted',
  178. 'This project has been deleted by the owner.'
  179. )
  180. .result.then(() => location.reload(true))
  181. return
  182. }
  183. // we have failed authentication, usually due to an invalid session cookie
  184. return this.reportConnectionError(err)
  185. })
  186. // Alternatively the attempt to connect can fail completely, so
  187. // we never get into the "connect" state.
  188. this.ide.socket.on('connect_failed', () => {
  189. this.updateConnectionManagerState('error')
  190. this.connected = false
  191. return this.$scope.$apply(() => {
  192. return (this.$scope.state.error =
  193. "Unable to connect, please view the <u><a href='/learn/Kb/Connection_problems'>connection problems guide</a></u> to fix the issue.")
  194. })
  195. })
  196. // We can get a "disconnect" event at any point after the
  197. // "connect" event.
  198. this.ide.socket.on('disconnect', () => {
  199. debugConsole.log('[socket.io disconnect] Disconnected')
  200. this.connected = false
  201. this.ide.pushEvent('disconnected')
  202. if (!this.$scope.connection.state.match(/^waiting/)) {
  203. if (
  204. !this.$scope.connection.forced_disconnect &&
  205. !this.userIsInactive &&
  206. !this.shuttingDown
  207. ) {
  208. this.startAutoReconnectCountdown()
  209. } else {
  210. this.updateConnectionManagerState('inactive')
  211. }
  212. }
  213. })
  214. // Site administrators can send the forceDisconnect event to all users
  215. this.ide.socket.on('forceDisconnect', (message, delay = 10) => {
  216. this.updateConnectionManagerState('inactive')
  217. this.shuttingDown = true // prevent reconnection attempts
  218. this.$scope.$apply(() => {
  219. this.$scope.permissions.write = false
  220. return (this.$scope.connection.forced_disconnect = true)
  221. })
  222. // flush changes before disconnecting
  223. this.ide.$scope.$broadcast('flush-changes')
  224. setTimeout(() => this.ide.socket.disconnect(), 1000)
  225. this.ide.showLockEditorMessageModal(
  226. 'Please wait',
  227. `\
  228. We're performing maintenance on Overleaf and you need to wait a moment.
  229. Sorry for any inconvenience.
  230. The editor will refresh automatically in ${delay} seconds.\
  231. `
  232. )
  233. return setTimeout(() => location.reload(), delay * 1000)
  234. })
  235. this.ide.socket.on('reconnectGracefully', () => {
  236. debugConsole.log('Reconnect gracefully')
  237. this.reconnectGracefully()
  238. })
  239. }
  240. switchToWsFallbackIfPossible() {
  241. const search = new URLSearchParams(window.location.search)
  242. if (getMeta('ol-wsUrl') && search.get('ws') !== 'fallback') {
  243. // if we tried to boot from a custom real-time backend and failed,
  244. // try reloading and falling back to the siteUrl
  245. search.set('ws', 'fallback')
  246. window.location.search = search.toString()
  247. return true
  248. }
  249. return false
  250. }
  251. updateConnectionManagerState(state) {
  252. this.$scope.$apply(() => {
  253. this.$scope.connection.jobId += 1
  254. const jobId = this.$scope.connection.jobId
  255. debugConsole.log(
  256. `[updateConnectionManagerState ${jobId}] from ${this.$scope.connection.state} to ${state}`
  257. )
  258. this.$scope.connection.state = state
  259. this.$scope.connection.reconnecting = false
  260. this.$scope.connection.stillReconnecting = false
  261. this.$scope.connection.inactive_disconnect = false
  262. this.$scope.connection.joining = false
  263. this.$scope.connection.reconnection_countdown = null
  264. if (state === 'connecting') {
  265. // initial connection
  266. } else if (state === 'reconnecting') {
  267. // reconnection after a connection has failed
  268. this.stopReconnectCountdownTimer()
  269. this.$scope.connection.reconnecting = true
  270. // if reconnecting takes more than 1s (it doesn't, usually) show the
  271. // 'reconnecting...' warning
  272. setTimeout(() => {
  273. if (
  274. this.$scope.connection.reconnecting &&
  275. this.$scope.connection.jobId === jobId
  276. ) {
  277. this.$scope.connection.stillReconnecting = true
  278. }
  279. }, 1000)
  280. } else if (state === 'reconnectFailed') {
  281. // reconnect attempt failed
  282. } else if (state === 'authenticating') {
  283. // socket connection has been established, trying to authenticate
  284. } else if (state === 'joining') {
  285. // authenticated, joining project
  286. this.$scope.connection.joining = true
  287. } else if (state === 'ready') {
  288. // project has been joined
  289. } else if (state === 'waitingCountdown') {
  290. // disconnected and waiting to reconnect via the countdown timer
  291. this.stopReconnectCountdownTimer()
  292. } else if (state === 'waitingGracefully') {
  293. // disconnected and waiting to reconnect gracefully
  294. this.stopReconnectCountdownTimer()
  295. } else if (state === 'inactive') {
  296. // disconnected and not trying to reconnect (inactive)
  297. } else if (state === 'error') {
  298. // something is wrong
  299. } else {
  300. debugConsole.log(
  301. `[WARN] [updateConnectionManagerState ${jobId}] got unrecognised state ${state}`
  302. )
  303. }
  304. })
  305. }
  306. expectConnectionManagerState(state, jobId) {
  307. if (
  308. this.$scope.connection.state === state &&
  309. (!jobId || jobId === this.$scope.connection.jobId)
  310. ) {
  311. return true
  312. }
  313. debugConsole.log(
  314. `[WARN] [state mismatch] expected state ${state}${
  315. jobId ? '/' + jobId : ''
  316. } when in ${this.$scope.connection.state}/${
  317. this.$scope.connection.jobId
  318. }`
  319. )
  320. return false
  321. }
  322. // Error reporting, which can reload the page if appropriate
  323. reportConnectionError(err) {
  324. debugConsole.log('[socket.io] reporting connection error')
  325. this.updateConnectionManagerState('error')
  326. if (
  327. (err != null ? err.message : undefined) === 'not authorized' ||
  328. (err != null ? err.message : undefined) === 'invalid session'
  329. ) {
  330. return (window.location = `/login?redir=${encodeURI(
  331. window.location.pathname
  332. )}`)
  333. } else {
  334. this.ide.socket.disconnect()
  335. return this.ide.showGenericMessageModal(
  336. 'Something went wrong connecting',
  337. `\
  338. Something went wrong connecting to your project. Please refresh if this continues to happen.\
  339. `
  340. )
  341. }
  342. }
  343. handleJoinProjectResponse({ project, permissionsLevel, protocolVersion }) {
  344. if (
  345. this.$scope.protocolVersion != null &&
  346. this.$scope.protocolVersion !== protocolVersion
  347. ) {
  348. location.reload(true)
  349. }
  350. this.$scope.$apply(() => {
  351. this.updateConnectionManagerState('ready')
  352. this.$scope.protocolVersion = protocolVersion
  353. const defaultProjectAttributes = { rootDoc_id: null }
  354. this.$scope.project = { ...defaultProjectAttributes, ...project }
  355. this.$scope.permissionsLevel = permissionsLevel
  356. this.ide.loadingManager.socketLoaded()
  357. window.dispatchEvent(
  358. new CustomEvent('project:joined', { detail: this.$scope.project })
  359. )
  360. this.$scope.$broadcast('project:joined')
  361. })
  362. }
  363. reconnectImmediately() {
  364. this.disconnect()
  365. return this.tryReconnect()
  366. }
  367. disconnect(options) {
  368. if (options && options.permanent) {
  369. debugConsole.log('[disconnect] shutting down ConnectionManager')
  370. this.updateConnectionManagerState('inactive')
  371. this.shuttingDown = true // prevent reconnection attempts
  372. } else if (this.ide.socket.socket && !this.ide.socket.socket.connected) {
  373. debugConsole.log(
  374. '[socket.io] skipping disconnect because socket.io has not connected'
  375. )
  376. return
  377. }
  378. debugConsole.log('[socket.io] disconnecting client')
  379. return this.ide.socket.disconnect()
  380. }
  381. startAutoReconnectCountdown() {
  382. this.updateConnectionManagerState('waitingCountdown')
  383. const connectionId = this.$scope.connection.jobId
  384. let countdown
  385. debugConsole.log('[ConnectionManager] starting autoreconnect countdown')
  386. const twoMinutes = 2 * 60 * 1000
  387. if (
  388. this.lastUserAction != null &&
  389. new Date() - this.lastUserAction > twoMinutes
  390. ) {
  391. // between 1 minute and 3 minutes
  392. countdown = 60 + Math.floor(Math.random() * 120)
  393. } else {
  394. countdown = 3 + Math.floor(Math.random() * 7)
  395. }
  396. if (this.userIsLeavingPage) {
  397. // user will have pressed refresh or back etc
  398. return
  399. }
  400. this.$scope.$apply(() => {
  401. this.$scope.connection.reconnecting = false
  402. this.$scope.connection.stillReconnecting = false
  403. this.$scope.connection.joining = false
  404. this.$scope.connection.reconnection_countdown = countdown
  405. })
  406. setTimeout(() => {
  407. if (!this.connected && !this.countdownTimeoutId) {
  408. this.countdownTimeoutId = setTimeout(
  409. () => this.decreaseCountdown(connectionId),
  410. 1000
  411. )
  412. }
  413. }, 200)
  414. }
  415. stopReconnectCountdownTimer() {
  416. // clear timeout and set to null so we know there is no countdown running
  417. if (this.countdownTimeoutId != null) {
  418. debugConsole.log(
  419. '[ConnectionManager] cancelling existing reconnect timer'
  420. )
  421. clearTimeout(this.countdownTimeoutId)
  422. this.countdownTimeoutId = null
  423. }
  424. }
  425. decreaseCountdown(connectionId) {
  426. this.countdownTimeoutId = null
  427. if (this.$scope.connection.reconnection_countdown == null) {
  428. return
  429. }
  430. if (
  431. !this.expectConnectionManagerState('waitingCountdown', connectionId)
  432. ) {
  433. debugConsole.log(
  434. `[ConnectionManager] Aborting stale countdown ${connectionId}`
  435. )
  436. return
  437. }
  438. debugConsole.log(
  439. '[ConnectionManager] decreasing countdown',
  440. this.$scope.connection.reconnection_countdown
  441. )
  442. this.$scope.$apply(() => {
  443. this.$scope.connection.reconnection_countdown--
  444. })
  445. if (this.$scope.connection.reconnection_countdown <= 0) {
  446. this.$scope.connection.reconnecting = false
  447. this.$scope.$apply(() => {
  448. this.tryReconnect()
  449. })
  450. } else {
  451. this.countdownTimeoutId = setTimeout(
  452. () => this.decreaseCountdown(connectionId),
  453. 1000
  454. )
  455. }
  456. }
  457. tryReconnect() {
  458. debugConsole.log('[ConnectionManager] tryReconnect')
  459. if (
  460. this.connected ||
  461. this.shuttingDown ||
  462. this.$scope.connection.reconnecting
  463. ) {
  464. return
  465. }
  466. this.updateConnectionManagerState('reconnecting')
  467. debugConsole.log('[ConnectionManager] Starting new connection')
  468. const removeHandler = () => {
  469. this.ide.socket.removeListener('error', handleFailure)
  470. this.ide.socket.removeListener('connect', handleSuccess)
  471. }
  472. const handleFailure = () => {
  473. debugConsole.log('[ConnectionManager] tryReconnect: failed')
  474. removeHandler()
  475. this.updateConnectionManagerState('reconnectFailed')
  476. this.tryReconnectWithRateLimit({ force: true })
  477. }
  478. const handleSuccess = () => {
  479. debugConsole.log('[ConnectionManager] tryReconnect: success')
  480. removeHandler()
  481. }
  482. this.ide.socket.on('error', handleFailure)
  483. this.ide.socket.on('connect', handleSuccess)
  484. // use socket.io connect() here to make a single attempt, the
  485. // reconnect() method makes multiple attempts
  486. this.ide.socket.socket.connect()
  487. // record the time of the last attempt to connect
  488. this.lastConnectionAttempt = new Date()
  489. }
  490. tryReconnectWithRateLimit(options) {
  491. // bail out if the reconnect is already in progress
  492. if (this.$scope.connection.reconnecting || this.connected) {
  493. return
  494. }
  495. // bail out if we are going to reconnect soon anyway
  496. const reconnectingSoon =
  497. this.$scope.connection.reconnection_countdown != null &&
  498. this.$scope.connection.reconnection_countdown <= 5
  499. const clickedTryNow = options != null ? options.force : undefined // user requested reconnection
  500. if (reconnectingSoon && !clickedTryNow) {
  501. return
  502. }
  503. // bail out if we tried reconnecting recently
  504. const allowedInterval = clickedTryNow
  505. ? this.MIN_RETRY_INTERVAL
  506. : this.BACKGROUND_RETRY_INTERVAL
  507. if (
  508. this.lastConnectionAttempt != null &&
  509. new Date() - this.lastConnectionAttempt < allowedInterval
  510. ) {
  511. if (this.$scope.connection.state !== 'waitingCountdown') {
  512. this.startAutoReconnectCountdown()
  513. }
  514. return
  515. }
  516. this.tryReconnect()
  517. }
  518. disconnectIfInactive() {
  519. this.userIsInactive =
  520. new Date() - this.lastUserAction > this.disconnectAfterMs
  521. if (this.userIsInactive && this.connected) {
  522. this.disconnect()
  523. return this.$scope.$apply(() => {
  524. return (this.$scope.connection.inactive_disconnect = true)
  525. }) // 5 minutes
  526. }
  527. }
  528. reconnectGracefully(force) {
  529. if (this.reconnectGracefullyStarted == null) {
  530. this.reconnectGracefullyStarted = new Date()
  531. } else {
  532. if (!force) {
  533. debugConsole.log(
  534. '[reconnectGracefully] reconnection is already in process, so skipping'
  535. )
  536. return
  537. }
  538. }
  539. const userIsInactive =
  540. new Date() - this.lastUserAction >
  541. this.RECONNECT_GRACEFULLY_RETRY_INTERVAL
  542. const maxIntervalReached =
  543. new Date() - this.reconnectGracefullyStarted >
  544. this.MAX_RECONNECT_GRACEFULLY_INTERVAL
  545. if (userIsInactive || maxIntervalReached) {
  546. debugConsole.log(
  547. "[reconnectGracefully] User didn't do anything for last 5 seconds, reconnecting"
  548. )
  549. this._reconnectGracefullyNow()
  550. } else {
  551. debugConsole.log(
  552. '[reconnectGracefully] User is working, will try again in 5 seconds'
  553. )
  554. this.updateConnectionManagerState('waitingGracefully')
  555. setTimeout(() => {
  556. this.reconnectGracefully(true)
  557. }, this.RECONNECT_GRACEFULLY_RETRY_INTERVAL)
  558. }
  559. }
  560. _reconnectGracefullyNow() {
  561. this.gracefullyReconnecting = true
  562. this.reconnectGracefullyStarted = null
  563. // Clear cookie so we don't go to the same backend server
  564. $.cookie('SERVERID', '', { expires: -1, path: '/' })
  565. return this.reconnectImmediately()
  566. }
  567. }
  568. ConnectionManager.initClass()
  569. return ConnectionManager
  570. })()