ConnectionManager.coffee 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. define [], () ->
  2. ONEHOUR = 1000 * 60 * 60
  3. class ConnectionManager
  4. disconnectAfterMs: ONEHOUR * 24
  5. lastUserAction : new Date()
  6. constructor: (@ide, @$scope) ->
  7. if !io?
  8. console.error "Socket.io javascript not loaded. Please check that the real-time service is running and accessible."
  9. @ide.socket =
  10. on: () ->
  11. @$scope.$apply () =>
  12. @$scope.state.error = "Could not connect to websocket server :("
  13. return
  14. setInterval(() =>
  15. @disconnectIfInactive()
  16. , ONEHOUR)
  17. # trigger a reconnect immediately if network comes back online
  18. window.addEventListener 'online', =>
  19. sl_console.log "[online] browser notified online"
  20. if !@connected
  21. @tryReconnectWithRateLimit({force:true})
  22. @userIsLeavingPage = false
  23. window.addEventListener 'beforeunload', =>
  24. @userIsLeavingPage = true
  25. return # Don't return true or it will show a pop up
  26. @connected = false
  27. @userIsInactive = false
  28. @gracefullyReconnecting = false
  29. @$scope.connection =
  30. reconnecting: false
  31. # If we need to force everyone to reload the editor
  32. forced_disconnect: false
  33. inactive_disconnect: false
  34. @$scope.tryReconnectNow = () =>
  35. # user manually requested reconnection via "Try now" button
  36. @tryReconnectWithRateLimit({force:true})
  37. @$scope.$on 'cursor:editor:update', () =>
  38. @lastUserAction = new Date() # time of last edit
  39. if !@connected
  40. # user is editing, try to reconnect
  41. @tryReconnectWithRateLimit()
  42. document.querySelector('body').addEventListener 'click', (e) =>
  43. if !@connected and e.target.id != 'try-reconnect-now-button'
  44. # user is editing, try to reconnect
  45. @tryReconnectWithRateLimit()
  46. @ide.socket = io.connect null,
  47. reconnect: false
  48. 'connect timeout': 30 * 1000
  49. "force new connection": true
  50. # The "connect" event is the first event we get back. It only
  51. # indicates that the websocket is connected, we still need to
  52. # pass authentication to join a project.
  53. @ide.socket.on "connect", () =>
  54. sl_console.log "[socket.io connect] Connected"
  55. # The next event we should get is an authentication response
  56. # from the server, either "connectionAccepted" or
  57. # "connectionRejected".
  58. @ide.socket.on 'connectionAccepted', (message) =>
  59. sl_console.log "[socket.io connectionAccepted] allowed to connect"
  60. @connected = true
  61. @gracefullyReconnecting = false
  62. @ide.pushEvent("connected")
  63. @$scope.$apply () =>
  64. @$scope.connection.reconnecting = false
  65. @$scope.connection.inactive_disconnect = false
  66. if @$scope.state.loading
  67. @$scope.state.load_progress = 70
  68. # we have passed authentication so we can now join the project
  69. setTimeout(() =>
  70. @joinProject()
  71. , 100)
  72. @ide.socket.on 'connectionRejected', (err) =>
  73. sl_console.log "[socket.io connectionRejected] session not valid or other connection error"
  74. # we have failed authentication, usually due to an invalid session cookie
  75. return @reportConnectionError(err)
  76. # Alternatively the attempt to connect can fail completely, so
  77. # we never get into the "connect" state.
  78. @ide.socket.on "connect_failed", () =>
  79. @connected = false
  80. @$scope.$apply () =>
  81. @$scope.state.error = "Unable to connect, please view the <u><a href='/learn/Kb/Connection_problems'>connection problems guide</a></u> to fix the issue."
  82. # We can get a "disconnect" event at any point after the
  83. # "connect" event.
  84. @ide.socket.on 'disconnect', () =>
  85. sl_console.log "[socket.io disconnect] Disconnected"
  86. @connected = false
  87. @ide.pushEvent("disconnected")
  88. @$scope.$apply () =>
  89. @$scope.connection.reconnecting = false
  90. if !@$scope.connection.forced_disconnect and !@userIsInactive and !@gracefullyReconnecting
  91. @startAutoReconnectCountdown()
  92. # Site administrators can send the forceDisconnect event to all users
  93. @ide.socket.on 'forceDisconnect', (message) =>
  94. @$scope.$apply () =>
  95. @$scope.permissions.write = false
  96. @$scope.connection.forced_disconnect = true
  97. @ide.socket.disconnect()
  98. @ide.showGenericMessageModal("Please Refresh", """
  99. We're performing maintenance on ShareLaTeX and you need to refresh the editor.
  100. Sorry for any inconvenience.
  101. The editor will refresh in automatically in 10 seconds.
  102. """)
  103. setTimeout () ->
  104. location.reload()
  105. , 10 * 1000
  106. @ide.socket.on "reconnectGracefully", () =>
  107. sl_console.log "Reconnect gracefully"
  108. @reconnectGracefully()
  109. # Error reporting, which can reload the page if appropriate
  110. reportConnectionError: (err) ->
  111. sl_console.log "[socket.io] reporting connection error"
  112. if err?.message == "not authorized" or err?.message == "invalid session"
  113. window.location = "/login?redir=#{encodeURI(window.location.pathname)}"
  114. else
  115. @ide.socket.disconnect()
  116. @ide.showGenericMessageModal("Something went wrong connecting", """
  117. Something went wrong connecting to your project. Please refresh if this continues to happen.
  118. """)
  119. joinProject: () ->
  120. sl_console.log "[joinProject] joining..."
  121. # Note: if the "joinProject" message doesn't reach the server
  122. # (e.g. if we are in a disconnected state at this point) the
  123. # callback will never be executed
  124. data = {
  125. project_id: @ide.project_id
  126. }
  127. if window.anonymousAccessToken
  128. data.anonymousAccessToken = window.anonymousAccessToken
  129. @ide.socket.emit 'joinProject', data, (err, project, permissionsLevel, protocolVersion) =>
  130. if err? or !project?
  131. return @reportConnectionError(err)
  132. if @$scope.protocolVersion? and @$scope.protocolVersion != protocolVersion
  133. location.reload(true)
  134. @$scope.$apply () =>
  135. @$scope.protocolVersion = protocolVersion
  136. @$scope.project = project
  137. @$scope.permissionsLevel = permissionsLevel
  138. @$scope.state.load_progress = 100
  139. @$scope.state.loading = false
  140. @$scope.$broadcast "project:joined"
  141. reconnectImmediately: () ->
  142. @disconnect()
  143. @tryReconnect()
  144. disconnect: () ->
  145. sl_console.log "[socket.io] disconnecting client"
  146. @ide.socket.disconnect()
  147. startAutoReconnectCountdown: () ->
  148. sl_console.log "[ConnectionManager] starting autoreconnect countdown"
  149. twoMinutes = 2 * 60 * 1000
  150. if @lastUserAction? and new Date() - @lastUserAction > twoMinutes
  151. # between 1 minute and 3 minutes
  152. countdown = 60 + Math.floor(Math.random() * 120)
  153. else
  154. countdown = 3 + Math.floor(Math.random() * 7)
  155. if @userIsLeavingPage #user will have pressed refresh or back etc
  156. return
  157. @$scope.$apply () =>
  158. @$scope.connection.reconnecting = false
  159. @$scope.connection.reconnection_countdown = countdown
  160. setTimeout(=>
  161. if !@connected
  162. @timeoutId = setTimeout (=> @decreaseCountdown()), 1000
  163. , 200)
  164. cancelReconnect: () ->
  165. # clear timeout and set to null so we know there is no countdown running
  166. if @timeoutId?
  167. sl_console.log "[ConnectionManager] cancelling existing reconnect timer"
  168. clearTimeout @timeoutId
  169. @timeoutId = null
  170. decreaseCountdown: () ->
  171. @timeoutId = null
  172. return if !@$scope.connection.reconnection_countdown?
  173. sl_console.log "[ConnectionManager] decreasing countdown", @$scope.connection.reconnection_countdown
  174. @$scope.$apply () =>
  175. @$scope.connection.reconnection_countdown--
  176. if @$scope.connection.reconnection_countdown <= 0
  177. @$scope.$apply () =>
  178. @tryReconnect()
  179. else
  180. @timeoutId = setTimeout (=> @decreaseCountdown()), 1000
  181. tryReconnect: () ->
  182. sl_console.log "[ConnectionManager] tryReconnect"
  183. @cancelReconnect()
  184. delete @$scope.connection.reconnection_countdown
  185. return if @connected
  186. @$scope.connection.reconnecting = true
  187. # use socket.io connect() here to make a single attempt, the
  188. # reconnect() method makes multiple attempts
  189. @ide.socket.socket.connect()
  190. # record the time of the last attempt to connect
  191. @lastConnectionAttempt = new Date()
  192. setTimeout (=> @startAutoReconnectCountdown() if !@connected), 2000
  193. MIN_RETRY_INTERVAL: 1000 # ms, rate limit on reconnects for user clicking "try now"
  194. BACKGROUND_RETRY_INTERVAL : 5 * 1000 # ms, rate limit on reconnects for other user activity (e.g. cursor moves)
  195. tryReconnectWithRateLimit: (options) ->
  196. # bail out if the reconnect is already in progress
  197. return if @$scope.connection?.reconnecting
  198. # bail out if we are going to reconnect soon anyway
  199. reconnectingSoon = @$scope.connection?.reconnection_countdown? and @$scope.connection.reconnection_countdown <= 5
  200. clickedTryNow = options?.force # user requested reconnection
  201. return if reconnectingSoon and not clickedTryNow
  202. # bail out if we tried reconnecting recently
  203. allowedInterval = if clickedTryNow then @MIN_RETRY_INTERVAL else @BACKGROUND_RETRY_INTERVAL
  204. return if @lastConnectionAttempt? and new Date() - @lastConnectionAttempt < allowedInterval
  205. @tryReconnect()
  206. disconnectIfInactive: ()->
  207. @userIsInactive = (new Date() - @lastUserAction) > @disconnectAfterMs
  208. if @userIsInactive and @connected
  209. @disconnect()
  210. @$scope.$apply () =>
  211. @$scope.connection.inactive_disconnect = true
  212. RECONNECT_GRACEFULLY_RETRY_INTERVAL: 5000 # ms
  213. MAX_RECONNECT_GRACEFULLY_INTERVAL: 60 * 5 * 1000 # 5 minutes
  214. reconnectGracefully: () ->
  215. @reconnectGracefullyStarted ?= new Date()
  216. userIsInactive = (new Date() - @lastUserAction) > @RECONNECT_GRACEFULLY_RETRY_INTERVAL
  217. maxIntervalReached = (new Date() - @reconnectGracefullyStarted) > @MAX_RECONNECT_GRACEFULLY_INTERVAL
  218. if userIsInactive or maxIntervalReached
  219. sl_console.log "[reconnectGracefully] User didn't do anything for last 5 seconds, reconnecting"
  220. @_reconnectGracefullyNow()
  221. else
  222. sl_console.log "[reconnectGracefully] User is working, will try again in 5 seconds"
  223. setTimeout () =>
  224. @reconnectGracefully()
  225. , @RECONNECT_GRACEFULLY_RETRY_INTERVAL
  226. _reconnectGracefullyNow: () ->
  227. @gracefullyReconnecting = true
  228. @reconnectGracefullyStarted = null
  229. # Clear cookie so we don't go to the same backend server
  230. $.cookie("SERVERID", "", { expires: -1, path: "/" })
  231. @reconnectImmediately()