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 | |
| 27 | import { open, chat, signInAs, newChat } from './harness.mjs'; |
| 28 | import { makePagePro } from './pro.mjs'; |
| 29 | import { GW_URL } from './ports.mjs'; |
| 30 | |
| 31 | const ok = [], bad = []; |
| 32 | const 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. |
| 38 | const 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 | |
| 53 | const state = (pg) => pg.evaluate(() => window.DaimondSync.state()); |
| 54 | const quiet = (pg) => pg.evaluate(() => window.DaimondSync.state().quiet); |
| 55 | const parcel = (pg) => pg.evaluate(async () => JSON.stringify(await window.DaimondSync.parcel())); |
| 56 | /// The raw key this build persists its fixed point under, un-namespaced. |
| 57 | const sigRaw = (pg) => pg.evaluate(() => localStorage.getItem('daimond-sync-sig')); |
| 58 | /// Every storage key holding a fixed point, however namespaced. See accounts.js. |
| 59 | const 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. |
| 69 | const 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. |
| 80 | const 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 | |
| 94 | const a = await open({ name: 'rpush', signIn: true, connect: true, defaults: false }); |
| 95 | let b = null; |
| 96 | try { |
| 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 | |
| 279 | console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed'); |
| 280 | if (bad.length) { for (const l of bad) console.log(' FAILED: ' + l); } |
| 281 | process.exit(bad.length ? 1 : 0); |