editor-watchdog-manager.ts 7.7 KB

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