ConnectionManager.js 22 KB

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