ConnectionManager.js 22 KB

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