| 1 | // Copied verbatim from Docs (oficina/docs/fixtures/resync-controller.mjs), the first |
| 2 | // implementation of the live-sync client; Slides is the second, and the |
| 3 | // extraction to a shared module is slice 4. Only the header name differs: |
| 4 | // Docs-Seq is Slides-Seq here. |
| 5 | // fixtures/resync-controller.mjs — the state machine for "the local |
| 6 | // document has diverged from the server and needs to be rebuilt from |
| 7 | // server truth," factored out of fixtures/editor-entry.js so it can |
| 8 | // be driven under plain Node with a stub resync and a fake clock — |
| 9 | // see fixtures/resync-controller.test.mjs. |
| 10 | // |
| 11 | // This is fix round 2's own fix. Round 1's resync() ran once and, on |
| 12 | // failure, just logged and gave up — its own comment claimed the |
| 13 | // editor would get "another chance to resync the next time a batch |
| 14 | // is dropped," which is false: once a batch has already been |
| 15 | // dropped, the local Y.Doc has already diverged, and every update it |
| 16 | // produces from then on is encoded relative to content the server |
| 17 | // never received. core.Handle.Apply does not error on such an |
| 18 | // update — it has nowhere to attach it, so it returns a real 204 and |
| 19 | // silently never integrates it. No further 4xx ever arrives to |
| 20 | // trigger another resync attempt, so one transient resync failure |
| 21 | // (a dropped connection at exactly the wrong moment, reproduced by |
| 22 | // the round 2 reviewer by aborting the resync fetch) recreated the |
| 23 | // silent-204 data loss for the rest of the session. Reproduced again |
| 24 | // here, deliberately, before this fix: fixtures/resync-controller. |
| 25 | // test.mjs's own "without retry" case shows attemptResync called |
| 26 | // exactly once and diverged staying true forever after a failure. |
| 27 | // |
| 28 | // The fix: retry the resync itself, with backoff, and again |
| 29 | // immediately on the browser's own 'online' event — and expose |
| 30 | // `diverged` so the caller (editor-entry.js) can gate what "saving |
| 31 | // fine" even means while it is true: a 204 during divergence proves |
| 32 | // nothing, so no local edit may be treated as saved, and none should |
| 33 | // even be sent (fix round 2's LOW note: every 204 taken during |
| 34 | // divergence permanently consumes a maxRetainedUpdates row for |
| 35 | // nothing). |
| 36 | export function createResyncController({ |
| 37 | attemptResync, |
| 38 | onDiverged, |
| 39 | onRecovered, |
| 40 | // Exponential backoff, capped: 1s, 2s, 4s, 8s, 16s, 30s, 30s, ... — |
| 41 | // fast enough to shrug off a one-off blip without the person |
| 42 | // noticing, capped low enough that a genuinely offline session |
| 43 | // isn't left waiting minutes between attempts once connectivity |
| 44 | // does return. The 'online' listener below is the fast path for |
| 45 | // that case; backoff is the fallback for everything the browser's |
| 46 | // own online/offline signal does not catch (a resync that fails |
| 47 | // for a reason other than "no network at all"). |
| 48 | backoffMs = (attempt) => Math.min(1000 * 2 ** (attempt - 1), 30000), |
| 49 | setTimeoutFn = setTimeout, |
| 50 | clearTimeoutFn = clearTimeout, |
| 51 | addOnlineListener = (fn) => addEventListener('online', fn), |
| 52 | }) { |
| 53 | let diverged = false |
| 54 | let resyncing = false |
| 55 | let attempt = 0 |
| 56 | let timer = null |
| 57 | |
| 58 | function scheduleRetry() { |
| 59 | attempt++ |
| 60 | if (timer) clearTimeoutFn(timer) |
| 61 | timer = setTimeoutFn(run, backoffMs(attempt)) |
| 62 | } |
| 63 | |
| 64 | async function run() { |
| 65 | // Cancel whatever timer might still be pending, not just forget |
| 66 | // it: run() can be entered from three places (a fired timer, an |
| 67 | // 'online' event, or reportDivergence() itself calling it |
| 68 | // eagerly), and only clearing the local reference without |
| 69 | // actually cancelling a still-scheduled OTHER timer would leave |
| 70 | // it alive to fire later and trigger an unplanned extra attempt. |
| 71 | if (timer) { |
| 72 | clearTimeoutFn(timer) |
| 73 | timer = null |
| 74 | } |
| 75 | // A stray trigger or a redundant 'online' event firing after |
| 76 | // recovery must not re-run resync against an already-healthy |
| 77 | // document — that would silently throw away everything typed |
| 78 | // since the last successful resync for no reason. |
| 79 | if (!diverged || resyncing) return |
| 80 | resyncing = true |
| 81 | try { |
| 82 | await attemptResync() |
| 83 | diverged = false |
| 84 | attempt = 0 |
| 85 | if (onRecovered) onRecovered() |
| 86 | } catch (err) { |
| 87 | scheduleRetry() |
| 88 | } finally { |
| 89 | resyncing = false |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | // reportDivergence is called the moment a batch is known dropped — |
| 94 | // before the first resync attempt even starts, per this module's |
| 95 | // own header comment ("knowingly diverged" from that instant, not |
| 96 | // from whenever the first attempt happens to finish). |
| 97 | function reportDivergence() { |
| 98 | if (!diverged) { |
| 99 | diverged = true |
| 100 | attempt = 0 |
| 101 | if (onDiverged) onDiverged() |
| 102 | } |
| 103 | run() |
| 104 | } |
| 105 | |
| 106 | addOnlineListener(() => { |
| 107 | if (diverged) run() |
| 108 | }) |
| 109 | |
| 110 | return { |
| 111 | reportDivergence, |
| 112 | get diverged() { return diverged }, |
| 113 | get resyncing() { return resyncing }, |
| 114 | } |
| 115 | } |
| 116 | |