Merge pull request #2125 from overleaf/spd-editor-reconnect-improvements

Track connection state in ConnectionManager and improve retry logic

GitOrigin-RevId: b81b8376f7210e89e0dc102e68bdf2092c1ae8ad
This commit is contained in:
Timothée Alby 2019-09-10 16:31:08 +02:00 committed by sharelatex
parent 0c9bf05a0a
commit 561d14bbb6
2 changed files with 198 additions and 67 deletions

View file

@ -31,7 +31,7 @@ block content
| #{translate("reconnecting_in_x_secs", {seconds:"{{ connection.reconnection_countdown }}"})}. | #{translate("reconnecting_in_x_secs", {seconds:"{{ connection.reconnection_countdown }}"})}.
a#try-reconnect-now-button.alert-link-as-btn.pull-right(href, ng-click="tryReconnectNow()") #{translate("try_now")} a#try-reconnect-now-button.alert-link-as-btn.pull-right(href, ng-click="tryReconnectNow()") #{translate("try_now")}
.alert.alert-warning.small(ng-if="connection.reconnecting") .alert.alert-warning.small(ng-if="connection.reconnecting && connection.stillReconnecting")
strong #{translate("reconnecting")}... strong #{translate("reconnecting")}...
.alert.alert-warning.small(ng-if="sync_tex_error") .alert.alert-warning.small(ng-if="sync_tex_error")
@ -44,6 +44,8 @@ block content
.alert.alert-warning.small(ng-if="connection.inactive_disconnect") .alert.alert-warning.small(ng-if="connection.inactive_disconnect")
strong #{translate("editor_disconected_click_to_reconnect")} strong #{translate("editor_disconected_click_to_reconnect")}
.alert.alert-warning.small(ng-if="connection.debug") {{ connection.state }}
.div(ng-controller="SavingNotificationController") .div(ng-controller="SavingNotificationController")
.alert.alert-warning.small(ng-repeat="(doc_id, state) in docSavingStatus" ng-if="state.unsavedSeconds > 8") #{translate("saving_notification_with_seconds", {docname:"{{ state.doc.name }}", seconds:"{{ state.unsavedSeconds }}"})} .alert.alert-warning.small(ng-repeat="(doc_id, state) in docSavingStatus" ng-if="state.unsavedSeconds > 8") #{translate("saving_notification_with_seconds", {docname:"{{ state.doc.name }}", seconds:"{{ state.unsavedSeconds }}"})}

View file

