overleaf/services/web/public/coffee/ide/editor/Document.coffee
James Allen cde5144c42 Retry updates that have not been acknowledged.
If we do not get a reply from the server acknowledging our update after 5 seconds,
send it again. If it never got to the server, this is like normal. If the update
got to the server, but we never received the ack then we need to rely on ShareJs's
duplicate handling. We set the dupIfSource parameter on any retried updates which
let ShareJs know that it's a dup if we already have an op with this version number
and client id. The doc-updater and real-time services need changes to correctly
send another ack only to the submitting client in the case of a duplicate update.
2015-11-19 12:04:12 +00:00

254 lines
7 KiB
CoffeeScript

define [
"utils/EventEmitter"
"ide/editor/ShareJsDoc"
], (EventEmitter, ShareJsDoc) ->
class Document extends EventEmitter
@getDocument: (ide, doc_id) ->
@openDocs ||= {}
if !@openDocs[doc_id]?
@openDocs[doc_id] = new Document(ide, doc_id)
return @openDocs[doc_id]
@hasUnsavedChanges: () ->
for doc_id, doc of (@openDocs or {})
return true if doc.hasBufferedOps()
return false
@flushAll: () ->
for doc_id, doc of @openDocs
doc.flush()
constructor: (@ide, @doc_id) ->
@connected = @ide.socket.socket.connected
@joined = false
@wantToBeJoined = false
@_checkConsistency = _.bind(@_checkConsistency, @)
@inconsistentCount = 0
@_bindToEditorEvents()
@_bindToSocketEvents()
attachToAce: (@ace) ->
@doc?.attachToAce(@ace)
editorDoc = @ace.getSession().getDocument()
editorDoc.on "change", @_checkConsistency
detachFromAce: () ->
@doc?.detachFromAce()
editorDoc = @ace?.getSession().getDocument()
editorDoc?.off "change", @_checkConsistency
_checkConsistency: () ->
# We've been seeing a lot of errors when I think there shouldn't be
# any, which may be related to this check happening before the change is
# applied. If we use a timeout, hopefully we can reduce this.
setTimeout () =>
editorValue = @ace?.getValue()
sharejsValue = @doc?.getSnapshot()
if editorValue != sharejsValue
@inconsistentCount++
else
@inconsistentCount = 0
if @inconsistentCount >= 3
@_onError new Error("Editor text does not match server text")
, 0
getSnapshot: () ->
@doc?.getSnapshot()
getType: () ->
@doc?.getType()
getInflightOp: () ->
@doc?.getInflightOp()
getPendingOp: () ->
@doc?.getPendingOp()
hasBufferedOps: () ->
@doc?.hasBufferedOps()
_bindToSocketEvents: () ->
@_onUpdateAppliedHandler = (update) => @_onUpdateApplied(update)
@ide.socket.on "otUpdateApplied", @_onUpdateAppliedHandler
@_onErrorHandler = (error, update) => @_onError(error, update)
@ide.socket.on "otUpdateError", @_onErrorHandler
@_onDisconnectHandler = (error) => @_onDisconnect(error)
@ide.socket.on "disconnect", @_onDisconnectHandler
_bindToEditorEvents: () ->
onReconnectHandler = (update) =>
@_onReconnect(update)
@_unsubscribeReconnectHandler = @ide.$scope.$on "project:joined", onReconnectHandler
_unBindFromEditorEvents: () ->
@_unsubscribeReconnectHandler()
_unBindFromSocketEvents: () ->
@ide.socket.removeListener "otUpdateApplied", @_onUpdateAppliedHandler
@ide.socket.removeListener "otUpdateError", @_onErrorHandler
@ide.socket.removeListener "disconnect", @_onDisconnectHandler
leaveAndCleanUp: () ->
@leave (error) =>
@_cleanUp()
join: (callback = (error) ->) ->
@wantToBeJoined = true
@_cancelLeave()
if @connected
return @_joinDoc callback
else
@_joinCallbacks ||= []
@_joinCallbacks.push callback
leave: (callback = (error) ->) ->
@wantToBeJoined = false
@_cancelJoin()
if (@doc? and @doc.hasBufferedOps())
@_leaveCallbacks ||= []
@_leaveCallbacks.push callback
else if !@connected
callback()
else
@_leaveDoc(callback)
flush: () ->
@doc?.flushPendingOps()
pollSavedStatus: () ->
# returns false if doc has ops waiting to be acknowledged or
# sent that haven't changed since the last time we checked.
# Otherwise returns true.
inflightOp = @getInflightOp()
pendingOp = @getPendingOp()
if !inflightOp? and !pendingOp?
# there's nothing going on
saved = true
else if inflightOp == @oldInflightOp
saved = false
else if pendingOp?
saved = false
else
saved = true
@oldInflightOp = inflightOp
return saved
_cancelLeave: () ->
if @_leaveCallbacks?
delete @_leaveCallbacks
_cancelJoin: () ->
if @_joinCallbacks?
delete @_joinCallbacks
_onUpdateApplied: (update) ->
@ide.pushEvent "received-update",
doc_id: @doc_id
remote_doc_id: update?.doc
wantToBeJoined: @wantToBeJoined
update: update
if window.dropAcks? and Math.random() < window.dropAcks
if !update.op? # Only drop our own acks, not collaborator updates
console.log "Simulating a lost ack", update
return
if update?.doc == @doc_id and @doc?
@doc.processUpdateFromServer update
if !@wantToBeJoined
@leave()
_onDisconnect: () ->
@connected = false
@joined = false
@doc?.updateConnectionState "disconnected"
_onReconnect: () ->
@ide.pushEvent "reconnected:afterJoinProject"
@connected = true
if @wantToBeJoined or @doc?.hasBufferedOps()
@_joinDoc (error) =>
return @_onError(error) if error?
@doc.updateConnectionState "ok"
@doc.flushPendingOps()
@_callJoinCallbacks()
_callJoinCallbacks: () ->
for callback in @_joinCallbacks or []
callback()
delete @_joinCallbacks
_joinDoc: (callback = (error) ->) ->
if @doc?
@ide.socket.emit 'joinDoc', @doc_id, @doc.getVersion(), (error, docLines, version, updates) =>
return callback(error) if error?
@joined = true
@doc.catchUp( updates )
callback()
else
@ide.socket.emit 'joinDoc', @doc_id, (error, docLines, version) =>
return callback(error) if error?
@joined = true
@doc = new ShareJsDoc @doc_id, docLines, version, @ide.socket
@_bindToShareJsDocEvents()
callback()
_leaveDoc: (callback = (error) ->) ->
@ide.socket.emit 'leaveDoc', @doc_id, (error) =>
return callback(error) if error?
@joined = false
for callback in @_leaveCallbacks or []
callback(error)
delete @_leaveCallbacks
callback(error)
_cleanUp: () ->
delete Document.openDocs[@doc_id]
@_unBindFromEditorEvents()
@_unBindFromSocketEvents()
_bindToShareJsDocEvents: () ->
@doc.on "error", (error, meta) => @_onError error, meta
@doc.on "externalUpdate", (update) =>
@ide.pushEvent "externalUpdate",
doc_id: @doc_id
@trigger "externalUpdate", update
@doc.on "remoteop", () =>
@ide.pushEvent "remoteop",
doc_id: @doc_id
@trigger "remoteop"
@doc.on "op:sent", (op) =>
@ide.pushEvent "op:sent",
doc_id: @doc_id
op: op
@trigger "op:sent"
@doc.on "op:acknowledged", (op) =>
@ide.pushEvent "op:acknowledged",
doc_id: @doc_id
op: op
@trigger "op:acknowledged"
@doc.on "op:timeout", (op) =>
@ide.pushEvent "op:timeout",
doc_id: @doc_id
op: op
@trigger "op:timeout"
ga?('send', 'event', 'error', "op timeout", "Op was now acknowledged - #{@ide.socket.socket.transport.name}" )
@ide.connectionManager.reconnectImmediately()
@doc.on "flush", (inflightOp, pendingOp, version) =>
@ide.pushEvent "flush",
doc_id: @doc_id,
inflightOp: inflightOp,
pendingOp: pendingOp
v: version
_onError: (error, meta = {}) ->
meta.doc_id = @doc_id
console.error "ShareJS error", error, meta
ga?('send', 'event', 'error', "shareJsError", "#{error.message} - #{@ide.socket.socket.transport.name}" )
@doc?.clearInflightAndPendingOps()
@_cleanUp()
@trigger "error", error, meta