oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

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