Document.coffee 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. define [
  2. "utils/EventEmitter"
  3. "ide/editor/ShareJsDoc"
  4. ], (EventEmitter, ShareJsDoc) ->
  5. class Document extends EventEmitter
  6. @getDocument: (ide, doc_id) ->
  7. @openDocs ||= {}
  8. if !@openDocs[doc_id]?
  9. sl_console.log "[getDocument] Creating new document instance for #{doc_id}"
  10. @openDocs[doc_id] = new Document(ide, doc_id)
  11. else
  12. sl_console.log "[getDocument] Returning existing document instance for #{doc_id}"
  13. return @openDocs[doc_id]
  14. @hasUnsavedChanges: () ->
  15. for doc_id, doc of (@openDocs or {})
  16. return true if doc.hasBufferedOps()
  17. return false
  18. @flushAll: () ->
  19. for doc_id, doc of @openDocs
  20. doc.flush()
  21. constructor: (@ide, @doc_id) ->
  22. @connected = @ide.socket.socket.connected
  23. @joined = false
  24. @wantToBeJoined = false
  25. @_checkConsistency = _.bind(@_checkConsistency, @)
  26. @inconsistentCount = 0
  27. @_bindToEditorEvents()
  28. @_bindToSocketEvents()
  29. attachToAce: (@ace) ->
  30. @doc?.attachToAce(@ace)
  31. editorDoc = @ace.getSession().getDocument()
  32. editorDoc.on "change", @_checkConsistency
  33. detachFromAce: () ->
  34. @doc?.detachFromAce()
  35. editorDoc = @ace?.getSession().getDocument()
  36. editorDoc?.off "change", @_checkConsistency
  37. @ide.$scope.$emit 'document:closed', @doc
  38. _checkConsistency: () ->
  39. # We've been seeing a lot of errors when I think there shouldn't be
  40. # any, which may be related to this check happening before the change is
  41. # applied. If we use a timeout, hopefully we can reduce this.
  42. setTimeout () =>
  43. editorValue = @ace?.getValue()
  44. sharejsValue = @doc?.getSnapshot()
  45. if editorValue != sharejsValue
  46. @inconsistentCount++
  47. else
  48. @inconsistentCount = 0
  49. if @inconsistentCount >= 3
  50. @_onError new Error("Editor text does not match server text")
  51. , 0
  52. getSnapshot: () ->
  53. @doc?.getSnapshot()
  54. getType: () ->
  55. @doc?.getType()
  56. getInflightOp: () ->
  57. @doc?.getInflightOp()
  58. getPendingOp: () ->
  59. @doc?.getPendingOp()
  60. getRecentAck: () ->
  61. @doc?.getRecentAck()
  62. getOpSize: (op) ->
  63. @doc?.getOpSize(op)
  64. hasBufferedOps: () ->
  65. @doc?.hasBufferedOps()
  66. _bindToSocketEvents: () ->
  67. @_onUpdateAppliedHandler = (update) => @_onUpdateApplied(update)
  68. @ide.socket.on "otUpdateApplied", @_onUpdateAppliedHandler
  69. @_onErrorHandler = (error, update) => @_onError(error, update)
  70. @ide.socket.on "otUpdateError", @_onErrorHandler
  71. @_onDisconnectHandler = (error) => @_onDisconnect(error)
  72. @ide.socket.on "disconnect", @_onDisconnectHandler
  73. _bindToEditorEvents: () ->
  74. onReconnectHandler = (update) =>
  75. @_onReconnect(update)
  76. @_unsubscribeReconnectHandler = @ide.$scope.$on "project:joined", onReconnectHandler
  77. _unBindFromEditorEvents: () ->
  78. @_unsubscribeReconnectHandler()
  79. _unBindFromSocketEvents: () ->
  80. @ide.socket.removeListener "otUpdateApplied", @_onUpdateAppliedHandler
  81. @ide.socket.removeListener "otUpdateError", @_onErrorHandler
  82. @ide.socket.removeListener "disconnect", @_onDisconnectHandler
  83. leaveAndCleanUp: () ->
  84. @leave (error) =>
  85. @_cleanUp()
  86. join: (callback = (error) ->) ->
  87. @wantToBeJoined = true
  88. @_cancelLeave()
  89. if @connected
  90. return @_joinDoc callback
  91. else
  92. @_joinCallbacks ||= []
  93. @_joinCallbacks.push callback
  94. leave: (callback = (error) ->) ->
  95. @wantToBeJoined = false
  96. @_cancelJoin()
  97. if (@doc? and @doc.hasBufferedOps())
  98. sl_console.log "[leave] Doc has buffered ops, pushing callback for later"
  99. @_leaveCallbacks ||= []
  100. @_leaveCallbacks.push callback
  101. else if !@connected
  102. sl_console.log "[leave] Not connected, returning now"
  103. callback()
  104. else
  105. sl_console.log "[leave] Leaving now"
  106. @_leaveDoc(callback)
  107. flush: () ->
  108. @doc?.flushPendingOps()
  109. chaosMonkey: (line = 0, char = "a") ->
  110. orig = char
  111. copy = null
  112. pos = 0
  113. timer = () =>
  114. unless copy? and copy.length
  115. copy = orig.slice() + ' ' + new Date() + '\n'
  116. line += if Math.random() > 0.1 then 1 else -2
  117. line = 0 if line < 0
  118. pos = 0
  119. char = copy[0]
  120. copy = copy.slice(1)
  121. @ace.session.insert({row: line, column: pos}, char)
  122. pos += 1
  123. @_cm = setTimeout timer, 100 + if Math.random() < 0.1 then 1000 else 0
  124. @_cm = timer()
  125. clearChaosMonkey: () ->
  126. clearTimeout @_cm
  127. MAX_PENDING_OP_SIZE: 30 # pending ops bigger than this are always considered unsaved
  128. pollSavedStatus: () ->
  129. # returns false if doc has ops waiting to be acknowledged or
  130. # sent that haven't changed since the last time we checked.
  131. # Otherwise returns true.
  132. inflightOp = @getInflightOp()
  133. pendingOp = @getPendingOp()
  134. recentAck = @getRecentAck()
  135. pendingOpSize = pendingOp? && @getOpSize(pendingOp)
  136. if !inflightOp? and !pendingOp?
  137. # there's nothing going on, this is ok.
  138. saved = true
  139. sl_console.log "[pollSavedStatus] no inflight or pending ops"
  140. else if inflightOp? and inflightOp == @oldInflightOp
  141. # The same inflight op has been sitting unacked since we
  142. # last checked, this is bad.
  143. saved = false
  144. sl_console.log "[pollSavedStatus] inflight op is same as before"
  145. else if pendingOp? and recentAck && pendingOpSize < @MAX_PENDING_OP_SIZE
  146. # There is an op waiting to go to server but it is small and
  147. # within the flushDelay, this is ok for now.
  148. saved = true
  149. sl_console.log "[pollSavedStatus] pending op (small with recent ack) assume ok", pendingOp, pendingOpSize
  150. else
  151. # In any other situation, assume the document is unsaved.
  152. saved = false
  153. sl_console.log "[pollSavedStatus] assuming not saved (inflightOp?: #{inflightOp?}, pendingOp?: #{pendingOp?})"
  154. @oldInflightOp = inflightOp
  155. return saved
  156. _cancelLeave: () ->
  157. if @_leaveCallbacks?
  158. delete @_leaveCallbacks
  159. _cancelJoin: () ->
  160. if @_joinCallbacks?
  161. delete @_joinCallbacks
  162. _onUpdateApplied: (update) ->
  163. @ide.pushEvent "received-update",
  164. doc_id: @doc_id
  165. remote_doc_id: update?.doc
  166. wantToBeJoined: @wantToBeJoined
  167. update: update
  168. if window.disconnectOnAck? and Math.random() < window.disconnectOnAck
  169. sl_console.log "Disconnecting on ack", update
  170. window._ide.socket.socket.disconnect()
  171. # Pretend we never received the ack
  172. return
  173. if window.dropAcks? and Math.random() < window.dropAcks
  174. if !update.op? # Only drop our own acks, not collaborator updates
  175. sl_console.log "Simulating a lost ack", update
  176. return
  177. if update?.doc == @doc_id and @doc?
  178. @doc.processUpdateFromServer update
  179. if !@wantToBeJoined
  180. @leave()
  181. _onDisconnect: () ->
  182. sl_console.log '[onDisconnect] disconnecting'
  183. @connected = false
  184. @joined = false
  185. @doc?.updateConnectionState "disconnected"
  186. _onReconnect: () ->
  187. sl_console.log "[onReconnect] reconnected (joined project)"
  188. @ide.pushEvent "reconnected:afterJoinProject"
  189. @connected = true
  190. if @wantToBeJoined or @doc?.hasBufferedOps()
  191. sl_console.log "[onReconnect] Rejoining (wantToBeJoined: #{@wantToBeJoined} OR hasBufferedOps: #{@doc?.hasBufferedOps()})"
  192. @_joinDoc (error) =>
  193. return @_onError(error) if error?
  194. @doc.updateConnectionState "ok"
  195. @doc.flushPendingOps()
  196. @_callJoinCallbacks()
  197. _callJoinCallbacks: () ->
  198. for callback in @_joinCallbacks or []
  199. callback()
  200. delete @_joinCallbacks
  201. _joinDoc: (callback = (error) ->) ->
  202. if @doc?
  203. @ide.socket.emit 'joinDoc', @doc_id, @doc.getVersion(), (error, docLines, version, updates) =>
  204. return callback(error) if error?
  205. @joined = true
  206. @doc.catchUp( updates )
  207. callback()
  208. else
  209. @ide.socket.emit 'joinDoc', @doc_id, (error, docLines, version) =>
  210. return callback(error) if error?
  211. @joined = true
  212. @doc = new ShareJsDoc @doc_id, docLines, version, @ide.socket
  213. @_bindToShareJsDocEvents()
  214. callback()
  215. _leaveDoc: (callback = (error) ->) ->
  216. sl_console.log '[_leaveDoc] Sending leaveDoc request'
  217. @ide.socket.emit 'leaveDoc', @doc_id, (error) =>
  218. return callback(error) if error?
  219. @joined = false
  220. for callback in @_leaveCallbacks or []
  221. sl_console.log '[_leaveDoc] Calling buffered callback', callback
  222. callback(error)
  223. delete @_leaveCallbacks
  224. callback(error)
  225. _cleanUp: () ->
  226. if Document.openDocs[@doc_id] == @
  227. sl_console.log "[_cleanUp] Removing self (#{@doc_id}) from in openDocs"
  228. delete Document.openDocs[@doc_id]
  229. else
  230. # It's possible that this instance has error, and the doc has been reloaded.
  231. # This creates a new instance in Document.openDoc with the same id. We shouldn't
  232. # clear it because it's not this instance.
  233. sl_console.log "[_cleanUp] New instance of (#{@doc_id}) created. Not removing"
  234. @_unBindFromEditorEvents()
  235. @_unBindFromSocketEvents()
  236. _bindToShareJsDocEvents: () ->
  237. @doc.on "error", (error, meta) => @_onError error, meta
  238. @doc.on "externalUpdate", (update) =>
  239. @ide.pushEvent "externalUpdate",
  240. doc_id: @doc_id
  241. @trigger "externalUpdate", update
  242. @doc.on "remoteop", (args...) =>
  243. @ide.pushEvent "remoteop",
  244. doc_id: @doc_id
  245. @trigger "remoteop", args...
  246. @doc.on "op:sent", (op) =>
  247. @ide.pushEvent "op:sent",
  248. doc_id: @doc_id
  249. op: op
  250. @trigger "op:sent"
  251. @doc.on "op:acknowledged", (op) =>
  252. @ide.pushEvent "op:acknowledged",
  253. doc_id: @doc_id
  254. op: op
  255. @trigger "op:acknowledged"
  256. @doc.on "op:timeout", (op) =>
  257. @ide.pushEvent "op:timeout",
  258. doc_id: @doc_id
  259. op: op
  260. @trigger "op:timeout"
  261. @_onError new Error("op timed out"), {op: op}
  262. @doc.on "flush", (inflightOp, pendingOp, version) =>
  263. @ide.pushEvent "flush",
  264. doc_id: @doc_id,
  265. inflightOp: inflightOp,
  266. pendingOp: pendingOp
  267. v: version
  268. _onError: (error, meta = {}) ->
  269. meta.doc_id = @doc_id
  270. console.error "ShareJS error", error, meta
  271. ga?('send', 'event', 'error', "shareJsError", "#{error.message} - #{@ide.socket.socket.transport.name}" )
  272. @doc?.clearInflightAndPendingOps()
  273. @trigger "error", error, meta
  274. # The clean up should run after the error is triggered because the error triggers a
  275. # disconnect. If we run the clean up first, we remove our event handlers and miss
  276. # the disconnect event, which means we try to leaveDoc when the connection comes back.
  277. # This could intefere with the new connection of a new instance of this document.
  278. @_cleanUp()