Oregami
Repositories/oxedyne/daimond

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.
31import { open, signInAs } from './harness.mjs';
32import { makePagePro } from './pro.mjs';
33import path from 'node:path';
34import { fileURLToPath } from 'node:url';
35
36const ok = [], bad = [];
37const check = (name, pass, detail) => {
38 (pass ? ok : bad).push(name);
39 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
40};
41const 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.
49async 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.
72async 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
84const GWDIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'gateway');
85const s = await open({ name: 'rearm', signIn: true, connect: false, defaults: false });
86const { page } = s;
87let child = null;
88
89try {
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
305console.log('\n' + (bad.length ? `FAIL: ${bad.length} failed, ${ok.length} passed` : `ok: all ${ok.length} passed`));
306process.exit(bad.length ? 1 : 0);