EditorWatchdogManager.js 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. /*
  2. EditorWatchdogManager is used for end-to-end checks of edits.
  3. The editor UI is backed by Ace and CodeMirrors, which in turn are connected
  4. to ShareJs documents in the frontend.
  5. Edits propagate from the editor to ShareJs and are send through socket.io
  6. and real-time to document-updater.
  7. In document-updater edits are integrated into the document history and
  8. a confirmation/rejection is sent back to the frontend.
  9. Along the way things can get lost.
  10. We have certain safe-guards in place, but are still getting occasional
  11. reports of lost edits.
  12. EditorWatchdogManager is implementing the basis for end-to-end checks on
  13. two levels:
  14. - local/ShareJsDoc: edits that pass-by a ShareJs document shall get
  15. acknowledged eventually.
  16. - global: any edits made in the editor shall get acknowledged eventually,
  17. independent for which ShareJs document (potentially none) sees it.
  18. How does this work?
  19. ===================
  20. The global check is using a global EditorWatchdogManager that is available
  21. via the angular factory 'ide'.
  22. Local/ShareJsDoc level checks will connect to the global instance.
  23. Each EditorWatchdogManager keeps track of the oldest un-acknowledged edit.
  24. When ever a ShareJs document receives an acknowledgement event, a local
  25. EditorWatchdogManager will see it and also notify the global instance about
  26. it.
  27. The next edit cycle will clear the oldest un-acknowledged timestamp in case
  28. a new ack has arrived, otherwise it will bark loud! via the timeout handler.
  29. Scenarios
  30. =========
  31. - User opens the CodeMirror editor
  32. - attach global check to new CM instance
  33. - detach Ace from the local EditorWatchdogManager
  34. - when the frontend attaches the CM instance to ShareJs, we also
  35. attach it to the local EditorWatchdogManager
  36. - the internal attach process writes the document content to the editor,
  37. which in turn emits 'change' events. These event need to be excluded
  38. from the watchdog. EditorWatchdogManager.ignoreEditsFor takes care
  39. of that.
  40. - User opens the Ace editor (again)
  41. - (attach global check to the Ace editor, only one copy of Ace is around)
  42. - detach local EditorWatchdogManager from CM
  43. - likewise with CM, attach Ace to the local EditorWatchdogManager
  44. - User makes an edit
  45. - the editor will emit a 'change' event
  46. - the global EditorWatchdogManager will process it first
  47. - the local EditorWatchdogManager will process it next
  48. - Document-updater confirms an edit
  49. - the local EditorWatchdogManager will process it first, it passes it on to
  50. - the global EditorWatchdogManager will process it next
  51. Time
  52. ====
  53. The delay between edits and acks is measured using a monotonic clock:
  54. `performance.now()`.
  55. It is agnostic to system clock changes in either direction and timezone
  56. changes do not affect it as well.
  57. Roughly speaking, it is initialized with `0` when the `window` context is
  58. created, before our JS app boots.
  59. As per canIUse.com and MDN `performance.now()` is available to all supported
  60. Browsers, including IE11.
  61. See also: https://caniuse.com/?search=performance.now
  62. See also: https://developer.mozilla.org/en-US/docs/Web/API/Performance/now
  63. */
  64. import { debugConsole } from '@/utils/debugging'
  65. // TIMEOUT specifies the timeout for edits into a single ShareJsDoc.
  66. const TIMEOUT = 60 * 1000
  67. // GLOBAL_TIMEOUT specifies the timeout for edits into any ShareJSDoc.
  68. const GLOBAL_TIMEOUT = TIMEOUT
  69. // REPORT_EVERY specifies how often we send events/report errors.
  70. const REPORT_EVERY = 60 * 1000
  71. const SCOPE_LOCAL = 'ShareJsDoc'
  72. const SCOPE_GLOBAL = 'global'
  73. class Reporter {
  74. constructor(onTimeoutHandler) {
  75. this._onTimeoutHandler = onTimeoutHandler
  76. this._lastReport = undefined
  77. this._queue = []
  78. }
  79. _getMetaPreferLocal() {
  80. for (const meta of this._queue) {
  81. if (meta.scope === SCOPE_LOCAL) {
  82. return meta
  83. }
  84. }
  85. return this._queue.pop()
  86. }
  87. onTimeout(meta) {
  88. // Collect all 'meta's for this update.
  89. // global arrive before local ones, but we are eager to report local ones.
  90. this._queue.push(meta)
  91. setTimeout(() => {
  92. // Another handler processed the 'meta' entry already
  93. if (!this._queue.length) return
  94. const maybeLocalMeta = this._getMetaPreferLocal()
  95. // Discard other, newly arrived 'meta's
  96. this._queue.length = 0
  97. const now = Date.now()
  98. // Do not flood the server with losing-edits events
  99. const reportedRecently = now - this._lastReport < REPORT_EVERY
  100. if (!reportedRecently) {
  101. this._lastReport = now
  102. this._onTimeoutHandler(maybeLocalMeta)
  103. }
  104. })
  105. }
  106. }
  107. export default class EditorWatchdogManager {
  108. constructor({ parent, onTimeoutHandler }) {
  109. this.scope = parent ? SCOPE_LOCAL : SCOPE_GLOBAL
  110. this.timeout = parent ? TIMEOUT : GLOBAL_TIMEOUT
  111. this.parent = parent
  112. if (parent) {
  113. this.reporter = parent.reporter
  114. } else {
  115. this.reporter = new Reporter(onTimeoutHandler)
  116. }
  117. this.lastAck = null
  118. this.lastUnackedEdit = null
  119. }
  120. onAck() {
  121. this.lastAck = performance.now()
  122. // bubble up to globalEditorWatchdogManager
  123. if (this.parent) this.parent.onAck()
  124. }
  125. onEdit() {
  126. // Use timestamps to track the high-water mark of unacked edits
  127. const now = performance.now()
  128. // Discard the last unacked edit if there are now newer acks
  129. if (this.lastAck > this.lastUnackedEdit) {
  130. this.lastUnackedEdit = null
  131. }
  132. // Start tracking for this keypress if we aren't already tracking an
  133. // unacked edit
  134. if (!this.lastUnackedEdit) {
  135. this.lastUnackedEdit = now
  136. }
  137. // Report an error if the last tracked edit hasn't been cleared by an
  138. // ack from the server after a long time
  139. const delay = now - this.lastUnackedEdit
  140. if (delay > this.timeout) {
  141. const timeOrigin = Date.now() - now
  142. const scope = this.scope
  143. const lastAck = new Date(this.lastAck ? timeOrigin + this.lastAck : 0)
  144. const lastUnackedEdit = new Date(timeOrigin + this.lastUnackedEdit)
  145. const meta = { scope, delay, lastAck, lastUnackedEdit }
  146. this._log('timedOut', meta)
  147. this.reporter.onTimeout(meta)
  148. }
  149. }
  150. attachToEditor(editorName, editor) {
  151. let onChange
  152. if (editorName === 'CM6') {
  153. // Code Mirror 6
  154. this._log('attach to editor', editorName)
  155. onChange = (_editor, changeDescription) => {
  156. if (changeDescription.origin === 'remote') return
  157. if (!(changeDescription.removed || changeDescription.inserted)) return
  158. this.onEdit()
  159. }
  160. editor.on('change', onChange)
  161. const detachFromEditor = () => {
  162. this._log('detach from editor', editorName)
  163. editor.off('change', onChange)
  164. }
  165. return detachFromEditor
  166. }
  167. if (editorName === 'CM') {
  168. // CM is passing the CM instance as first parameter, then the change.
  169. onChange = (editor, change) => {
  170. // Ignore remote changes.
  171. if (change.origin === 'remote') return
  172. // sharejs only looks at DEL or INSERT change events.
  173. // NOTE: Keep in sync with sharejs.
  174. if (!(change.removed || change.text)) return
  175. this.onEdit()
  176. }
  177. } else {
  178. // ACE is passing the change object as first parameter.
  179. onChange = change => {
  180. // Ignore remote changes.
  181. if (change.origin === 'remote') return
  182. // sharejs only looks at DEL or INSERT change events.
  183. // NOTE: Keep in sync with sharejs.
  184. if (!(change.action === 'remove' || change.action === 'insert')) return
  185. this.onEdit()
  186. }
  187. }
  188. this._log('attach to editor', editorName)
  189. editor.on('change', onChange)
  190. const detachFromEditor = () => {
  191. this._log('detach from editor', editorName)
  192. editor.off('change', onChange)
  193. }
  194. return detachFromEditor
  195. }
  196. _log() {
  197. debugConsole.log(`[EditorWatchdogManager] ${this.scope}:`, ...arguments)
  198. }
  199. }