// fixtures/resync-controller.mjs — the state machine for "the local // document has diverged from the server and needs to be rebuilt from // server truth," factored out of fixtures/editor-entry.js so it can // be driven under plain Node with a stub resync and a fake clock — // see fixtures/resync-controller.test.mjs. // // This is fix round 2's own fix. Round 1's resync() ran once and, on // failure, just logged and gave up — its own comment claimed the // editor would get "another chance to resync the next time a batch // is dropped," which is false: once a batch has already been // dropped, the local Y.Doc has already diverged, and every update it // produces from then on is encoded relative to content the server // never received. core.Handle.Apply does not error on such an // update — it has nowhere to attach it, so it returns a real 204 and // silently never integrates it. No further 4xx ever arrives to // trigger another resync attempt, so one transient resync failure // (a dropped connection at exactly the wrong moment, reproduced by // the round 2 reviewer by aborting the resync fetch) recreated the // silent-204 data loss for the rest of the session. Reproduced again // here, deliberately, before this fix: fixtures/resync-controller. // test.mjs's own "without retry" case shows attemptResync called // exactly once and diverged staying true forever after a failure. // // The fix: retry the resync itself, with backoff, and again // immediately on the browser's own 'online' event — and expose // `diverged` so the caller (editor-entry.js) can gate what "saving // fine" even means while it is true: a 204 during divergence proves // nothing, so no local edit may be treated as saved, and none should // even be sent (fix round 2's LOW note: every 204 taken during // divergence permanently consumes a maxRetainedUpdates row for // nothing). export function createResyncController({ attemptResync, onDiverged, onRecovered, // Exponential backoff, capped: 1s, 2s, 4s, 8s, 16s, 30s, 30s, ... — // fast enough to shrug off a one-off blip without the person // noticing, capped low enough that a genuinely offline session // isn't left waiting minutes between attempts once connectivity // does return. The 'online' listener below is the fast path for // that case; backoff is the fallback for everything the browser's // own online/offline signal does not catch (a resync that fails // for a reason other than "no network at all"). backoffMs = (attempt) => Math.min(1000 * 2 ** (attempt - 1), 30000), setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout, addOnlineListener = (fn) => addEventListener('online', fn), }) { let diverged = false let resyncing = false let attempt = 0 let timer = null function scheduleRetry() { attempt++ if (timer) clearTimeoutFn(timer) timer = setTimeoutFn(run, backoffMs(attempt)) } async function run() { // Cancel whatever timer might still be pending, not just forget // it: run() can be entered from three places (a fired timer, an // 'online' event, or reportDivergence() itself calling it // eagerly), and only clearing the local reference without // actually cancelling a still-scheduled OTHER timer would leave // it alive to fire later and trigger an unplanned extra attempt. if (timer) { clearTimeoutFn(timer) timer = null } // A stray trigger or a redundant 'online' event firing after // recovery must not re-run resync against an already-healthy // document — that would silently throw away everything typed // since the last successful resync for no reason. if (!diverged || resyncing) return resyncing = true try { await attemptResync() diverged = false attempt = 0 if (onRecovered) onRecovered() } catch (err) { scheduleRetry() } finally { resyncing = false } } // reportDivergence is called the moment a batch is known dropped — // before the first resync attempt even starts, per this module's // own header comment ("knowingly diverged" from that instant, not // from whenever the first attempt happens to finish). function reportDivergence() { if (!diverged) { diverged = true attempt = 0 if (onDiverged) onDiverged() } run() } addOnlineListener(() => { if (diverged) run() }) return { reportDivergence, get diverged() { return diverged }, get resyncing() { return resyncing }, } }