Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_reloadpush.mjs

13.8 KiB, 1 run

created by r2519314175:645, 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_reloadpush.mjs — a reload with nothing to say sends nothing.
2//
3// The sync chip cycles twice after a hard refresh, a couple of seconds apart.
4// The first is the boot pull; the second is the push `onAuthed` schedules
5// behind it, and that one is the subject here. `push()` skips a parcel it has
6// already sent, but the fixed point it compares against lived only in memory,
7// so it began every page empty and the guard could not match — and the WHOLE
8// parcel went up on every reload whether or not a byte had changed. Measured at
9// 163 KB on an account holding one chat.
10//
11// What it cost was not credits: `sync_per_mib_minor` is "0" on the deployed
12// route, so a push is not priced today. It cost the upload, the gateway's
13// write, a version bump, and a wake sent to every other device of the account —
14// each of which then pulled and merged a parcel identical to what it already
15// held. This file measures the last of those with a second real device.
16//
17// The fixed point is a digest now, and it persists. So the danger this file
18// exists to guard is the opposite one: a device that skips a push it OWED.
19// Four of the checks below are about that and nothing else — a real change
20// after a reload, a stored digest that is unreadable, one written by another
21// format, and one belonging to another account.
22//
23// Needs the dev stack: the app (DAIMOND_PORT), the mock, and a gateway on
24// DAIMOND_GW_PORT. Sync is Pro-gated, so the account is granted Pro the one way
25// the gateway trusts (dev/pro.mjs).
26
27import { open, chat, signInAs, newChat } from './harness.mjs';
28import { makePagePro } from './pro.mjs';
29import { GW_URL } from './ports.mjs';
30
31const ok = [], bad = [];
32const check = (name, pass, detail) => {
33 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
34 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
35};
36
37/// Count POSTs to the mailbox on this page, with the bytes each carried.
38const counter = (page) => {
39 const seen = [];
40 page.on('request', (r) => {
41 try {
42 const u = new URL(r.url());
43 if (u.pathname === '/api/sync' && r.method() === 'POST') {
44 let n = 0;
45 try { n = (r.postData() || '').length; } catch (e) { /* unreadable */ }
46 seen.push(n);
47 }
48 } catch (e) { /* not a URL this counts */ }
49 });
50 return seen;
51};
52
53const state = (pg) => pg.evaluate(() => window.DaimondSync.state());
54const quiet = (pg) => pg.evaluate(() => window.DaimondSync.state().quiet);
55const parcel = (pg) => pg.evaluate(async () => JSON.stringify(await window.DaimondSync.parcel()));
56/// The raw key this build persists its fixed point under, un-namespaced.
57const sigRaw = (pg) => pg.evaluate(() => localStorage.getItem('daimond-sync-sig'));
58/// Every storage key holding a fixed point, however namespaced. See accounts.js.
59const sigKeys = (pg) => pg.evaluate(() => {
60 const out = [];
61 for (let i = 0; i < localStorage.length; i++) {
62 const k = localStorage.key(i);
63 if (k && k.indexOf('daimond-sync-sig') !== -1) out.push(k);
64 }
65 return out.sort();
66});
67
68/// Wait until nothing is running and nothing is armed.
69const settle = async (pg, ms = 20000) => {
70 const t0 = Date.now();
71 while (Date.now() - t0 < ms) {
72 if (await quiet(pg)) { await pg.waitForTimeout(1500); if (await quiet(pg)) return true; }
73 await pg.waitForTimeout(400);
74 }
75 return false;
76};
77
78/// Reload, sign back in, and wait long enough for the boot pull AND the push it
79/// schedules behind it to have happened or decided not to.
80const reload = async (s, name) => {
81 await s.page.reload({ waitUntil: 'domcontentloaded' });
82 const pushes = counter(s.page);
83 await signInAs(s, name);
84 await s.page.waitForFunction(
85 () => !!window.DaimondSync && window.DaimondGateway && DaimondGateway.state().authed,
86 null, { timeout: 20000 }).catch(() => {});
87 // Longer than PUSH_DEBOUNCE_MS by a wide margin, so a push that is coming has
88 // come. A check that waited only for the debounce would pass on a slow box by
89 // measuring a push that had not been made yet.
90 await s.page.waitForTimeout(11000);
91 return pushes;
92};
93
94const a = await open({ name: 'rpush', signIn: true, connect: true, defaults: false });
95let b = null;
96try {
97 await a.page.waitForFunction(
98 () => !!window.DaimondSync && !!window.DaimondCore && window.DaimondGateway
99 && DaimondGateway.state().authed,
100 null, { timeout: 15000 }).catch(() => {});
101 const pro = await makePagePro(a.page, new URL('../gateway', import.meta.url).pathname, GW_URL);
102 check('the account holds Pro, so a push is not refused before it is measured',
103 pro.pro === true, JSON.stringify(pro));
104
105 await newChat(a);
106 await chat(a, 'one turn, so there is something to sync at all');
107 await settle(a.page);
108 await a.page.waitForTimeout(3000);
109
110 // ── The control ────────────────────────────────────────────────────
111 // An idle tab nobody reloads already sends nothing. If this ever fails, the
112 // in-memory half of the guard has broken and every check below is measuring
113 // the wrong thing.
114 const idle = counter(a.page);
115 await a.page.waitForTimeout(15000);
116 check('an idle tab that is not reloaded sends nothing', idle.length === 0,
117 idle.length + ' push(es)');
118
119 // ── (1) A reload with nothing changed ──────────────────────────────
120 // The ledger is re-priced once per page life (js/ledger.js), which really
121 // does move the parcel, so the FIRST reload after a metered turn has
122 // something honest to send. The claim under test is about a reload with
123 // nothing to say, so the parcel is compared rather than assumed: what must
124 // send nothing is a reload across which the parcel did not move.
125 await reload(a, 'rpush');
126 await settle(a.page);
127 const before = await parcel(a.page);
128 const sigBefore = await sigRaw(a.page);
129 check('a fixed point was written down, so the next page has one to read',
130 !!sigBefore && /"v":\s*1/.test(sigBefore) && /"sig":\s*"[0-9a-f]{64}"/.test(sigBefore),
131 String(sigBefore).slice(0, 90));
132
133 const quietReload = await reload(a, 'rpush');
134 const after = await parcel(a.page);
135 check('the parcel really did not move across that reload', before === after,
136 before === after ? '' : 'it moved, so this check cannot speak');
137 check('a reload with nothing to say sends nothing',
138 quietReload.length === 0,
139 quietReload.length + ' push(es)'
140 + (quietReload.length ? ' of ' + quietReload.map(n => Math.round(n / 1024) + 'K').join(', ') : ''));
141 const chipAfter = await a.page.evaluate(() => {
142 const e = document.getElementById('sync-chip');
143 return e ? { st: e.dataset.state || '', shown: e.style.display !== 'none' } : null;
144 });
145 check('and the chip is not left claiming a round that never happened',
146 !!chipAfter && chipAfter.st !== 'syncing', JSON.stringify(chipAfter));
147
148 // ── (2) A reload after a REAL change still sends ───────────────────
149 // The whole hazard of persisting a fixed point. A device that skipped a push
150 // it owed would leave the user's work here and nothing anywhere saying so,
151 // which is worse than the upload this change removes.
152 await chat(a, 'a second turn, made before the reload');
153 await settle(a.page);
154 const real = await reload(a, 'rpush');
155 check('a reload after a real change still sends it', real.length >= 1,
156 real.length + ' push(es)');
157
158 // ── (3) An unreadable fixed point sends ────────────────────────────
159 await settle(a.page);
160 await a.page.evaluate(() => localStorage.setItem('daimond-sync-sig', 'not json at all'));
161 const corrupt = await reload(a, 'rpush');
162 check('a fixed point that cannot be read sends rather than skips',
163 corrupt.length >= 1, corrupt.length + ' push(es)');
164
165 // ── (4) A fixed point from another format sends ────────────────────
166 await settle(a.page);
167 // A DIGEST OF ITS OWN rather than one read back out of storage. What is there
168 // at this moment depends on whether the build under test writes that key at
169 // all -- the one this file was written against does not -- so reading it back
170 // made the fixture throw on the very code it exists to measure, and the run
171 // ended four checks early with a JSON parse error standing where a result
172 // should have been. The value only has to be well formed and to name a
173 // version this build does not know.
174 await a.page.evaluate(() => {
175 localStorage.setItem('daimond-sync-sig', JSON.stringify({
176 v: 99, sig: '0'.repeat(64),
177 }));
178 });
179 const older = await reload(a, 'rpush');
180 check('a fixed point a different format wrote sends rather than skips',
181 older.length >= 1, older.length + ' push(es)');
182
183 // ── (5) It is namespaced with the parcel it describes ──────────────
184 // accounts.js prefixes every `daimond-*` key for a non-primary account, so a
185 // second account on one browser must not answer this one's question. Asserted
186 // on the key rather than on the prefix rule, which is accounts.js's to keep.
187 await settle(a.page);
188 const keys = await sigKeys(a.page);
189 const ns = await a.page.evaluate(() => ({
190 prefix: window.DaimondAccounts ? DaimondAccounts.prefix() : '(no accounts.js)',
191 count: window.DaimondAccounts ? DaimondAccounts.count() : 0,
192 }));
193 check('the fixed point lands in the account namespace, beside the version it belongs to',
194 keys.length === 1 && keys[0] === ns.prefix + 'daimond-sync-sig',
195 JSON.stringify(keys) + ' with prefix ' + JSON.stringify(ns.prefix));
196
197 // ── (6) The pull is untouched ──────────────────────────────────────
198 // The fixed point gates the push and must go on gating only the push. A
199 // device that consulted it before deciding whether to LOOK would sit on its
200 // own stale copy while the other device's work waited in the mailbox — the
201 // hypothesis disproved on 2026-08-27, which this change must not introduce.
202 b = await open({ name: 'rpushmate', signIn: false, connect: false });
203 await b.page.waitForFunction(() => !!window.DaimondPairing, null, { timeout: 15000 }).catch(() => {});
204 const code = await a.page.evaluate(() => DaimondPairing.create());
205 await b.page.evaluate((c) => DaimondPairing.redeem(c), code.code);
206 await b.page.reload({ waitUntil: 'domcontentloaded' });
207 await signInAs(b, 'rpush');
208 await b.page.waitForFunction(
209 () => !!window.DaimondSync && window.DaimondGateway && DaimondGateway.state().authed,
210 null, { timeout: 20000 }).catch(() => {});
211 await b.page.waitForTimeout(6000);
212
213 // The second device makes a change and sends it. This one reloads, with its
214 // own fixed point sitting in storage, and must still come back with the work.
215 await b.page.evaluate(async () => {
216 const m = await import('/pkg/oxedyne_daimond.js');
217 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
218 await app.create_diamond('Made-While-The-Other-Was-Away');
219 });
220 // PUSHED BY HAND, and that is not laziness. `create_diamond` through a fresh
221 // `DaimondApp` does not go through the app's own funnels, so nothing on that
222 // device schedules a push and the Diamond sits in its store. The first cut of
223 // this check waited for a push that was never going to be made, timed out, and
224 // then reported the missing Diamond as a fault in the fixed point -- which is
225 // exactly the failure it was written to catch, arriving from the fixture
226 // instead of from the product.
227 const sent = await b.page.evaluate(async (ms) => {
228 const mailbox = async () => {
229 const r = await fetch('/api/sync', { credentials: 'same-origin', headers: { 'x-daimond-api': '1' } });
230 const j = await r.json();
231 if (!j.present) return null;
232 try { return await window.DaimondIdentity.unwrap(j.blob); }
233 catch (e) { return null; }
234 };
235 const mine = new Set();
236 const t0 = Date.now();
237 while (Date.now() - t0 < ms) {
238 await window.DaimondSync.push();
239 mine.add(JSON.stringify(await window.DaimondSync.parcel()));
240 const held = await mailbox();
241 if (held !== null && mine.has(held)) return true;
242 await new Promise(r => setTimeout(r, 250));
243 }
244 return false;
245 }, 25000);
246 check('the other device really put its Diamond in the mailbox', sent === true);
247 await reload(a, 'rpush');
248 const names = await a.page.evaluate(async () => {
249 const m = await import('/pkg/oxedyne_daimond.js');
250 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
251 return JSON.parse(await app.list_diamonds()).map(d => d.name);
252 });
253 check('a reload still PULLS — the fixed point gates the push and nothing else',
254 names.indexOf('Made-While-The-Other-Was-Away') !== -1, JSON.stringify(names));
255
256 // ── (7) And the other device is not woken for nothing ──────────────
257 // What the wasted push actually cost the account: every other device was
258 // tapped and pulled a parcel identical to the one it already held.
259 await settle(a.page);
260 await b.page.waitForTimeout(3000);
261 const wokeBefore = await b.page.evaluate(() => window.DaimondSync.wake().wakes);
262 const lastQuiet = await reload(a, 'rpush');
263 await b.page.waitForTimeout(4000);
264 const wokeAfter = await b.page.evaluate(() => window.DaimondSync.wake().wakes);
265 check('and a reload that sends nothing does not wake the account’s other device',
266 lastQuiet.length === 0 && wokeAfter === wokeBefore,
267 lastQuiet.length + ' push(es), wakes ' + wokeBefore + ' -> ' + wokeAfter);
268
269 const st = await state(a.page);
270 check('the engine is left in a good state, not stalled', st.stalled === false,
271 JSON.stringify({ stalled: st.stalled, why: st.stalledWhy, failed: st.failedParts }));
272} catch (e) {
273 check('the run finished', false, String((e && e.stack) || e));
274} finally {
275 if (b) await b.close();
276 await a.close();
277}
278
279console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed');
280if (bad.length) { for (const l of bad) console.log(' FAILED: ' + l); }
281process.exit(bad.length ? 1 : 0);