| 1 | // fixtures/resync-controller.test.mjs — regression test for fix |
| 2 | // round 2's blocking finding: a resync that fails once used to give |
| 3 | // up forever, silently recreating the 204-but-never-integrated data |
| 4 | // loss for the rest of the session (see resync-controller.mjs's own |
| 5 | // header comment for the full mechanism). Runs under Node's own test |
| 6 | // runner (node --test); no DOM, no ProseMirror, no real Y.Doc — |
| 7 | // attemptResync/timing/the 'online' listener are all injected. |
| 8 | import { test } from 'node:test' |
| 9 | import assert from 'node:assert/strict' |
| 10 | import { createResyncController } from './resync-controller.mjs' |
| 11 | |
| 12 | // A tiny fake clock: setTimeoutFn/clearTimeoutFn below don't run real |
| 13 | // timers at all — advance(ms) runs every callback whose deadline has |
| 14 | // now passed, in the order they were scheduled. Deterministic and |
| 15 | // instant, unlike waiting out a real 30s backoff cap in a test. |
| 16 | function makeClock() { |
| 17 | let now = 0 |
| 18 | let id = 0 |
| 19 | const pending = new Map() |
| 20 | return { |
| 21 | setTimeoutFn: (fn, ms) => { |
| 22 | const t = ++id |
| 23 | pending.set(t, { fn, at: now + ms }) |
| 24 | return t |
| 25 | }, |
| 26 | clearTimeoutFn: (t) => pending.delete(t), |
| 27 | advance: (ms) => { |
| 28 | now += ms |
| 29 | for (const [t, { fn, at }] of [...pending]) { |
| 30 | if (at <= now) { |
| 31 | pending.delete(t) |
| 32 | fn() |
| 33 | } |
| 34 | } |
| 35 | }, |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | test('a resync that fails is retried with backoff until it succeeds, without ever running twice at once', async () => { |
| 40 | const clock = makeClock() |
| 41 | let onlineHandler = null |
| 42 | const calls = [] |
| 43 | let succeedOnCall = 3 |
| 44 | |
| 45 | const attemptResync = () => { |
| 46 | calls.push(clock) |
| 47 | return calls.length >= succeedOnCall |
| 48 | ? Promise.resolve() |
| 49 | : Promise.reject(new Error('network down')) |
| 50 | } |
| 51 | |
| 52 | const divergedEvents = [] |
| 53 | const recoveredEvents = [] |
| 54 | const controller = createResyncController({ |
| 55 | attemptResync, |
| 56 | onDiverged: () => divergedEvents.push(true), |
| 57 | onRecovered: () => recoveredEvents.push(true), |
| 58 | backoffMs: (attempt) => attempt * 1000, // 1s, 2s, 3s — deterministic and small for the test |
| 59 | setTimeoutFn: clock.setTimeoutFn, |
| 60 | clearTimeoutFn: clock.clearTimeoutFn, |
| 61 | addOnlineListener: (fn) => { onlineHandler = fn }, |
| 62 | }) |
| 63 | |
| 64 | assert.equal(controller.diverged, false) |
| 65 | controller.reportDivergence() |
| 66 | // The first attempt runs synchronously off reportDivergence itself — |
| 67 | // "knowingly diverged from the instant a batch is dropped," not |
| 68 | // from whenever the first attempt happens to finish. |
| 69 | await Promise.resolve() |
| 70 | await Promise.resolve() |
| 71 | assert.equal(calls.length, 1, 'the first attempt must run immediately, not wait for a timer') |
| 72 | assert.equal(controller.diverged, true, 'still diverged: without retry, this fix round 2 regression stays true forever') |
| 73 | assert.deepEqual(divergedEvents, [true]) |
| 74 | |
| 75 | // Calling reportDivergence again while already diverged must not |
| 76 | // reset the attempt count or fire onDiverged a second time — it is |
| 77 | // still the same episode of divergence — but it is free to trigger |
| 78 | // another immediate attempt rather than silently waiting out |
| 79 | // whatever backoff is already scheduled; the resyncing guard keeps |
| 80 | // that from ever running two attempts at once. |
| 81 | controller.reportDivergence() |
| 82 | await Promise.resolve() |
| 83 | await Promise.resolve() |
| 84 | assert.equal(calls.length, 2, 'reportDivergence is free to retry eagerly; it must never fire onDiverged twice') |
| 85 | assert.deepEqual(divergedEvents, [true]) |
| 86 | assert.equal(controller.diverged, true, 'still failing (call 2 of 3)') |
| 87 | |
| 88 | // The pending backoff timer from the FIRST failed attempt is still |
| 89 | // live; it must not cause a THIRD concurrent/duplicate call on top |
| 90 | // of the eager one just above. |
| 91 | clock.advance(1000) |
| 92 | await Promise.resolve() |
| 93 | await Promise.resolve() |
| 94 | assert.equal(calls.length, 2, 'a stale timer from a superseded attempt must not double up on the eager retry') |
| 95 | |
| 96 | clock.advance(2000) |
| 97 | await Promise.resolve() |
| 98 | await Promise.resolve() |
| 99 | assert.equal(calls.length, 3, 'the next scheduled retry succeeds') |
| 100 | assert.equal(controller.diverged, false, 'recovered') |
| 101 | assert.deepEqual(recoveredEvents, [true]) |
| 102 | |
| 103 | // A stray, already-scheduled retry firing after recovery must be a |
| 104 | // no-op — re-running resync against an already-healthy document |
| 105 | // would discard everything typed since recovery for nothing. |
| 106 | clock.advance(10000) |
| 107 | assert.equal(calls.length, 3, 'no further attempts once recovered') |
| 108 | }) |
| 109 | |
| 110 | test("the browser's online event retries immediately, not just on the next backoff tick", async () => { |
| 111 | const clock = makeClock() |
| 112 | let onlineHandler = null |
| 113 | let calls = 0 |
| 114 | const attemptResync = () => { |
| 115 | calls++ |
| 116 | return calls >= 2 ? Promise.resolve() : Promise.reject(new Error('offline')) |
| 117 | } |
| 118 | |
| 119 | const controller = createResyncController({ |
| 120 | attemptResync, |
| 121 | backoffMs: () => 60000, // a long backoff the test never advances to, |
| 122 | // so the only thing that can trigger the second attempt here is |
| 123 | // the online listener itself. |
| 124 | setTimeoutFn: clock.setTimeoutFn, |
| 125 | clearTimeoutFn: clock.clearTimeoutFn, |
| 126 | addOnlineListener: (fn) => { onlineHandler = fn }, |
| 127 | }) |
| 128 | |
| 129 | controller.reportDivergence() |
| 130 | await Promise.resolve() |
| 131 | await Promise.resolve() |
| 132 | assert.equal(calls, 1) |
| 133 | assert.equal(controller.diverged, true) |
| 134 | |
| 135 | assert.equal(typeof onlineHandler, 'function', 'an online listener must be registered') |
| 136 | onlineHandler() |
| 137 | await Promise.resolve() |
| 138 | await Promise.resolve() |
| 139 | assert.equal(calls, 2, "the online event must trigger a fresh attempt without waiting for the 60s backoff") |
| 140 | assert.equal(controller.diverged, false) |
| 141 | }) |
| 142 | |
| 143 | test('reportDivergence before any failure runs the first attempt without a caller ever calling online', async () => { |
| 144 | const clock = makeClock() |
| 145 | const controller = createResyncController({ |
| 146 | attemptResync: () => Promise.resolve(), |
| 147 | setTimeoutFn: clock.setTimeoutFn, |
| 148 | clearTimeoutFn: clock.clearTimeoutFn, |
| 149 | addOnlineListener: () => {}, |
| 150 | }) |
| 151 | controller.reportDivergence() |
| 152 | await Promise.resolve() |
| 153 | await Promise.resolve() |
| 154 | assert.equal(controller.diverged, false, 'a resync that succeeds on the first try recovers immediately') |
| 155 | }) |
| 156 | |