Oregami
Repositories/oxedyne/daimond

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.
34import fs from 'node:fs';
35import { open, signInAs, scratch } from './harness.mjs';
36
37const PROFILE = scratch('pw', 'parcelstable');
38fs.rmSync(PROFILE, { recursive: true, force: true });
39
40let bad = 0;
41const 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.
49function 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.
62function 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.
78function 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.
88const s = await open({ name: 'parcelstable', profile: PROFILE });
89const { page } = s;
90
91try {
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
190console.log(bad === 0 ? '\nall checks passed' : `\n${bad} check(s) FAILED`);
191process.exit(bad === 0 ? 0 : 1);