overleaf/services/web/public/coffee/ide/editor/Document.coffee
Alasdair Smith e2bb6dcecb Pass option to encode range
To ensure backwards compat with clients not refreshing, pass a flag to enable
encoding.  This way, old client won't receive encoded ranges, but also won't
have decoding logic. The flag can then be removed once all clients are up to
date
2017-09-21 14:23:39 +01:00

394 lines
13 KiB
CoffeeScript

define [
"utils/EventEmitter"
"ide/editor/ShareJsDoc"
"ide/review-panel/RangesTracker"
], (EventEmitter, ShareJsDoc, RangesTracker) ->
class Document extends EventEmitter
@getDocument: (ide, doc_id) ->
@openDocs ||= {}
if !@openDocs[doc_id]?
sl_console.log "[getDocument] Creating new document instance for #{doc_id}"
@openDocs[doc_id] = new Document(ide, doc_id)
else
sl_console.log "[getDocument] Returning existing document instance for #{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
@ide.$scope.$emit 'document:opened', @doc
detachFromAce: () ->
@doc?.detachFromAce()
editorDoc = @ace?.getSession().getDocument()
editorDoc?.off "change", @_checkConsistency
@ide.$scope.$emit 'document:closed', @doc
submitOp: (args...) -> @doc?.submitOp(args...)
_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()
getRecentAck: () ->
@doc?.getRecentAck()
getOpSize: (op) ->
@doc?.getOpSize(op)
hasBufferedOps: () ->
@doc?.hasBufferedOps()
setTrackingChanges: (track_changes) ->
@doc.track_changes = track_changes
getTrackingChanges: () ->
!!@doc.track_changes
setTrackChangesIdSeeds: (id_seeds) ->
@doc.track_changes_id_seeds = id_seeds
_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())
sl_console.log "[leave] Doc has buffered ops, pushing callback for later"
@_leaveCallbacks ||= []
@_leaveCallbacks.push callback
else if !@connected
sl_console.log "[leave] Not connected, returning now"
callback()
else
sl_console.log "[leave] Leaving now"
@_leaveDoc(callback)
flush: () ->
@doc?.flushPendingOps()
chaosMonkey: (line = 0, char = "a") ->
orig = char
copy = null
pos = 0
timer = () =>
unless copy? and copy.length
copy = orig.slice() + ' ' + new Date() + '\n'
line += if Math.random() > 0.1 then 1 else -2
line = 0 if line < 0
pos = 0
char = copy[0]
copy = copy.slice(1)
@ace.session.insert({row: line, column: pos}, char)
pos += 1
@_cm = setTimeout timer, 100 + if Math.random() < 0.1 then 1000 else 0
@_cm = timer()
clearChaosMonkey: () ->
clearTimeout @_cm
MAX_PENDING_OP_SIZE: 30 # pending ops bigger than this are always considered unsaved
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()
recentAck = @getRecentAck()
pendingOpSize = pendingOp? && @getOpSize(pendingOp)
if !inflightOp? and !pendingOp?
# there's nothing going on, this is ok.
saved = true
sl_console.log "[pollSavedStatus] no inflight or pending ops"
else if inflightOp? and inflightOp == @oldInflightOp
# The same inflight op has been sitting unacked since we
# last checked, this is bad.
saved = false
sl_console.log "[pollSavedStatus] inflight op is same as before"
else if pendingOp? and recentAck && pendingOpSize < @MAX_PENDING_OP_SIZE
# There is an op waiting to go to server but it is small and
# within the flushDelay, this is ok for now.
saved = true
sl_console.log "[pollSavedStatus] pending op (small with recent ack) assume ok", pendingOp, pendingOpSize
else
# In any other situation, assume the document is unsaved.
saved = false
sl_console.log "[pollSavedStatus] assuming not saved (inflightOp?: #{inflightOp?}, pendingOp?: #{pendingOp?})"
@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.disconnectOnAck? and Math.random() < window.disconnectOnAck
sl_console.log "Disconnecting on ack", update
window._ide.socket.socket.disconnect()
# Pretend we never received the ack
return
if window.dropAcks? and Math.random() < window.dropAcks
if !update.op? # Only drop our own acks, not collaborator updates
sl_console.log "Simulating a lost ack", update
return
if update?.doc == @doc_id and @doc?
@doc.processUpdateFromServer update
if !@wantToBeJoined
@leave()
_onDisconnect: () ->
sl_console.log '[onDisconnect] disconnecting'
@connected = false
@joined = false
@doc?.updateConnectionState "disconnected"
_onReconnect: () ->
sl_console.log "[onReconnect] reconnected (joined project)"
@ide.pushEvent "reconnected:afterJoinProject"
@connected = true
if @wantToBeJoined or @doc?.hasBufferedOps()
sl_console.log "[onReconnect] Rejoining (wantToBeJoined: #{@wantToBeJoined} OR hasBufferedOps: #{@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, { encodeRanges: true }, @doc.getVersion(), (error, docLines, version, updates, ranges) =>
return callback(error) if error?
@joined = true
@doc.catchUp( updates )
{ changes, comments } = @_decodeRanges(ranges)
@_catchUpRanges( changes, comments )
callback()
else
@ide.socket.emit 'joinDoc', @doc_id, { encodeRanges: true }, (error, docLines, version, updates, ranges) =>
return callback(error) if error?
@joined = true
@doc = new ShareJsDoc @doc_id, docLines, version, @ide.socket
{ changes, comments } = @_decodeRanges(ranges)
@ranges = new RangesTracker(changes, comments)
@_bindToShareJsDocEvents()
callback()
_decodeRanges: (ranges) ->
if ranges?.changes
changes = for change in ranges.changes
change.op.i = decodeURIComponent(escape(change.op.i)) if change.op.i
change.op.d = decodeURIComponent(escape(change.op.d)) if change.op.d
change
if ranges?.comments
comments = for comment in ranges.comments
comment.op.c = decodeURIComponent(escape(comment.op.c))
comment
{ changes, comments }
_leaveDoc: (callback = (error) ->) ->
sl_console.log '[_leaveDoc] Sending leaveDoc request'
@ide.socket.emit 'leaveDoc', @doc_id, (error) =>
return callback(error) if error?
@joined = false
for callback in @_leaveCallbacks or []
sl_console.log '[_leaveDoc] Calling buffered callback', callback
callback(error)
delete @_leaveCallbacks
callback(error)
_cleanUp: () ->
if Document.openDocs[@doc_id] == @
sl_console.log "[_cleanUp] Removing self (#{@doc_id}) from in openDocs"
delete Document.openDocs[@doc_id]
else
# It's possible that this instance has error, and the doc has been reloaded.
# This creates a new instance in Document.openDoc with the same id. We shouldn't
# clear it because it's not this instance.
sl_console.log "[_cleanUp] New instance of (#{@doc_id}) created. Not removing"
@_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", (args...) =>
@ide.pushEvent "remoteop",
doc_id: @doc_id
@trigger "remoteop", args...
@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
@ide.$scope.$emit "ide:opAcknowledged",
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"
@_onError new Error("op timed out"), {op: op}
@doc.on "flush", (inflightOp, pendingOp, version) =>
@ide.pushEvent "flush",
doc_id: @doc_id,
inflightOp: inflightOp,
pendingOp: pendingOp
v: version
@doc.on "change", (ops, oldSnapshot, msg) =>
@_applyOpsToRanges(ops, oldSnapshot, msg)
@doc.on "flipped_pending_to_inflight", () =>
@trigger "flipped_pending_to_inflight"
_onError: (error, meta = {}) ->
meta.doc_id = @doc_id
sl_console.log "ShareJS error", error, meta
ga?('send', 'event', 'error', "shareJsError", "#{error.message} - #{@ide.socket.socket.transport.name}" )
@doc?.clearInflightAndPendingOps()
@trigger "error", error, meta
# The clean up should run after the error is triggered because the error triggers a
# disconnect. If we run the clean up first, we remove our event handlers and miss
# the disconnect event, which means we try to leaveDoc when the connection comes back.
# This could intefere with the new connection of a new instance of this document.
@_cleanUp()
_applyOpsToRanges: (ops = [], oldSnapshot, msg) ->
track_changes_as = null
remote_op = msg?
if msg?.meta?.tc?
old_id_seed = @ranges.getIdSeed()
@ranges.setIdSeed(msg.meta.tc)
if remote_op and msg.meta?.tc
track_changes_as = msg.meta.user_id
else if !remote_op and @track_changes_as?
track_changes_as = @track_changes_as
@ranges.track_changes = track_changes_as?
for op in ops
@ranges.applyOp op, { user_id: track_changes_as }
if old_id_seed?
@ranges.setIdSeed(old_id_seed)
if remote_op
# With remote ops, Ace hasn't been updated when we receive this op,
# so defer updating track changes until it has
setTimeout () => @emit "ranges:dirty"
else
@emit "ranges:dirty"
_catchUpRanges: (changes = [], comments = []) ->
# We've just been given the current server's ranges, but need to apply any local ops we have.
# Reset to the server state then apply our local ops again.
@emit "ranges:clear"
@ranges.changes = changes
@ranges.comments = comments
@ranges.track_changes = @doc.track_changes
for op in @doc.getInflightOp() or []
@ranges.setIdSeed(@doc.track_changes_id_seeds.inflight)
@ranges.applyOp(op, { user_id: @track_changes_as })
for op in @doc.getPendingOp() or []
@ranges.setIdSeed(@doc.track_changes_id_seeds.pending)
@ranges.applyOp(op, { user_id: @track_changes_as })
@emit "ranges:redraw"