oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

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).
32export 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