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