oxedyne/daimond/dev/verify_wakerearm.mjs
14.8 KiB, 1 run
created by r2519314175:799, 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_wakerearm.mjs — re-arming the wake channel must not cost the news it |
| 2 | // was holding, nor the channel itself. |
| 3 | // |
| 4 | // The wake channel parks a request at the gateway for three quarters of a |
| 5 | // minute and is answered the moment another device pushes. Tearing the channel |
| 6 | // down (`wakeStop`, and therefore `wakeVia`, a sign-out, a tier change, the |
| 7 | // supervisor) bumps a generation counter so the loop stands down — but it |
| 8 | // CANNOT take back the request that loop is parked on. The gateway goes on |
| 9 | // holding it, and for as long as it does: |
| 10 | // |
| 11 | // 1. `wakePolling` stayed true, so the re-armed channel turned round at its |
| 12 | // own front door (`if (wakePolling) return`) and parked NOTHING. Measured |
| 13 | // at thirty seconds of a device with no channel at all, and it can be |
| 14 | // fifty-five: the park's own wait, plus a supervisor tick. |
| 15 | // 2. When that request finally answered SAYING THE MAILBOX HAD MOVED, the |
| 16 | // loop broke on the generation check BEFORE reading it, and the news went |
| 17 | // in the bin. A version the gateway has moved past is a fact about the |
| 18 | // ACCOUNT, not about the channel that happened to be holding the question. |
| 19 | // |
| 20 | // Both halves are asserted here as things that HAPPEN on the wire, not as flags: |
| 21 | // a fresh park is a request leaving the browser, and news acted on is a Diamond |
| 22 | // arriving with its new name. And the second is isolated rather than assumed — |
| 23 | // every park made after the re-arm is HELD OPEN by this file, so the only route |
| 24 | // by which the news can reach the device is the request that was already at the |
| 25 | // gateway when the re-arm happened. |
| 26 | // |
| 27 | // node dev/verify_wakerearm.mjs |
| 28 | // |
| 29 | // Needs dev/serve.mjs (DAIMOND_PORT) AND the gateway on :9002: parking is the |
| 30 | // gateway's half and a stub cannot hold a request. |
| 31 | import { open, signInAs } from './harness.mjs'; |
| 32 | import { makePagePro } from './pro.mjs'; |
| 33 | import path from 'node:path'; |
| 34 | import { fileURLToPath } from 'node:url'; |
| 35 | |
| 36 | const ok = [], bad = []; |
| 37 | const check = (name, pass, detail) => { |
| 38 | (pass ? ok : bad).push(name); |
| 39 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 40 | }; |
| 41 | const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| 42 | |
| 43 | /// Push until THIS device's own parcel is what the mailbox holds. |
| 44 | /// |
| 45 | /// Lifted from verify_sync for the same reason it exists there: `push()` stands |
| 46 | /// aside over a live turn and answers its caller no differently than when it |
| 47 | /// sent, so awaiting one proves nothing, and the version advancing can be some |
| 48 | /// other round entirely. |
| 49 | async function pushLanded(pg) { |
| 50 | return await pg.evaluate(async (ms) => { |
| 51 | const mailbox = async () => { |
| 52 | const res = await fetch('/api/sync', { credentials: 'same-origin', headers: { 'x-daimond-api': '1' } }); |
| 53 | const j = await res.json(); |
| 54 | if (!j.present) return null; |
| 55 | try { return await window.DaimondIdentity.unwrap(j.blob); } catch (e) { return null; } |
| 56 | }; |
| 57 | const mine = new Set(); |
| 58 | const t0 = Date.now(); |
| 59 | while (Date.now() - t0 < ms) { |
| 60 | await window.DaimondSync.push(); |
| 61 | mine.add(JSON.stringify(await window.DaimondSync.parcel())); |
| 62 | const held = await mailbox(); |
| 63 | if (held !== null && mine.has(held)) return true; |
| 64 | await new Promise(r => setTimeout(r, 200)); |
| 65 | } |
| 66 | return false; |
| 67 | }, 25000); |
| 68 | } |
| 69 | |
| 70 | /// Return once the engine has stopped moving, so nothing already armed is left |
| 71 | /// to converge on the device's behalf. |
| 72 | async function quiesce(pg, ms = 20000) { |
| 73 | let last = -1, stable = Date.now(); |
| 74 | const t0 = Date.now(); |
| 75 | while (Date.now() - t0 < ms) { |
| 76 | const v = await pg.evaluate(() => window.DaimondSync.state().version); |
| 77 | if (v !== last) { last = v; stable = Date.now(); } |
| 78 | else if (Date.now() - stable > 4000) return last; |
| 79 | await pg.waitForTimeout(300); |
| 80 | } |
| 81 | return last; |
| 82 | } |
| 83 | |
| 84 | const GWDIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'gateway'); |
| 85 | const s = await open({ name: 'rearm', signIn: true, connect: false, defaults: false }); |
| 86 | const { page } = s; |
| 87 | let child = null; |
| 88 | |
| 89 | try { |
| 90 | await page.waitForFunction( |
| 91 | () => !!window.DaimondSync && !!window.DaimondGateway && DaimondGateway.state().authed, |
| 92 | null, { timeout: 20000 }).catch(() => {}); |
| 93 | const lic = await makePagePro(page, GWDIR); |
| 94 | check('the account holds Pro, so the mailbox will take a push at all', |
| 95 | lic.pro === true, `webhook ${lic.status}, pro=${lic.pro}`); |
| 96 | |
| 97 | // A second REAL device, so the wake has something to be woken BY. How it got |
| 98 | // the identity is not what is under test here, so it is paired in the plain |
| 99 | // way. |
| 100 | child = await open({ name: 'rearmmate', signIn: false, connect: false }); |
| 101 | await child.page.waitForFunction(() => !!window.DaimondPairing, null, { timeout: 20000 }); |
| 102 | const code = await page.evaluate(() => DaimondPairing.create()); |
| 103 | await child.page.evaluate(c => DaimondPairing.redeem(c), code.code); |
| 104 | await child.page.reload({ waitUntil: 'domcontentloaded' }); |
| 105 | await signInAs(child, 'rearm'); |
| 106 | await child.page.waitForFunction( |
| 107 | () => !!window.DaimondSync && window.DaimondGateway && DaimondGateway.state().authed, |
| 108 | null, { timeout: 20000 }).catch(() => {}); |
| 109 | const same = await child.page.evaluate(() => window.DaimondIdentity.publicKeyB64url()); |
| 110 | const mine = await page.evaluate(() => window.DaimondIdentity.publicKeyB64url()); |
| 111 | check('a second device holds the same account', same === mine, same.slice(0, 12)); |
| 112 | |
| 113 | const shared = await page.evaluate(async () => { |
| 114 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 115 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 116 | return await app.create_diamond('Rearm-Subject'); |
| 117 | }); |
| 118 | check('a Diamond exists for the two of them to disagree about', !!shared, shared); |
| 119 | check('and the first device gets it into the mailbox', await pushLanded(page)); |
| 120 | await child.page.evaluate(() => window.DaimondSync.pull()); |
| 121 | |
| 122 | /// One Diamond's name, as this device's own store reads it. |
| 123 | const nameOn = (pg) => pg.evaluate(async (id) => { |
| 124 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 125 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 126 | return ((JSON.parse(await app.list_diamonds()).find(d => d.id === id)) || {}).name || '(absent)'; |
| 127 | }, shared); |
| 128 | const nameSettles = async (pg, want, ms) => { |
| 129 | const t0 = Date.now(); |
| 130 | let seen = ''; |
| 131 | do { |
| 132 | seen = await nameOn(pg); |
| 133 | if (seen === want) return seen; |
| 134 | await pg.waitForTimeout(150); |
| 135 | } while (Date.now() - t0 < ms); |
| 136 | return seen; |
| 137 | }; |
| 138 | const rename = (to) => page.evaluate(async (arg) => { |
| 139 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 140 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 141 | await app.rename_diamond(arg.id, arg.to); |
| 142 | }, { id: shared, to }); |
| 143 | |
| 144 | // ── Every park the second device makes, and when it was answered ── |
| 145 | // Attached before the channel is switched over: a park lasts three quarters |
| 146 | // of a minute, and a listener added later would miss the one already open. |
| 147 | const parks = []; |
| 148 | const above = (p) => p ? ((p.url.match(/[?&]above=(\d+)/) || [])[1] | 0) : -1; |
| 149 | const budget = (p) => p ? ((p.url.match(/[?&]ms=(\d+)/) || [])[1] | 0) : 0; |
| 150 | const onPark = (r) => { |
| 151 | if (r.url().includes('/api/sync?above=')) { |
| 152 | parks.push({ r, url: r.url(), began: Date.now(), ended: 0, body: null }); |
| 153 | } |
| 154 | }; |
| 155 | const onDone = (r) => { |
| 156 | const p = parks.find(q => q.r === r && !q.ended); |
| 157 | if (!p) return; |
| 158 | p.ended = Date.now(); |
| 159 | p.body = r.response().then(res => res.text()).catch(() => ''); |
| 160 | }; |
| 161 | child.page.on('request', onPark); |
| 162 | child.page.on('requestfinished', onDone); |
| 163 | |
| 164 | // ── (1) A park that is genuinely being HELD ─────────────────────── |
| 165 | const mode = await child.page.evaluate(() => window.DaimondSync.wakeVia('poll')); |
| 166 | check('the second device is put onto parked requests', mode === 'poll', mode); |
| 167 | await quiesce(child.page); |
| 168 | await quiesce(page); |
| 169 | // Held, rather than merely made: still unanswered a second after it left, |
| 170 | // which no answer served out of the store on arrival ever is, and with more |
| 171 | // of its declared wait left than anything below can take. |
| 172 | const held = await (async () => { |
| 173 | const t0 = Date.now(); |
| 174 | while (Date.now() - t0 < 60000) { |
| 175 | const openOnes = parks.filter(q => !q.ended); |
| 176 | const p = openOnes.length ? openOnes[openOnes.length - 1] : null; |
| 177 | if (p && Date.now() - p.began >= 1000 && p.began + budget(p) - Date.now() > 25000) return p; |
| 178 | await sleep(200); |
| 179 | } |
| 180 | return null; |
| 181 | })(); |
| 182 | check('the gateway is HOLDING a park of the second device’s own, unanswered', |
| 183 | !!held, held ? `above ${above(held)}, open ${Date.now() - held.began}ms of ${budget(held)}ms` |
| 184 | : 'no park of the channel’s own stayed open long enough in 60s'); |
| 185 | |
| 186 | // ── (2) Nothing made after the re-arm may deliver the news ──────── |
| 187 | // Every park from here on is held open by this file and never answered, so |
| 188 | // the ONLY route by which the mailbox's news can reach this device is the |
| 189 | // request that was already at the gateway when the re-arm happened. Without |
| 190 | // this the re-armed loop's own park would carry it, and the check below would |
| 191 | // pass whether or not the abandoned one was read. |
| 192 | let attempted = 0; |
| 193 | const hung = []; |
| 194 | // A predicate, not a glob: `?` is a wildcard in a URL pattern, and what has to |
| 195 | // be caught here is exactly the parked shape and nothing else the engine sends |
| 196 | // to the same path. |
| 197 | const isPark = (url) => (url.pathname + url.search).indexOf('/api/sync?above=') === 0; |
| 198 | await child.page.route(isPark, async (route) => { |
| 199 | attempted++; |
| 200 | hung.push(route); // never fulfilled: the request stays pending |
| 201 | }); |
| 202 | |
| 203 | const wakesBefore = await child.page.evaluate(() => { |
| 204 | window.__probe = { focus: 0, vis: 0, idle: 0 }; |
| 205 | window.addEventListener('focus', () => window.__probe.focus++, true); |
| 206 | document.addEventListener('visibilitychange', () => window.__probe.vis++, true); |
| 207 | window.addEventListener('daimond:idle', () => window.__probe.idle++, true); |
| 208 | return { wake: window.DaimondSync.wake(), version: window.DaimondSync.state().version }; |
| 209 | }); |
| 210 | |
| 211 | // ── (3) The re-arm ──────────────────────────────────────────────── |
| 212 | const rearmAt = Date.now(); |
| 213 | await child.page.evaluate(() => window.DaimondSync.wakeVia('poll')); |
| 214 | const parked = await (async () => { |
| 215 | const t0 = Date.now(); |
| 216 | while (Date.now() - t0 < 3000) { |
| 217 | if (attempted > 0) return Date.now() - rearmAt; |
| 218 | await sleep(50); |
| 219 | } |
| 220 | return -1; |
| 221 | })(); |
| 222 | check('re-arming while a park is outstanding parks a FRESH request, at once', |
| 223 | parked >= 0, parked >= 0 |
| 224 | ? `a new park left the browser ${parked}ms after the re-arm` |
| 225 | : 'no park was even attempted in the 3s after the re-arm — the channel is shut ' |
| 226 | + 'and the flag says otherwise: ' + JSON.stringify( |
| 227 | await child.page.evaluate(() => window.DaimondSync.wake()))); |
| 228 | |
| 229 | // ── (4) And the abandoned park's news is still acted on ─────────── |
| 230 | const renamed = 'Woken-Through-The-Rearm'; |
| 231 | await rename(renamed); |
| 232 | const pushedAt = Date.now(); |
| 233 | check('the first device gets the rename into the mailbox', await pushLanded(page)); |
| 234 | const arrived = await nameSettles(child.page, renamed, 8000); |
| 235 | const after = await child.page.evaluate(() => ({ |
| 236 | wake: window.DaimondSync.wake(), |
| 237 | version: window.DaimondSync.state().version, |
| 238 | probe: window.__probe, |
| 239 | })); |
| 240 | // The answer to the park that was outstanding at the re-arm: it must say the |
| 241 | // mailbox moved, and it must have been answered AFTER the push rather than |
| 242 | // having run its own wait out beforehand. |
| 243 | let answer = null; |
| 244 | for (let i = 0; i < 100 && held && !held.ended; i++) await sleep(100); |
| 245 | try { answer = held && held.body ? JSON.parse(await held.body) : null; } catch (e) { answer = null; } |
| 246 | check('the gateway answers the abandoned park with the news', |
| 247 | !!answer && answer.waited === true && answer.changed === true |
| 248 | && (answer.version | 0) > above(held) && held.ended > pushedAt, |
| 249 | held ? `held on ${above(held)} for ${held.ended - held.began}ms, answered ` |
| 250 | + `${held.ended - pushedAt}ms after the push: ${JSON.stringify(answer)}` : 'nothing was parked'); |
| 251 | check('and the device ACTS on it — the news is not thrown away with the loop', |
| 252 | arrived === renamed && after.version > wakesBefore.version, |
| 253 | `name "${arrived}", version ${wakesBefore.version} -> ${after.version}`); |
| 254 | check('with nothing happening on the device itself to explain it', |
| 255 | after.probe.focus === 0 && after.probe.vis === 0 && after.probe.idle === 0, |
| 256 | JSON.stringify(after.probe)); |
| 257 | check('and the channel counts the pull as its own doing', |
| 258 | after.wake.wakes > wakesBefore.wake.wakes && after.wake.heard > wakesBefore.version, |
| 259 | `wakes ${wakesBefore.wake.wakes} -> ${after.wake.wakes}, heard ${after.wake.heard} ` |
| 260 | + `while holding ${wakesBefore.version}`); |
| 261 | |
| 262 | // ── (5) The probe has the same shape, and the same fix ──────────── |
| 263 | // The one-shot park the channel makes before reaching for a socket is guarded |
| 264 | // by a flag of its own. Held open, it blocked a re-arm exactly as the loop |
| 265 | // did — briefly, because it is a one-second wait, but a device that reaches |
| 266 | // for a channel and is told one is already being opened by a generation that |
| 267 | // has stood down is the same fault in a smaller window. |
| 268 | const probesBefore = attempted; |
| 269 | await child.page.evaluate(() => window.DaimondSync.wakeVia('')); // probe #1, hung by the route |
| 270 | await sleep(1500); |
| 271 | const first = attempted - probesBefore; |
| 272 | await child.page.evaluate(() => window.DaimondSync.wakeVia('')); // re-arm on top of it |
| 273 | const second = await (async () => { |
| 274 | const t0 = Date.now(); |
| 275 | while (Date.now() - t0 < 3000) { |
| 276 | if (attempted - probesBefore >= first + 1) return Date.now() - t0; |
| 277 | await sleep(50); |
| 278 | } |
| 279 | return -1; |
| 280 | })(); |
| 281 | check('a probe left hanging does not block the next attempt at a channel either', |
| 282 | first >= 1 && second >= 0, |
| 283 | `${first} probe(s) before the re-arm, the next attempted ${second >= 0 ? second + 'ms after it' : 'never'}`); |
| 284 | |
| 285 | // Let the hung requests go before the page is asked to close. |
| 286 | await child.page.unroute(isPark); |
| 287 | for (const r of hung) { try { await r.abort(); } catch (e) { /* the page may have gone */ } } |
| 288 | child.page.off('request', onPark); |
| 289 | child.page.off('requestfinished', onDone); |
| 290 | |
| 291 | const noise = /WebSocket connection to '[^']*\/api\/sync\/ws/; |
| 292 | const clean = (errs) => errs.filter(e => |
| 293 | !/favicon|ERR_|Failed to load resource|401|402|409|426|502|Unauthorized/.test(e) && !noise.test(e)); |
| 294 | check('no unexpected console errors on the woken device', clean(child.errs).length === 0, |
| 295 | clean(child.errs).slice(0, 3).join(' | ')); |
| 296 | check('no unexpected console errors on the pushing device', clean(s.errs).length === 0, |
| 297 | clean(s.errs).slice(0, 3).join(' | ')); |
| 298 | } catch (e) { |
| 299 | check('verify_wakerearm ran without throwing', false, String(e && e.message || e)); |
| 300 | } finally { |
| 301 | await child?.close?.().catch?.(() => {}); |
| 302 | await s.close?.().catch?.(() => {}); |
| 303 | } |
| 304 | |
| 305 | console.log('\n' + (bad.length ? `FAIL: ${bad.length} failed, ${ok.length} passed` : `ok: all ${ok.length} passed`)); |
| 306 | process.exit(bad.length ? 1 : 0); |