Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_handreload.mjs

32.8 KiB, 1 run

created by r2519314175:471, 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_handreload.mjs — what a daimon left running, after the page is reloaded.
2//
3// WHY THIS FILE EXISTS. `dev/verify_handstop.mjs` proved that a command which
4// outlives itself comes back: a `sleep` left standing by a finished run is listed
5// under that run's identifier and can be stopped by it. Every one of its checks
6// happens in ONE page load. Nothing anywhere asks the next question, which is the
7// one a person actually meets:
8//
9// a daimon starts a dev server, the turn ends, the user reloads the page —
10// and asks what is still running.
11//
12// A reload is not an exotic event. It is what F5 does, what a crash does, what
13// `dev/serve.mjs` restarting does, and what the app itself does after an update
14// (the 426 heal). It is also a COLD START, which is the moment the owner named:
15// "every time I start using it I bump into a bug or things I hate."
16//
17// ── The property, which is not the one I set out to check ───────────
18//
19// I wrote this file expecting to find the record lost and the leak orphaned:
20// `Runner.left` (hand/src/exec.rs:417) is an `Arc<Mutex<HashMap<..>>>` — memory in
21// the hand process and nowhere else — and `ext/hand.js`'s `stop()` (ext/hand.js:556)
22// sends `bye` and disconnects the native host when the page goes. Both true, and
23// the conclusion drawn from them was wrong. MEASURED (2026-08-24, first run of this
24// file): the teardown kills the standing group with it, so the process is gone and
25// the empty listing that follows is honest.
26//
27// So the check is not "the record survives". It is the property that has to hold
28// whichever way the teardown behaves, and the only one worth asserting:
29//
30// AFTER A RELOAD, THE LISTING AND THE KERNEL AGREE.
31//
32// A listing that says "Nothing this hand started is still running" while a server
33// holds a port is the exact defect `runs_report` was written to make impossible
34// (`src/tools.rs:5279`: reporting a kill as done because the message went out "is
35// the defect that left two servers holding ports with nothing able to reach them").
36// An empty listing and a true nothing-there are indistinguishable from the daimon's
37// side, and only one of them is honest.
38//
39// ── AND THEN THE OWNER DECIDED WHICH WAY IT SHOULD GO ───────────────
40//
41// The line above used to end "which way the teardown goes is reported as a NOTE,
42// because it is a design fact that may legitimately change". It changed, on
43// 2026-08-25, and it is now the design: a page that goes away holds what it left
44// running for ABOUT THIRTY SECONDS and the same tab, reloaded, re-attaches. So
45// the note becomes three more checks, and the agreement check stays exactly where
46// it was, because agreement is what has to hold whichever way the design goes.
47//
48// A RELOAD INSIDE THE GRACE FINDS THE PROCESS STILL RUNNING, ON THE SAME
49// HAND, WITH THE OUTPUT THAT ARRIVED WHILE THE PAGE WAS AWAY INTACT.
50//
51// A RELOAD AFTER IT FINDS THE PROCESS STOPPED AND IS TOLD SO.
52//
53// The second is the half that is easy to leave out and is the more important. A
54// process killed at thirty seconds and not mentioned is the teardown that
55// swallowed a failed kill, in a new place: the listing is honestly empty and
56// nothing anywhere says why. So the lapse is asserted to reach the page as WORDS,
57// not merely to have happened.
58//
59// THE KERNEL IS THE ORACLE, at every step and from OUTSIDE the browser. The pid is
60// read out of the command's own output and `/proc` is asked directly from node,
61// before the reload and after it. A listing that agreed with itself would prove
62// nothing; a process that is alive is alive.
63//
64// ── What this does NOT prove ────────────────────────────────────────
65//
66// There is no app and no model here: the page calls `DaimondHand.runs()` where
67// `Tool::runs` would, exactly as `verify_handstop.mjs` does and for the same
68// reason. What the model is HANDED is decided by `runs_step` and `runs_report`,
69// which are pure and tested natively. This proves the transport and the record
70// those two sit on top of, across a page load.
71//
72// xvfb-run -a -s "-screen 0 1400x900x24" node dev/verify_handreload.mjs
73//
74// --break orphan the reloaded page never adopts what was held for it, so
75// the parked relay keeps the standing group ALIVE while the
76// new page's new hand knows nothing about it. That is the
77// world this file exists to refuse — a process holding a
78// port with an empty listing beside it.
79// --break nograce the page going away stops everything on the spot, as it
80// did until 2026-08-25. The listing and the kernel still
81// AGREE, which is exactly why the agreement check was never
82// enough on its own.
83// --break dropheld the hold keeps the processes and throws the output away.
84// --break silentlapse the grace runs out and nothing is written down, so the
85// page that comes back late meets an empty listing with no
86// explanation — the swallowed teardown, in a new place.
87// --break nolisten the page's `runs` reply is dropped, as it was at 3e9ac52
88// --keep leave the scratch tree behind
89//
90// WHAT EACH BREAK REDDENS, established by running all four rather than reasoned
91// about. A break whose reach is not stated is a break whose reach is not known.
92//
93// orphan 7 — the reloaded page reaches no hand at all, because the
94// parked relay still holds the journal and a second host
95// exits on the lock. So everything downstream of "there is a
96// hand" goes with it, the agreement check among them.
97// nograce 6 — the reload kills what it found, as it did before, and a
98// SECOND hand answers. The agreement check stays GREEN
99// throughout, which is exactly why it was never enough alone.
100// dropheld 2 — the two output checks, and NOTHING else. The processes
101// survive, the hand is the same one, the lapse still reports
102// itself; only the words the command said in the gap are gone.
103// That isolation is what establishes that the output checks
104// measure the hold rather than the grace.
105// silentlapse 1 — the lapse check alone. The kill still happens, so "the
106// grace runs out and the standing group is stopped" stays
107// green: the split between doing it and saying it is the
108// whole point of that pair.
109//
110// Headed, because Chromium loads an unpacked extension in no other mode. Needs
111// nothing else running: it serves its own two files, on its own port from 8837,
112// into its own profile, journal and granted folder, and takes all of it down again.
113import fs from 'node:fs';
114import os from 'node:os';
115import http from 'node:http';
116import path from 'node:path';
117import { spawnSync } from 'node:child_process';
118import { fileURLToPath, pathToFileURL } from 'node:url';
119
120// Chromium's ozone platform is chosen by autodetection and prefers Wayland whenever
121// `WAYLAND_DISPLAY` is set -- which it is in every rc session on argonaut -- so a headed
122// run under `xvfb-run` still went to the compositor and opened a window on the owner's
123// desktop. Importing this strips the two variables from `process.env`, which is all a
124// launcher that spreads `process.env` needs. See dev/display.mjs.
125import './display.mjs';
126const PW = process.env.DAIMOND_PW
127 || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs');
128const { chromium } = await import(pathToFileURL(PW).href);
129const CHROME = process.env.DAIMOND_CHROME
130 || `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`;
131
132const HERE = path.dirname(fileURLToPath(import.meta.url));
133const ROOT = path.join(HERE, '..');
134const WWW = path.join(ROOT, 'www');
135const INSTALL = path.join(ROOT, 'hand/install/install.sh');
136const HAND = path.join(ROOT, 'hand/target/release/daimond-hand');
137const EXTID = 'mpliijponglmmffjnonahhignkpkhmij';
138// Not /tmp -- it is a tmpfs, and what is written there is RAM charged to this
139// machine's agent fleet. See the SCRATCH note in harness.mjs.
140const SCRATCH = process.env.DAIMOND_SCRATCH || path.join(os.homedir(), '.cache/daimond');
141
142// The extension's own `HOLD_MS`, read from the file rather than repeated here: a
143// wait that disagreed with the hold would pass or fail for a reason that is not
144// the property.
145const HOLD_S = (() => {
146 const src = fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '../ext/hand.js'), 'utf8');
147 const m = /const HOLD_MS = (\d+);/.exec(src);
148 if (!m) { console.error('ext/hand.js no longer names HOLD_MS; the wait cannot be aimed'); process.exit(2); }
149 return Number(m[1]) / 1000;
150})();
151
152const argv = process.argv.slice(2);
153const KEEP = argv.includes('--keep');
154const BREAK = (() => { const i = argv.indexOf('--break'); return i >= 0 ? String(argv[i + 1] || '') : ''; })();
155
156// Each break is applied to a COPY of the file it is in, never to the tree. `must`
157// is what it has to redden: a break that reddens none of the checks aimed at it has
158// proved that those checks are measuring nothing.
159const BREAKS = {
160 // THE ONE THIS FILE IS FOR. The extension stops tearing the native host down
161 // when the page goes, so the OLD hand stays alive holding the standing group,
162 // and the reloaded page gets a NEW hand that has never heard of it. The process
163 // is then alive on the machine and absent from the listing — and a daimon
164 // reading that listing will tell the user nothing is running. It is not a
165 // hypothetical: it is what the whole `Runner.left` mechanism was built to stop,
166 // one layer up, where nothing had looked.
167 orphan: {
168 file: 'ext',
169 find: "\t\tconst waiting = tab ? parked.get(tab) : null;",
170 with: "\t\tconst waiting = null;\t/* break 'orphan': nothing is ever adopted */",
171 must: ['the listing after the reload agrees with the kernel'],
172 },
173 // The world as it was until the grace landed: the page goes and everything
174 // goes with it. Kept as a break because it is the one that shows why the
175 // agreement check could never have caught this on its own — under it the
176 // listing and the kernel agree perfectly, about a process that has been
177 // killed by a keypress.
178 nograce: {
179 file: 'ext',
180 find: "\t\tfunction park() {\n\t\t\tif (closing) return;",
181 with: "\t\tfunction park() {\n\t\t\tif (closing) return;\n\t\t\tstop(''); return;\t/* break 'nograce' */",
182 must: ['a reload inside the grace leaves the standing group running'],
183 },
184 // The processes are held and their output is not. This is the break the
185 // output checks exist for: a daimon that re-attaches and silently misses
186 // thirty seconds of a build is worse off than one whose build was killed.
187 dropheld: {
188 file: 'ext',
189 find: "\t\t\tlet size = 0;\n\t\t\ttry { size = JSON.stringify(m).length; } catch (e) { size = 0; }\n\t\t\theld.push({ m, size });",
190 with: "\t\t\tlet size = 0;\n\t\t\treturn;\t/* break 'dropheld': held output is thrown away */\n\t\t\theld.push({ m, size });",
191 must: ['output that arrived while the page was away is held rather than dropped'],
192 },
193 // The grace runs out, the group is killed, and nothing is written down. The
194 // kill still HAPPENS -- so the check about the process is green and only the
195 // check about the words moves, which is the split that matters.
196 silentlapse: {
197 file: 'ext',
198 find: "\t\t\tlapses.set(tabId, {\n\t\t\t\tat: Date.now(),\n\t\t\t\taway: HOLD_MS,",
199 with: "\t\t\tif (false) lapses.set(tabId, {\n\t\t\t\tat: Date.now(),\n\t\t\t\taway: HOLD_MS,",
200 must: ['a page that comes back after the grace is told what was stopped'],
201 },
202 // The page's drop-for-no-id, as it stood at 3e9ac52 — the seam that made `runs`
203 // unreachable from the browser at all. It reddens the listing checks either
204 // side of the reload rather than the agreement, and it is kept because a file
205 // that can only be reddened one way has only been shown to measure one thing.
206 nolisten: {
207 file: 'www',
208 find: "\t\tif (msg.t === 'runs') {",
209 with: "\t\tif (false && msg.t === 'runs') {",
210 must: ['a background process is listed as standing before the reload'],
211 },
212};
213if (BREAK && !BREAKS[BREAK]) {
214 console.error(`unknown break '${BREAK}'; there are: ${Object.keys(BREAKS).join(', ')}`);
215 process.exit(2);
216}
217
218const ok = [], bad = [];
219const check = (name, pass, detail) => {
220 (pass ? ok : bad).push(name);
221 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
222};
223const note = (s) => console.log(' · ' + s);
224const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
225
226/// Is this pid a process that is still going, asked of the kernel and not of the
227/// hand? A zombie does not count: a reaped child keeps its `/proc` entry for a
228/// moment and counting it would make every successful stop look like a failure.
229function alive(pid) {
230 if (!pid || !Number.isInteger(pid)) return false;
231 try {
232 const st = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
233 return st.slice(st.lastIndexOf(')') + 2).split(' ')[0] !== 'Z';
234 } catch (e) { return false; }
235}
236
237/// Every `daimond-hand` this run's own installer registered that is alive now.
238///
239/// Read from `/proc` rather than from `pgrep`, and matched on THIS run's binary
240/// path, so a hand belonging to another lane on this machine is never counted.
241function handPids() {
242 const out = [];
243 for (const d of fs.readdirSync('/proc')) {
244 if (!/^\d+$/.test(d)) continue;
245 try {
246 if (fs.readlinkSync(`/proc/${d}/exe`) === HAND) out.push(Number(d));
247 } catch (e) { /* gone, or not ours to read */ }
248 }
249 return out;
250}
251
252// ── One tree, and nothing of this run survives it ───────────────────
253const BASE = path.join(SCRATCH, `handreload-${process.pid}`);
254const PROFILE = path.join(BASE, 'profile');
255const JOURNAL = path.join(BASE, 'journal');
256const GRANT = path.join(BASE, 'work');
257const HOSTS = path.join(PROFILE, 'NativeMessagingHosts');
258fs.rmSync(BASE, { recursive: true, force: true });
259for (const d of [PROFILE, JOURNAL, GRANT, HOSTS]) fs.mkdirSync(d, { recursive: true });
260// 0700, and it is load-bearing: `root.txt` beside the journal makes
261// `journal::is_ours` answer no, so the hand will not tighten the directory itself
262// and a mode anyone else can read is a refusal to start.
263fs.chmodSync(JOURNAL, 0o700);
264
265// ── The extension, and the page, as this run will serve them ────────
266const { extDev } = await import(pathToFileURL(path.join(HERE, 'extdev.mjs')).href);
267const PORT = await (async () => {
268 for (let p = 8837; p < 8877; p++) {
269 try {
270 await new Promise((res, rej) => {
271 const s = http.createServer(() => {});
272 s.once('error', rej);
273 s.listen(p, '127.0.0.1', () => s.close(res));
274 });
275 return p;
276 } catch (e) { if (e.code !== 'EADDRINUSE') throw e; }
277 }
278 console.error('no free port from 8837');
279 process.exit(2);
280})();
281const SHARED_EXT = await extDev(PORT);
282const EXT = path.join(BASE, 'ext');
283fs.cpSync(SHARED_EXT, EXT, { recursive: true });
284
285/// Puts one break back into a copy of the file it was in, and refuses where the
286/// line it names is not there — a break whose anchor has moved damages nothing and
287/// passes quietly, which is worse than not running at all.
288function damage(text, name) {
289 const b = BREAKS[name];
290 if (!text.includes(b.find)) {
291 console.error(`\nbreak '${name}' cannot be applied: its anchor is not in the file.\n${b.find}`);
292 process.exit(2);
293 }
294 return text.replace(b.find, b.with);
295}
296if (BREAK && BREAKS[BREAK].file === 'ext') {
297 const f = path.join(EXT, 'hand.js');
298 fs.writeFileSync(f, damage(fs.readFileSync(f, 'utf8'), BREAK));
299 note(`break '${BREAK}' applied to the extension copy`);
300}
301
302const PAGE = `<!doctype html><meta charset="utf-8"><title>handreload</title>
303<body><h1>handreload</h1>
304<script src="/js/hand.js"></script>
305<script>
306window.__why = function (e) { return 'ERR ' + ((e && e.message) || e); };
307window.__status = function () { return DaimondHand.status().then(function (s) { return String(s); }, window.__why); };
308window.__runs = function () { return DaimondHand.runs().then(function (s) { return JSON.stringify(s); }, window.__why); };
309window.__signal = function (id, sig) { return DaimondHand.signal(id, sig).then(function () { return 'sent'; }, window.__why); };
310window.__run = function (spec) { return DaimondHand.run(JSON.stringify(spec)).then(function (r) { return r; }, window.__why); };
311/* Started and NOT awaited: the point is a command still printing when the page
312 goes away, so the promise is parked on window and the caller returns at once. */
313window.__start = function (spec) { window.__pending = DaimondHand.run(JSON.stringify(spec)); return 'started'; };
314window.__held = function (id) { return DaimondHand.held(id).then(function (h) { return JSON.stringify(h); }, window.__why); };
315</script></body>`;
316
317const server = http.createServer((req, res) => {
318 if (/^\/js\/hand\.js/.test(req.url || '')) {
319 let js = fs.readFileSync(path.join(WWW, 'js/hand.js'), 'utf8');
320 if (BREAK && BREAKS[BREAK].file === 'www') js = damage(js, BREAK);
321 res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' });
322 res.end(js);
323 return;
324 }
325 res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
326 res.end(PAGE);
327});
328await new Promise((r) => server.listen(PORT, '127.0.0.1', r));
329if (BREAK && BREAKS[BREAK].file === 'www') note(`break '${BREAK}' applied to the page's copy of hand.js`);
330
331// ── The hand: built, granted a folder, and registered ───────────────
332//
333// `CARGO_TARGET_DIR` is removed from the build's environment, and that is not
334// tidying: an agent working in this tree usually has one set, cargo would write
335// the binary there, and `HAND` — the path `install.sh` registers and this file
336// therefore runs — would still be whatever was built last.
337const buildEnv = { ...process.env };
338delete buildEnv.CARGO_TARGET_DIR;
339if (!process.env.DAIMOND_NO_BUILD) {
340 const r = spawnSync('cargo', ['build', '--release', '--manifest-path', 'hand/Cargo.toml'],
341 { cwd: ROOT, encoding: 'utf8', env: buildEnv });
342 if (r.status !== 0) {
343 console.log((r.stderr || '').split('\n').filter((l) => /^error/.test(l)).slice(0, 5).join('\n'));
344 }
345}
346check('the hand is built', fs.existsSync(HAND), HAND);
347if (!fs.existsSync(HAND)) { console.log(`\n${ok.length} ok, ${bad.length} failed`); process.exit(1); }
348
349// The granted root, named the way a browser-launched hand actually reads it: a line
350// in `root.txt` beside the journal. Chrome hands a native messaging host its OWN
351// environment, so a variable exported in a terminal is not there.
352fs.writeFileSync(path.join(JOURNAL, 'root.txt'),
353 `# The one folder Daimond's machine hand may work in.\n${GRANT}\n`);
354process.env.DAIMOND_HAND_JOURNAL_DIR = JOURNAL;
355delete process.env.DAIMOND_HAND_ROOT;
356
357const inst = spawnSync('bash', [INSTALL, '--dir', HOSTS, HAND], { cwd: ROOT, encoding: 'utf8' });
358check('install.sh registers the real binary in this run\'s own profile',
359 inst.status === 0 && fs.existsSync(path.join(HOSTS, 'com.oxedyne.daimond.hand.json')),
360 (inst.stderr || inst.stdout || '').trim().split('\n').slice(-2).join(' '));
361
362// ── The browser ─────────────────────────────────────────────────────
363const b = await chromium.launchPersistentContext(PROFILE, {
364 executablePath: CHROME,
365 headless: false,
366 args: ['--no-sandbox', '--disable-dev-shm-usage',
367 `--disable-extensions-except=${EXT}`, `--load-extension=${EXT}`],
368 viewport: { width: 1100, height: 700 },
369});
370let page = await b.newPage();
371await page.goto(`http://127.0.0.1:${PORT}/`, { waitUntil: 'domcontentloaded' });
372await sleep(500);
373
374/// Finds the grant window and answers it. It is the extension's own page, so the
375/// click is a real one and there is no second Chrome prompt behind it.
376async function grant(answer = 'allow', ms = 15000) {
377 const until = Date.now() + ms;
378 while (Date.now() < until) {
379 for (const p of b.pages()) {
380 if (/grant\.html/.test(p.url())) {
381 await p.waitForLoadState('domcontentloaded');
382 await sleep(250);
383 await p.click(answer === 'allow' ? '#allow' : '#deny');
384 return true;
385 }
386 }
387 await sleep(150);
388 }
389 return false;
390}
391
392// The first message is what raises the grant window, so the answer is armed before
393// it is sent and awaited after.
394const statusP = page.evaluate(() => window.__status());
395const granted = await grant('allow');
396check('the grant window opened and was answered', granted);
397const status = JSON.parse((await statusP) || '{}');
398check('the real hand answered and named the folder it was granted',
399 status && status.transport === 'machine' && status.root === GRANT,
400 JSON.stringify(status).slice(0, 300));
401
402const handsBefore = handPids();
403check('exactly one hand of this run\'s own binary is running',
404 handsBefore.length === 1, JSON.stringify(handsBefore));
405
406// ── 1. A COMMAND THAT OUTLIVES ITSELF ───────────────────────────────
407//
408// `sleep` is put in the background and the shell exits, so the RUN ends and its
409// process group does not. Output is redirected, or the survivor holds the write
410// end of the pipe open and the run cannot be reported as ended at all.
411const RUN_ID = 'run-1-bash';
412const spec = {
413 t: 'exec', id: RUN_ID,
414 argv: ['bash', '-c', 'sleep 300 </dev/null >/dev/null 2>&1 & echo LEFT=$!'],
415 cwd: GRANT, env: [], stdin: null, timeout_ms: 30000, capture: 'both',
416 fence: { rw: [GRANT], ro: [], deny: [], net: false }, toolkits: [],
417};
418const ran = JSON.parse(await page.evaluate((s) => window.__run(s), spec));
419const leftPid = Number((/LEFT=(\d+)/.exec(ran.stdout || '') || [])[1] || 0);
420check('a command that backgrounds a process runs and ends',
421 ran.exit === 0 && leftPid > 0, JSON.stringify(ran).slice(0, 300));
422check('and the process it left is alive on the machine', alive(leftPid), `pid ${leftPid}`);
423
424// The hand looks at a group after the command ends, so the listing is asked for a
425// moment later rather than in the same breath.
426await sleep(600);
427const before = await page.evaluate(() => window.__runs());
428const rowsBefore = (() => { try { return JSON.parse(before).runs || []; } catch (e) { return []; } })();
429check('a background process is listed as standing before the reload',
430 rowsBefore.some((r) => r && r.id === RUN_ID && r.state === 'standing'),
431 String(before).slice(0, 400));
432
433// ── 2. THE RELOAD ───────────────────────────────────────────────────
434//
435// A plain reload of the same page, which is what F5 does and what the app's own
436// update heal does. Nothing else is touched: the same browser, the same profile,
437// the same extension, the same granted folder, and the same journal.
438await page.reload({ waitUntil: 'domcontentloaded' });
439await sleep(800);
440
441// A reloaded page raises the grant window again on its first message, because the
442// grant is held per page and not per profile. Armed the same way as the first.
443const status2P = page.evaluate(() => window.__status());
444await grant('allow', 8000);
445const status2 = JSON.parse((await status2P) || '{}');
446check('the reloaded page reaches a hand again',
447 status2 && status2.transport === 'machine' && status2.root === GRANT,
448 JSON.stringify(status2).slice(0, 200));
449
450const handsAfter = handPids();
451note(`hand pids before ${JSON.stringify(handsBefore)}, after ${JSON.stringify(handsAfter)}`);
452
453// ── 3. THE LISTING AND THE KERNEL MUST AGREE ────────────────────────
454//
455// THIS IS THE FILE'S REASON, and it is one check because it is one property. Which
456// way the teardown goes is a design fact and is REPORTED rather than asserted; that
457// the two answers agree is not negotiable, because a daimon has no third source.
458const after = await page.evaluate(() => window.__runs());
459const rowsAfter = (() => { try { return JSON.parse(after).runs || []; } catch (e) { return null; } })();
460const stillAlive = alive(leftPid);
461const listed = Array.isArray(rowsAfter) && rowsAfter.some((r) => r && r.id === RUN_ID);
462note(stillAlive
463 ? `the reload LEFT the process running (pid ${leftPid})`
464 : `the reload TOOK the standing group with it (pid ${leftPid} is gone)`);
465check('the listing after the reload agrees with the kernel',
466 rowsAfter !== null && listed === stillAlive,
467 `kernel says ${stillAlive ? 'alive' : 'gone'}, listing says `
468 + `${rowsAfter === null ? 'UNREADABLE' : (listed ? 'listed' : 'nothing')}`
469 + ` — ${String(after).slice(0, 240)}`);
470
471// ── 4. THE GRACE, WHICH IS NOW WHICH WAY IT GOES ────────────────────
472//
473// The kernel is still the oracle. `stillAlive` above was read from `/proc`, from
474// node, outside the browser; this is the same fact asserted rather than noted.
475check('a reload inside the grace leaves the standing group running',
476 stillAlive, `pid ${leftPid}`);
477
478// ONE HAND, NOT TWO. A new host that happened to find the old group would look
479// identical from the listing and would be a different thing entirely — the group
480// would be reachable by luck rather than because the relay was handed back.
481check('and the SAME machine hand answered, rather than a second one being started',
482 handsAfter.length === 1 && handsBefore.length === 1 && handsAfter[0] === handsBefore[0],
483 `before ${JSON.stringify(handsBefore)}, after ${JSON.stringify(handsAfter)}`);
484
485// AND THE RE-ATTACH IS SAID, not merely done. A gap nobody mentions is a gap the
486// reader assumes was not there.
487const listing = (() => { try { return JSON.parse(after); } catch (e) { return null; } })();
488check('a reload inside the grace says so, rather than saying nothing at all',
489 !!listing && typeof listing.note === 'string' && /picked the machine hand back up/.test(listing.note),
490 `note: ${String(listing && listing.note).slice(0, 200)}`);
491
492// And, where it did survive, being told is no use unless it can then be stopped.
493// Skipped rather than faked where the teardown already cleared it: a check that
494// asserts a dead process is dead measures nothing.
495if (stillAlive) {
496 const sent = await page.evaluate((id) => window.__signal(id, 'term'), RUN_ID);
497 await sleep(800);
498 check('and a run that outlived the reload can still be stopped by its identifier',
499 !alive(leftPid), `pid ${leftPid}, signal said: ${String(sent).slice(0, 80)}`);
500} else {
501 note('nothing survived the reload, so there is nothing to stop — check skipped');
502}
503
504// ── 4b. WHAT IT SAID WHILE NOBODY WAS LISTENING ─────────────────────
505//
506// The process surviving proves nothing about its OUTPUT, and the two are not the
507// same promise. A daimon that re-attaches and silently misses part of a build is
508// being lied to, which outranks a process that was honestly killed.
509//
510// THE MARKER IS PRINTED INSIDE THE GAP AND NOWHERE ELSE, which is what makes this
511// a measurement rather than a coincidence. The command says nothing for four
512// seconds, prints one line, and then goes quiet again; the page is away for eight.
513// So the line exists only in the hold. A test that watched a command printing
514// CONTINUOUSLY would pass with the hold gutted, because the output arriving after
515// the re-attach looks exactly like the output the hold kept — measured, on
516// 2026-08-25, by a `--break dropheld` that reddened nothing at all.
517const AWAY_ID = 'run-4-marker';
518const AWAY_MS = 8000;
519await page.evaluate((s) => window.__start(s), {
520 t: 'exec', id: AWAY_ID,
521 argv: ['bash', '-c', 'echo BEFORE-MARKER; sleep 4; echo AWAY-MARKER; sleep 200'],
522 cwd: GRANT, env: [], stdin: null, timeout_ms: 240000, capture: 'both',
523 fence: { rw: [GRANT], ro: [], deny: [], net: false }, toolkits: [],
524});
525await sleep(1200); // long enough for BEFORE-MARKER to reach the page that is here
526await page.goto('about:blank', { waitUntil: 'domcontentloaded' });
527await sleep(AWAY_MS);
528await page.goto(`http://127.0.0.1:${PORT}/`, { waitUntil: 'domcontentloaded' });
529await sleep(600);
530const backP = page.evaluate(() => window.__status());
531await grant('allow', 8000);
532await backP;
533
534const back = await page.evaluate(() => window.__runs());
535// Read BEFORE anything is stopped: handing the output over lets go of it, and a
536// stop would end the run it belongs to.
537const heldAway = await page.evaluate((id) => window.__held(id), AWAY_ID);
538const backList = (() => { try { return JSON.parse(back); } catch (e) { return null; } })();
539const carried = (backList && Array.isArray(backList.carried)) ? backList.carried : [];
540check('the listing names the output being held and which run it belongs to',
541 carried.some((c) => c && c.id === AWAY_ID && c.bytes > 0),
542 `carried: ${JSON.stringify(carried)}`);
543
544const twiceRaw = await page.evaluate((id) => window.__held(id), AWAY_ID);
545const readBack = (() => { try { return JSON.parse(heldAway); } catch (e) { return null; } })();
546check('output that arrived while the page was away is held rather than dropped',
547 !!readBack && readBack.found === true && /AWAY-MARKER/.test(String(readBack.out || '')),
548 `held: ${String(heldAway).slice(0, 300)}`);
549
550// SPENT ON READING. A second copy of a build's output in a tab is one nobody will
551// look at again, and a reader who is told it is held twice would believe the
552// second answer as much as the first.
553const twice = (() => { try { return JSON.parse(twiceRaw); } catch (e) { return null; } })();
554check('and it is handed over once, not held for a second reader',
555 !!twice && twice.found === false, `second read: ${String(twiceRaw).slice(0, 200)}`);
556
557// ── 5. AND WHEN NOTHING COMES BACK FOR IT ───────────────────────────
558//
559// The other half, and the one that is easy to leave out. The page goes away and
560// STAYS away past the hold, so what it left is stopped — and the page that comes
561// back afterwards has to be TOLD, in words, that something was stopped and why.
562// A kill nobody mentions is the teardown that swallowed a failed kill, in a new
563// place: the listing is honestly empty and nothing anywhere says the reason.
564//
565// A second standing group, because the first was stopped by the check above.
566const LATE_ID = 'run-3-late';
567const lateRaw = await page.evaluate((s) => window.__run(s), {
568 t: 'exec', id: LATE_ID,
569 argv: ['bash', '-c', 'sleep 300 </dev/null >/dev/null 2>&1 & echo LEFT=$!'],
570 cwd: GRANT, env: [], stdin: null, timeout_ms: 30000, capture: 'both',
571 fence: { rw: [GRANT], ro: [], deny: [], net: false }, toolkits: [],
572});
573// Not `JSON.parse` outright: under a break the hand may be gone and `__run`
574// answers a SENTENCE, and a verifier that died there would report nothing about
575// the checks below rather than reddening them.
576const late = (() => { try { return JSON.parse(lateRaw) || {}; } catch (e) { return {}; } })();
577const latePid = Number((/LEFT=(\d+)/.exec(late.stdout || '') || [])[1] || 0);
578check('a second background process is left standing for the grace to run out on',
579 late.exit === 0 && alive(latePid), `pid ${latePid}`);
580
581// Away, and away for longer than the hold. The SAME TAB, because the tab is what
582// the hold is keyed by — a hold offered to whichever page connected next would be
583// somebody else's compartment.
584await page.goto('about:blank', { waitUntil: 'domcontentloaded' });
585note(`waiting out the ${Math.round(HOLD_S)}s hold with the page away`);
586await sleep(HOLD_S * 1000 + 6000);
587
588check('the grace runs out and the standing group is stopped', !alive(latePid), `pid ${latePid}`);
589
590await page.goto(`http://127.0.0.1:${PORT}/`, { waitUntil: 'domcontentloaded' });
591await sleep(600);
592const status3P = page.evaluate(() => window.__status());
593await grant('allow', 8000);
594await status3P;
595const late2 = await page.evaluate(() => window.__runs());
596const lateList = (() => { try { return JSON.parse(late2); } catch (e) { return null; } })();
597check('a page that comes back after the grace is told what was stopped and why',
598 !!lateList && typeof lateList.note === 'string'
599 && /STOPPED/.test(lateList.note) && /did not come back/.test(lateList.note),
600 `note: ${String(lateList && lateList.note).slice(0, 300)} — ${String(late2).slice(0, 200)}`);
601
602// ── The verdict ─────────────────────────────────────────────────────
603//
604// Whatever was left standing is cleared from NODE, outside the browser and outside
605// the fence, so a red run does not leave a `sleep` behind for somebody else to find.
606if (alive(leftPid)) { spawnSync('kill', ['-9', String(leftPid)]); note(`cleared pid ${leftPid} from outside`); }
607await b.close();
608server.close();
609if (!KEEP) fs.rmSync(BASE, { recursive: true, force: true });
610
611console.log(`\n${ok.length} ok, ${bad.length} failed`);
612if (BREAK) {
613 const must = BREAKS[BREAK].must;
614 const missed = must.filter((m) => !bad.some((n) => n.startsWith(m)));
615 if (missed.length) {
616 console.log(`THE BREAK PROVED NOTHING about: ${missed.join('; ')}`);
617 process.exit(1);
618 }
619 console.log(`break '${BREAK}' reddened every check it names, and ${bad.length} in all`);
620 process.exit(0);
621}
622process.exit(bad.length ? 1 : 0);