@ -1,8 +1,3 @@
/* eslint-disable
max-len,
no-return-assign,
no-undef,
*/
// TODO: This file was created by bulk-decaffeinate. // TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint. // Fix any style issues and re-enable lint.
/* /*
@ -13,9 +8,12 @@
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/ */
/* global io sl_console sl_debugging */
define([], function() { define([], function() {
let ConnectionManager let ConnectionManager
const ONEHOUR = 1000 * 60 * 60 const ONEHOUR = 1000 * 60 * 60
return (ConnectionManager = (function() { return (ConnectionManager = (function() {
ConnectionManager = class ConnectionManager { ConnectionManager = class ConnectionManager {
static initClass() { static initClass() {
@ -26,6 +24,8 @@ define([], function() {
this.prototype.MIN_RETRY_INTERVAL = 1000 // ms, rate limit on reconnects for user clicking "try now" this.prototype.MIN_RETRY_INTERVAL = 1000 // ms, rate limit on reconnects for user clicking "try now"
this.prototype.BACKGROUND_RETRY_INTERVAL = 5 * 1000 this.prototype.BACKGROUND_RETRY_INTERVAL = 5 * 1000
this.prototype.JOIN_PROJECT_RETRY_INTERVAL = 5000
this.prototype.JOIN_PROJECT_MAX_RETRY_INTERVAL = 60000
this.prototype.RECONNECT_GRACEFULLY_RETRY_INTERVAL = 5000 // ms this.prototype.RECONNECT_GRACEFULLY_RETRY_INTERVAL = 5000 // ms
this.prototype.MAX_RECONNECT_GRACEFULLY_INTERVAL = 45 * 1000 this.prototype.MAX_RECONNECT_GRACEFULLY_INTERVAL = 45 * 1000
} }
@ -67,11 +67,16 @@ define([], function() {
this.userIsInactive = false this.userIsInactive = false
this.gracefullyReconnecting = false this.gracefullyReconnecting = false
this.joinProjectRetryInterval = this.JOIN_PROJECT_RETRY_INTERVAL
this.$scope.connection = { this.$scope.connection = {
debug: sl_debugging,
reconnecting: false, reconnecting: false,
stillReconnecting: false,
// If we need to force everyone to reload the editor // If we need to force everyone to reload the editor
forced_disconnect: false, forced_disconnect: false,
inactive_disconnect: false inactive_disconnect: false,
jobId: 0
} }
this.$scope.tryReconnectNow = () => { this.$scope.tryReconnectNow = () => {
@ -94,6 +99,8 @@ define([], function() {
} }
}) })
// initial connection attempt
this.updateConnectionManagerState('connecting')
this.ide.socket = io.connect( this.ide.socket = io.connect(
this.wsUrl, this.wsUrl,
{ {
@ -106,6 +113,7 @@ define([], function() {
// handle network-level websocket errors (e.g. failed dns lookups) // handle network-level websocket errors (e.g. failed dns lookups)
let connectionErrorHandler = err => { let connectionErrorHandler = err => {
this.updateConnectionManagerState('error')
sl_console.log('socket.io error', err) sl_console.log('socket.io error', err)
if (this.wsUrl && !window.location.href.match(/ws=fallback/)) { if (this.wsUrl && !window.location.href.match(/ws=fallback/)) {
// if we tried to load a custom websocket location and failed // if we tried to load a custom websocket location and failed
@ -126,9 +134,11 @@ define([], function() {
// pass authentication to join a project. // pass authentication to join a project.
this.ide.socket.on('connect', () => { this.ide.socket.on('connect', () => {
// state should be 'connecting'...
// remove connection error handler when connected, avoid unwanted fallbacks // remove connection error handler when connected, avoid unwanted fallbacks
this.ide.socket.removeListener('error', connectionErrorHandler) this.ide.socket.removeListener('error', connectionErrorHandler)
return sl_console.log('[socket.io connect] Connected') sl_console.log('[socket.io connect] Connected')
this.updateConnectionManagerState('authenticating')
}) })
// The next event we should get is an authentication response // The next event we should get is an authentication response
@ -136,26 +146,28 @@ define([], function() {
// "connectionRejected". // "connectionRejected".
this.ide.socket.on('connectionAccepted', message => { this.ide.socket.on('connectionAccepted', message => {
// state should be 'authenticating'...
sl_console.log('[socket.io connectionAccepted] allowed to connect') sl_console.log('[socket.io connectionAccepted] allowed to connect')
this.connected = true this.connected = true
this.gracefullyReconnecting = false this.gracefullyReconnecting = false
this.ide.pushEvent('connected') this.ide.pushEvent('connected')
this.updateConnectionManagerState('joining')
this.$scope.$apply(() => { this.$scope.$apply(() => {
this.$scope.connection.reconnecting = false
this.$scope.connection.inactive_disconnect = false
if (this.$scope.state.loading) { if (this.$scope.state.loading) {
return (this.$scope.state.load_progress = 70) return (this.$scope.state.load_progress = 70)
} }
}) })
// we have passed authentication so we can now join the project // we have passed authentication so we can now join the project
return setTimeout(() => { let connectionJobId = this.$scope.connection.jobId
return this.joinProject() setTimeout(() => {
this.joinProject(connectionJobId)
}, 100) }, 100)
}) })
this.ide.socket.on('connectionRejected', err => { this.ide.socket.on('connectionRejected', err => {
// state should be 'authenticating'...
sl_console.log( sl_console.log(
'[socket.io connectionRejected] session not valid or other connection error' '[socket.io connectionRejected] session not valid or other connection error'
) )
@ -171,6 +183,7 @@ define([], function() {
// we never get into the "connect" state. // we never get into the "connect" state.
this.ide.socket.on('connect_failed', () => { this.ide.socket.on('connect_failed', () => {
this.updateConnectionManagerState('error')
this.connected = false this.connected = false
return this.$scope.$apply(() => { return this.$scope.$apply(() => {
return (this.$scope.state.error = return (this.$scope.state.error =
@ -186,22 +199,22 @@ define([], function() {
this.connected = false this.connected = false
this.ide.pushEvent('disconnected') this.ide.pushEvent('disconnected')
this.$scope.$apply(() => { if (!this.$scope.connection.state.match(/^waiting/)) {
return (this.$scope.connection.reconnecting = false) if (
}) !this.$scope.connection.forced_disconnect &&
!this.userIsInactive
if ( ) {
!this.$scope.connection.forced_disconnect && this.startAutoReconnectCountdown()
!this.userIsInactive && } else {
!this.gracefullyReconnecting this.updateConnectionManagerState('inactive')
) { }
return this.startAutoReconnectCountdown()
} }
}) })
// Site administrators can send the forceDisconnect event to all users // Site administrators can send the forceDisconnect event to all users
this.ide.socket.on('forceDisconnect', message => { this.ide.socket.on('forceDisconnect', message => {
this.updateConnectionManagerState('inactive')
this.$scope.$apply(() => { this.$scope.$apply(() => {
this.$scope.permissions.write = false this.$scope.permissions.write = false
return (this.$scope.connection.forced_disconnect = true) return (this.$scope.connection.forced_disconnect = true)
@ -220,14 +233,90 @@ The editor will refresh in automatically in 10 seconds.\
this.ide.socket.on('reconnectGracefully', () => { this.ide.socket.on('reconnectGracefully', () => {
sl_console.log('Reconnect gracefully') sl_console.log('Reconnect gracefully')
return this.reconnectGracefully() this.reconnectGracefully()
}) })
} }
updateConnectionManagerState(state) {
this.$scope.$apply(() => {
this.$scope.connection.jobId += 1
let jobId = this.$scope.connection.jobId
sl_console.log(
`[updateConnectionManagerState ${jobId}] from ${
this.$scope.connection.state
} to ${state}`
)
this.$scope.connection.state = state
this.$scope.connection.reconnecting = false
this.$scope.connection.stillReconnecting = false
this.$scope.connection.inactive_disconnect = false
this.$scope.connection.joining = false
this.$scope.connection.reconnection_countdown = null
if (state === 'connecting') {
// initial connection
} else if (state === 'reconnecting') {
// reconnection after a connection has failed
this.$scope.connection.reconnecting = true
// if reconnecting takes more than 1s (it doesn't, usually) show the
// 'reconnecting...' warning
setTimeout(() => {
if (
this.$scope.connection.reconnecting &&
this.$scope.connection.jobId === jobId
) {
this.$scope.connection.stillReconnecting = true
}
}, 1000)
} else if (state === 'authenticating') {
// socket connection has been established, trying to authenticate
} else if (state === 'joining') {
// authenticated, joining project
this.$scope.connection.joining = true
} else if (state === 'ready') {
// project has been joined
} else if (state === 'waitingCountdown') {
// disconnected and waiting to reconnect via the countdown timer
this.cancelReconnect()
} else if (state === 'waitingGracefully') {
// disconnected and waiting to reconnect gracefully
this.cancelReconnect()
} else if (state === 'inactive') {
// disconnected and not trying to reconnect (inactive)
} else if (state === 'error') {
// something is wrong
} else {
sl_console.log(
`[WARN] [updateConnectionManagerState ${jobId}] got unrecognised state ${state}`
)
}
})
}
expectConnectionManagerState(state, jobId) {
if (
this.$scope.connection.state === state &&
(!jobId || jobId === this.$scope.connection.jobId)
) {
return true
}
sl_console.log(
`[WARN] [state mismatch] expected state ${state}${
jobId ? '/' + jobId : ''
} when in ${this.$scope.connection.state}/${
this.$scope.connection.jobId
}`
)
return false
}
// Error reporting, which can reload the page if appropriate // Error reporting, which can reload the page if appropriate
reportConnectionError(err) { reportConnectionError(err) {
sl_console.log('[socket.io] reporting connection error') sl_console.log('[socket.io] reporting connection error')
this.updateConnectionManagerState('error')
if ( if (
(err != null ? err.message : undefined) === 'not authorized' || (err != null ? err.message : undefined) === 'not authorized' ||
(err != null ? err.message : undefined) === 'invalid session' (err != null ? err.message : undefined) === 'invalid session'
@ -246,25 +335,50 @@ Something went wrong connecting to your project. Please refresh if this continue
} }
} }
joinProject() { joinProject(connectionId) {
sl_console.log('[joinProject] joining...') sl_console.log(`[joinProject ${connectionId}] joining...`)
// Note: if the "joinProject" message doesn't reach the server // Note: if the "joinProject" message doesn't reach the server
// (e.g. if we are in a disconnected state at this point) the // (e.g. if we are in a disconnected state at this point) the
// callback will never be executed // callback will never be executed
if (!this.expectConnectionManagerState('joining', connectionId)) {
sl_console.log(
`[joinProject ${connectionId}] aborting with stale connection`
)
return
}
const data = { const data = {
project_id: this.ide.project_id project_id: this.ide.project_id
} }
if (window.anonymousAccessToken) { if (window.anonymousAccessToken) {
data.anonymousAccessToken = window.anonymousAccessToken data.anonymousAccessToken = window.anonymousAccessToken
} }
return this.ide.socket.emit( this.ide.socket.emit(
'joinProject', 'joinProject',
data, data,
(err, project, permissionsLevel, protocolVersion) => { (err, project, permissionsLevel, protocolVersion) => {
if (err != null || project == null) { if (err != null || project == null) {
return this.reportConnectionError(err) if (err.code === 'TooManyRequests') {
sl_console.log(
`[joinProject ${connectionId}] retrying: ${err.message}`
)
setTimeout(
() => this.joinProject(connectionId),
this.joinProjectRetryInterval
)
if (
this.joinProjectRetryInterval <
this.JOIN_PROJECT_MAX_RETRY_INTERVAL
) {
this.joinProjectRetryInterval += this.JOIN_PROJECT_RETRY_INTERVAL
}
return
} else {
return this.reportConnectionError(err)
}
} }
this.joinProjectRetryInterval = this.JOIN_PROJECT_RETRY_INTERVAL
if ( if (
this.$scope.protocolVersion != null && this.$scope.protocolVersion != null &&
this.$scope.protocolVersion !== protocolVersion this.$scope.protocolVersion !== protocolVersion
@ -272,13 +386,14 @@ Something went wrong connecting to your project. Please refresh if this continue
location.reload(true) location.reload(true)
} }
return this.$scope.$apply(() => { this.$scope.$apply(() => {
this.updateConnectionManagerState('ready')
this.$scope.protocolVersion = protocolVersion this.$scope.protocolVersion = protocolVersion
this.$scope.project = project this.$scope.project = project
this.$scope.permissionsLevel = permissionsLevel this.$scope.permissionsLevel = permissionsLevel
this.$scope.state.load_progress = 100 this.$scope.state.load_progress = 100
this.$scope.state.loading = false this.$scope.state.loading = false
return this.$scope.$broadcast('project:joined') this.$scope.$broadcast('project:joined')
}) })
} }
) )
@ -301,6 +416,8 @@ Something went wrong connecting to your project. Please refresh if this continue
} }
startAutoReconnectCountdown() { startAutoReconnectCountdown() {
this.updateConnectionManagerState('waitingCountdown')
let connectionId = this.$scope.connection.jobId
let countdown let countdown
sl_console.log('[ConnectionManager] starting autoreconnect countdown') sl_console.log('[ConnectionManager] starting autoreconnect countdown')
const twoMinutes = 2 * 60 * 1000 const twoMinutes = 2 * 60 * 1000
@ -321,89 +438,90 @@ Something went wrong connecting to your project. Please refresh if this continue
this.$scope.$apply(() => { this.$scope.$apply(() => {
this.$scope.connection.reconnecting = false this.$scope.connection.reconnecting = false
return (this.$scope.connection.reconnection_countdown = countdown) this.$scope.connection.stillReconnecting = false
this.$scope.connection.joining = false
this.$scope.connection.reconnection_countdown = countdown
}) })
return setTimeout(() => { setTimeout(() => {
if (!this.connected) { if (!this.connected) {
return (this.timeoutId = setTimeout( this.countdownTimeoutId = setTimeout(
() => this.decreaseCountdown(), () => this.decreaseCountdown(connectionId),
1000 1000
)) )
} }
}, 200) }, 200)
} }
cancelReconnect() { cancelReconnect() {
this.disconnect()
// clear timeout and set to null so we know there is no countdown running // clear timeout and set to null so we know there is no countdown running
if (this.timeoutId != null) { if (this.countdownTimeoutId != null) {
sl_console.log( sl_console.log(
'[ConnectionManager] cancelling existing reconnect timer' '[ConnectionManager] cancelling existing reconnect timer'
) )
clearTimeout(this.timeoutId) clearTimeout(this.countdownTimeoutId)
return (this.timeoutId = null) this.countdownTimeoutId = null
} }
} }
decreaseCountdown() { decreaseCountdown(connectionId) {
this.timeoutId = null this.countdownTimeoutId = null
if (this.$scope.connection.reconnection_countdown == null) { if (this.$scope.connection.reconnection_countdown == null) {
return return
} }
if (
!this.expectConnectionManagerState('waitingCountdown', connectionId)
) {
sl_console.log(
`[ConnectionManager] Aborting stale countdown ${connectionId}`
)
return
}
sl_console.log( sl_console.log(
'[ConnectionManager] decreasing countdown', '[ConnectionManager] decreasing countdown',
this.$scope.connection.reconnection_countdown this.$scope.connection.reconnection_countdown
) )
this.$scope.$apply(() => { this.$scope.$apply(() => {
return this.$scope.connection.reconnection_countdown-- this.$scope.connection.reconnection_countdown--
}) })
if (this.$scope.connection.reconnection_countdown <= 0) { if (this.$scope.connection.reconnection_countdown <= 0) {
return this.$scope.$apply(() => { this.$scope.connection.reconnecting = false
return this.tryReconnect() this.$scope.$apply(() => {
this.tryReconnect()
}) })
} else { } else {
return (this.timeoutId = setTimeout( this.countdownTimeoutId = setTimeout(
() => this.decreaseCountdown(), () => this.decreaseCountdown(connectionId),
1000 1000
)) )
} }
} }
tryReconnect() { tryReconnect() {
sl_console.log('[ConnectionManager] tryReconnect') sl_console.log('[ConnectionManager] tryReconnect')
this.cancelReconnect() if (this.connected || this.$scope.connection.reconnecting) {
delete this.$scope.connection.reconnection_countdown
if (this.connected) {
return return
} }
this.$scope.connection.reconnecting = true this.updateConnectionManagerState('reconnecting')
sl_console.log('[ConnectionManager] Starting new connection')
// use socket.io connect() here to make a single attempt, the // use socket.io connect() here to make a single attempt, the
// reconnect() method makes multiple attempts // reconnect() method makes multiple attempts
this.ide.socket.socket.connect() this.ide.socket.socket.connect()
// record the time of the last attempt to connect // record the time of the last attempt to connect
this.lastConnectionAttempt = new Date() this.lastConnectionAttempt = new Date()
return setTimeout(() => {
if (!this.connected) {
return this.startAutoReconnectCountdown()
}
}, 2000) // ms, rate limit on reconnects for other user activity (e.g. cursor moves)
} }
tryReconnectWithRateLimit(options) { tryReconnectWithRateLimit(options) {
// bail out if the reconnect is already in progress // bail out if the reconnect is already in progress
if ( if (this.$scope.connection.reconnecting || this.connected) {
this.$scope.connection != null
? this.$scope.connection.reconnecting
: undefined
) {
return return
} }
// bail out if we are going to reconnect soon anyway // bail out if we are going to reconnect soon anyway
const reconnectingSoon = const reconnectingSoon =
(this.$scope.connection != null this.$scope.connection.reconnection_countdown != null &&
? this.$scope.connection.reconnection_countdown
: undefined) != null &&
this.$scope.connection.reconnection_countdown <= 5 this.$scope.connection.reconnection_countdown <= 5
const clickedTryNow = options != null ? options.force : undefined // user requested reconnection const clickedTryNow = options != null ? options.force : undefined // user requested reconnection
if (reconnectingSoon && !clickedTryNow) { if (reconnectingSoon && !clickedTryNow) {
@ -417,9 +535,12 @@ Something went wrong connecting to your project. Please refresh if this continue
this.lastConnectionAttempt != null && this.lastConnectionAttempt != null &&
new Date() - this.lastConnectionAttempt < allowedInterval new Date() - this.lastConnectionAttempt < allowedInterval
) { ) {
if (this.$scope.connection.state !== 'waitingCountdown') {
this.startAutoReconnectCountdown()
}
return return
} }
return this.tryReconnect() this.tryReconnect()
} }
disconnectIfInactive() { disconnectIfInactive() {
@ -432,9 +553,16 @@ Something went wrong connecting to your project. Please refresh if this continue
}) // 5 minutes }) // 5 minutes
} }
} }
reconnectGracefully() { reconnectGracefully(force) {
if (this.reconnectGracefullyStarted == null) { if (this.reconnectGracefullyStarted == null) {
this.reconnectGracefullyStarted = new Date() this.reconnectGracefullyStarted = new Date()
} else {
if (!force) {
sl_console.log(
'[reconnectGracefully] reconnection is already in process, so skipping'
)
return
}
} }
const userIsInactive = const userIsInactive =
new Date() - this.lastUserAction > new Date() - this.lastUserAction >
@ -446,13 +574,14 @@ Something went wrong connecting to your project. Please refresh if this continue
sl_console.log( sl_console.log(
"[reconnectGracefully] User didn't do anything for last 5 seconds, reconnecting" "[reconnectGracefully] User didn't do anything for last 5 seconds, reconnecting"
) )
return this._reconnectGracefullyNow() this._reconnectGracefullyNow()
} else { } else {
sl_console.log( sl_console.log(
'[reconnectGracefully] User is working, will try again in 5 seconds' '[reconnectGracefully] User is working, will try again in 5 seconds'
) )
return setTimeout(() => { this.updateConnectionManagerState('waitingGracefully')
return this.reconnectGracefully() setTimeout(() => {
this.reconnectGracefully(true)
}, this.RECONNECT_GRACEFULLY_RETRY_INTERVAL) }, this.RECONNECT_GRACEFULLY_RETRY_INTERVAL)
} }
} }