oxedyne/daimond/dev/verify_parcelstable.mjs
8.7 KiB, 1 run
created by r2519314175:567, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | // verify_parcelstable.mjs — the sync parcel is a fixed point, or two devices push for ever. |
| 2 | // |
| 3 | // `push()` skips the wire when the parcel stringifies to what it last sent |
| 4 | // (`www/js/sync.js:504`). That guard is the ONLY thing standing between an idle |
| 5 | // account and an endless round trip, and it compares bytes. So any part of the |
| 6 | // parcel that varies between two collects with nothing in between — a stamp |
| 7 | // taken at collect time, a map enumerated in whatever order the store felt like, |
| 8 | // a field rewritten by the merge — turns every device into a device that always |
| 9 | // has something to send. |
| 10 | // |
| 11 | // The failure that produces is not a slow sync. It is TWO DEVICES PUSHING AT |
| 12 | // EACH OTHER: A pushes, B pulls and merges, B's own collect now differs, B |
| 13 | // pushes, A pulls and merges, and neither of them is doing anything wrong. It |
| 14 | // was reported from an iPhone freshly paired by QR, as "the syncing seemed to go |
| 15 | // into an endless loop". |
| 16 | // |
| 17 | // Three properties, in the order they can break: |
| 18 | // |
| 19 | // 1. IDLE STABILITY. Two collects, nothing in between, byte-identical. |
| 20 | // 2. STABILITY AFTER WORK. The same, with Diamonds and a chat in the account, |
| 21 | // because an empty account has almost nothing that could vary. |
| 22 | // 3. THE FIXED POINT, and this is the one the loop is made of. Applying a |
| 23 | // parcel must not change what this device would then send. `applySync(p)` |
| 24 | // followed by `collectSync()` must give `p` back. A merge that rewrites so |
| 25 | // much as one stamp of its own fails here, and passes 1 and 2. |
| 26 | // |
| 27 | // Each failure names the sections that moved, then the fields inside them, |
| 28 | // because "the parcel differs" is not a finding anybody can act on. |
| 29 | // |
| 30 | // node dev/verify_parcelstable.mjs |
| 31 | // |
| 32 | // Needs dev/serve.mjs (DAIMOND_PORT, default 8777). No gateway, no mock LLM: nothing |
| 33 | // here runs a turn. |
| 34 | import fs from 'node:fs'; |
| 35 | import { open, signInAs, scratch } from './harness.mjs'; |
| 36 | |
| 37 | const PROFILE = scratch('pw', 'parcelstable'); |
| 38 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 39 | |
| 40 | let bad = 0; |
| 41 | const check = (pass, name, detail) => { |
| 42 | if (!pass) bad++; |
| 43 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 44 | }; |
| 45 | |
| 46 | /// Which top-level sections of two parcels differ, and — for an object or an |
| 47 | /// array of `{id}` — which entries inside them. A diff that stops at "diamonds" |
| 48 | /// sends the reader back to the same question they started with. |
| 49 | function differences(a, b) { |
| 50 | const out = []; |
| 51 | const keys = [...new Set([...Object.keys(a || {}), ...Object.keys(b || {})])].sort(); |
| 52 | for (const k of keys) { |
| 53 | const x = JSON.stringify(a ? a[k] : undefined); |
| 54 | const y = JSON.stringify(b ? b[k] : undefined); |
| 55 | if (x === y) continue; |
| 56 | out.push(k + inner(a ? a[k] : undefined, b ? b[k] : undefined)); |
| 57 | } |
| 58 | return out; |
| 59 | } |
| 60 | |
| 61 | /// One level further in, when that is cheap and says something. |
| 62 | function inner(x, y) { |
| 63 | if (Array.isArray(x) && Array.isArray(y)) { |
| 64 | if (x.length !== y.length) return `[${x.length}→${y.length}]`; |
| 65 | const moved = []; |
| 66 | for (let i = 0; i < x.length; i++) { |
| 67 | if (JSON.stringify(x[i]) === JSON.stringify(y[i])) continue; |
| 68 | const id = (x[i] && (x[i].id || x[i].path || x[i].address)) || i; |
| 69 | moved.push(String(id) + fields(x[i], y[i])); |
| 70 | } |
| 71 | return moved.length ? `[${moved.slice(0, 4).join(' ')}]` : '[reordered]'; |
| 72 | } |
| 73 | if (x && y && typeof x === 'object' && typeof y === 'object') return fields(x, y); |
| 74 | return ''; |
| 75 | } |
| 76 | |
| 77 | /// The named fields of two objects that do not match. |
| 78 | function fields(x, y) { |
| 79 | if (!x || !y || typeof x !== 'object' || typeof y !== 'object') return ''; |
| 80 | const keys = [...new Set([...Object.keys(x), ...Object.keys(y)])]; |
| 81 | const moved = keys.filter(k => JSON.stringify(x[k]) !== JSON.stringify(y[k])); |
| 82 | return moved.length ? `{${moved.slice(0, 5).join(',')}}` : ''; |
| 83 | } |
| 84 | |
| 85 | // Connected, because the rail's "new Diamond" control needs a provider before it |
| 86 | // will open its dialog, and an account with no Diamonds in it cannot show that |
| 87 | // the Diamonds section of the parcel is stable. |
| 88 | const s = await open({ name: 'parcelstable', profile: PROFILE }); |
| 89 | const { page } = s; |
| 90 | |
| 91 | try { |
| 92 | await page.waitForFunction(() => !!(window.DaimondCore && DaimondCore.collectSync), |
| 93 | null, { timeout: 15000 }); |
| 94 | await page.waitForTimeout(800); |
| 95 | |
| 96 | const collect = () => page.evaluate(() => DaimondCore.collectSync()); |
| 97 | |
| 98 | // ── 1. Idle stability ─────────────────────────────────────────── |
| 99 | // A pause between the two, because a stamp with second resolution taken at |
| 100 | // collect time would sit still inside one millisecond and move inside two. |
| 101 | const a1 = await collect(); |
| 102 | await page.waitForTimeout(2500); |
| 103 | const a2 = await collect(); |
| 104 | const idle = differences(a1, a2); |
| 105 | check(idle.length === 0, 'an idle account collects the same parcel twice', idle.join(' ')); |
| 106 | |
| 107 | // ── 2. Stability with something in the account ────────────────── |
| 108 | async function newDiamond(name) { |
| 109 | await page.click('#new-diamond-btn', { force: true }); |
| 110 | await page.waitForSelector('.dlg-input', { timeout: 10000 }); |
| 111 | await page.fill('.dlg-input', name); |
| 112 | await page.click('.dlg-ok', { force: true }); |
| 113 | await page.waitForTimeout(700); |
| 114 | } |
| 115 | await newDiamond('Stability one'); |
| 116 | await newDiamond('Stability two'); |
| 117 | await page.waitForTimeout(600); |
| 118 | |
| 119 | const b1 = await collect(); |
| 120 | await page.waitForTimeout(2500); |
| 121 | const b2 = await collect(); |
| 122 | const worked = differences(b1, b2); |
| 123 | check(worked.length === 0, 'an account with Diamonds collects the same parcel twice', |
| 124 | worked.join(' ')); |
| 125 | |
| 126 | // The parcel must actually have something in it, or checks 1-3 are three |
| 127 | // ways of comparing two empty objects. |
| 128 | const carried = (b2.diamonds || []).length; |
| 129 | check(carried >= 2, 'and the parcel carries the Diamonds', `${carried} carried`); |
| 130 | |
| 131 | // ── 3. The fixed point ────────────────────────────────────────── |
| 132 | // Apply this device's OWN parcel. Nothing in it is news, so nothing should |
| 133 | // move -- and what this device would send next must be what it just took in. |
| 134 | const applied = await page.evaluate(async (p) => { |
| 135 | const report = await DaimondCore.applySync(p); |
| 136 | return report && report.failed ? report.failed : []; |
| 137 | }, b2); |
| 138 | check(applied.length === 0, 'applying its own parcel reports no failed section', |
| 139 | applied.join(',')); |
| 140 | |
| 141 | await page.waitForTimeout(800); |
| 142 | const c1 = await collect(); |
| 143 | const fixed = differences(b2, c1); |
| 144 | check(fixed.length === 0, 'and leaves the next parcel unchanged (the fixed point)', |
| 145 | fixed.join(' ')); |
| 146 | |
| 147 | // ── 3b. A name parked at pairing, on storage that will not forget it ── |
| 148 | // `touchSelfDevice` consumes `daimond-pair-label` and stamps `namedAt` with the |
| 149 | // clock. The consuming `removeItem` is wrapped in a swallowing try/catch, so on |
| 150 | // a browser that refuses the delete the label is read again on the NEXT collect |
| 151 | // and stamped again -- a parcel that differs every time it is packed, on a |
| 152 | // device that has just been paired, which is exactly the device the endless |
| 153 | // loop was reported from. The stamp must be written only when the name it |
| 154 | // carries actually changes, so that a failed delete costs nothing. |
| 155 | // accounts.js shadows `removeItem` as an OWN property of the localStorage |
| 156 | // instance so every `daimond-` key lands in the account's namespace, so the |
| 157 | // prototype is not the thing the app calls and patching it proves nothing. |
| 158 | // This file did exactly that first, and the check passed while testing nothing. |
| 159 | await page.evaluate(() => { |
| 160 | const real = localStorage.removeItem.bind(localStorage); |
| 161 | localStorage.removeItem = function (k) { |
| 162 | if (String(k).indexOf('daimond-pair-label') !== -1) return; // the delete that will not take |
| 163 | return real(k); |
| 164 | }; |
| 165 | localStorage.setItem('daimond-pair-label', 'The phone'); |
| 166 | }); |
| 167 | const e1 = await collect(); |
| 168 | await page.waitForTimeout(1200); |
| 169 | const e2 = await collect(); |
| 170 | const parked = differences(e1, e2); |
| 171 | check(parked.length === 0, |
| 172 | 'a pairing name that will not delete is still stamped only once', parked.join(' ')); |
| 173 | |
| 174 | // ── 4. And across a reload ────────────────────────────────────── |
| 175 | // A device that restarts and immediately has something to send pushes on |
| 176 | // every launch, which is the same loop with a slower clock. |
| 177 | await page.reload({ waitUntil: 'domcontentloaded' }); |
| 178 | await page.waitForFunction(() => !!(window.DaimondCore && DaimondCore.collectSync), |
| 179 | null, { timeout: 15000 }); |
| 180 | await page.waitForTimeout(1500); |
| 181 | const d1 = await collect(); |
| 182 | const reloaded = differences(e2, d1); |
| 183 | check(reloaded.length === 0, 'a reload does not change what this device would send', |
| 184 | reloaded.join(' ')); |
| 185 | |
| 186 | } finally { |
| 187 | await s.close(); |
| 188 | } |
| 189 | |
| 190 | console.log(bad === 0 ? '\nall checks passed' : `\n${bad} check(s) FAILED`); |
| 191 | process.exit(bad === 0 ? 0 : 1); |