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. |
| 113 | import fs from 'node:fs'; |
| 114 | import os from 'node:os'; |
| 115 | import http from 'node:http'; |
| 116 | import path from 'node:path'; |
| 117 | import { spawnSync } from 'node:child_process'; |
| 118 | import { 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. |
| 125 | import './display.mjs'; |
| 126 | const PW = process.env.DAIMOND_PW |
| 127 | || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs'); |
| 128 | const { chromium } = await import(pathToFileURL(PW).href); |
| 129 | const CHROME = process.env.DAIMOND_CHROME |
| 130 | || `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`; |
| 131 | |
| 132 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 133 | const ROOT = path.join(HERE, '..'); |
| 134 | const WWW = path.join(ROOT, 'www'); |
| 135 | const INSTALL = path.join(ROOT, 'hand/install/install.sh'); |
| 136 | const HAND = path.join(ROOT, 'hand/target/release/daimond-hand'); |
| 137 | const 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. |
| 140 | const 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. |
| 145 | const 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 | |
| 152 | const argv = process.argv.slice(2); |
| 153 | const KEEP = argv.includes('--keep'); |
| 154 | const 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. |
| 159 | const 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 | }; |
| 213 | if (BREAK && !BREAKS[BREAK]) { |
| 214 | console.error(`unknown break '${BREAK}'; there are: ${Object.keys(BREAKS).join(', ')}`); |
| 215 | process.exit(2); |
| 216 | } |
| 217 | |
| 218 | const ok = [], bad = []; |
| 219 | const check = (name, pass, detail) => { |
| 220 | (pass ? ok : bad).push(name); |
| 221 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 222 | }; |
| 223 | const note = (s) => console.log(' · ' + s); |
| 224 | const 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. |
| 229 | function 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. |
| 241 | function 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 ─────────────────── |
| 253 | const BASE = path.join(SCRATCH, `handreload-${process.pid}`); |
| 254 | const PROFILE = path.join(BASE, 'profile'); |
| 255 | const JOURNAL = path.join(BASE, 'journal'); |
| 256 | const GRANT = path.join(BASE, 'work'); |
| 257 | const HOSTS = path.join(PROFILE, 'NativeMessagingHosts'); |
| 258 | fs.rmSync(BASE, { recursive: true, force: true }); |
| 259 | for (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. |
| 263 | fs.chmodSync(JOURNAL, 0o700); |
| 264 | |
| 265 | // ── The extension, and the page, as this run will serve them ──────── |
| 266 | const { extDev } = await import(pathToFileURL(path.join(HERE, 'extdev.mjs')).href); |
| 267 | const 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 | })(); |
| 281 | const SHARED_EXT = await extDev(PORT); |
| 282 | const EXT = path.join(BASE, 'ext'); |
| 283 | fs.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. |
| 288 | function 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 | } |
| 296 | if (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 | |
| 302 | const PAGE = `<!doctype html><meta charset="utf-8"><title>handreload</title> |
| 303 | <body><h1>handreload</h1> |
| 304 | <script src="/js/hand.js"></script> |
| 305 | <script> |
| 306 | window.__why = function (e) { return 'ERR ' + ((e && e.message) || e); }; |
| 307 | window.__status = function () { return DaimondHand.status().then(function (s) { return String(s); }, window.__why); }; |
| 308 | window.__runs = function () { return DaimondHand.runs().then(function (s) { return JSON.stringify(s); }, window.__why); }; |
| 309 | window.__signal = function (id, sig) { return DaimondHand.signal(id, sig).then(function () { return 'sent'; }, window.__why); }; |
| 310 | window.__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. */ |
| 313 | window.__start = function (spec) { window.__pending = DaimondHand.run(JSON.stringify(spec)); return 'started'; }; |
| 314 | window.__held = function (id) { return DaimondHand.held(id).then(function (h) { return JSON.stringify(h); }, window.__why); }; |
| 315 | </script></body>`; |
| 316 | |
| 317 | const 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 | }); |
| 328 | await new Promise((r) => server.listen(PORT, '127.0.0.1', r)); |
| 329 | if (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. |
| 337 | const buildEnv = { ...process.env }; |
| 338 | delete buildEnv.CARGO_TARGET_DIR; |
| 339 | if (!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 | } |
| 346 | check('the hand is built', fs.existsSync(HAND), HAND); |
| 347 | if (!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. |
| 352 | fs.writeFileSync(path.join(JOURNAL, 'root.txt'), |
| 353 | `# The one folder Daimond's machine hand may work in.\n${GRANT}\n`); |
| 354 | process.env.DAIMOND_HAND_JOURNAL_DIR = JOURNAL; |
| 355 | delete process.env.DAIMOND_HAND_ROOT; |
| 356 | |
| 357 | const inst = spawnSync('bash', [INSTALL, '--dir', HOSTS, HAND], { cwd: ROOT, encoding: 'utf8' }); |
| 358 | check('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 ───────────────────────────────────────────────────── |
| 363 | const 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 | }); |
| 370 | let page = await b.newPage(); |
| 371 | await page.goto(`http://127.0.0.1:${PORT}/`, { waitUntil: 'domcontentloaded' }); |
| 372 | await 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. |
| 376 | async 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. |
| 394 | const statusP = page.evaluate(() => window.__status()); |
| 395 | const granted = await grant('allow'); |
| 396 | check('the grant window opened and was answered', granted); |
| 397 | const status = JSON.parse((await statusP) || '{}'); |
| 398 | check('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 | |
| 402 | const handsBefore = handPids(); |
| 403 | check('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. |
| 411 | const RUN_ID = 'run-1-bash'; |
| 412 | const 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 | }; |
| 418 | const ran = JSON.parse(await page.evaluate((s) => window.__run(s), spec)); |
| 419 | const leftPid = Number((/LEFT=(\d+)/.exec(ran.stdout || '') || [])[1] || 0); |
| 420 | check('a command that backgrounds a process runs and ends', |
| 421 | ran.exit === 0 && leftPid > 0, JSON.stringify(ran).slice(0, 300)); |
| 422 | check('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. |
| 426 | await sleep(600); |
| 427 | const before = await page.evaluate(() => window.__runs()); |
| 428 | const rowsBefore = (() => { try { return JSON.parse(before).runs || []; } catch (e) { return []; } })(); |
| 429 | check('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. |
| 438 | await page.reload({ waitUntil: 'domcontentloaded' }); |
| 439 | await 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. |
| 443 | const status2P = page.evaluate(() => window.__status()); |
| 444 | await grant('allow', 8000); |
| 445 | const status2 = JSON.parse((await status2P) || '{}'); |
| 446 | check('the reloaded page reaches a hand again', |
| 447 | status2 && status2.transport === 'machine' && status2.root === GRANT, |
| 448 | JSON.stringify(status2).slice(0, 200)); |
| 449 | |
| 450 | const handsAfter = handPids(); |
| 451 | note(`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. |
| 458 | const after = await page.evaluate(() => window.__runs()); |
| 459 | const rowsAfter = (() => { try { return JSON.parse(after).runs || []; } catch (e) { return null; } })(); |
| 460 | const stillAlive = alive(leftPid); |
| 461 | const listed = Array.isArray(rowsAfter) && rowsAfter.some((r) => r && r.id === RUN_ID); |
| 462 | note(stillAlive |
| 463 | ? `the reload LEFT the process running (pid ${leftPid})` |
| 464 | : `the reload TOOK the standing group with it (pid ${leftPid} is gone)`); |
| 465 | check('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. |
| 475 | check('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. |
| 481 | check('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. |
| 487 | const listing = (() => { try { return JSON.parse(after); } catch (e) { return null; } })(); |
| 488 | check('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. |
| 495 | if (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. |
| 517 | const AWAY_ID = 'run-4-marker'; |
| 518 | const AWAY_MS = 8000; |
| 519 | await 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 | }); |
| 525 | await sleep(1200); // long enough for BEFORE-MARKER to reach the page that is here |
| 526 | await page.goto('about:blank', { waitUntil: 'domcontentloaded' }); |
| 527 | await sleep(AWAY_MS); |
| 528 | await page.goto(`http://127.0.0.1:${PORT}/`, { waitUntil: 'domcontentloaded' }); |
| 529 | await sleep(600); |
| 530 | const backP = page.evaluate(() => window.__status()); |
| 531 | await grant('allow', 8000); |
| 532 | await backP; |
| 533 | |
| 534 | const 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. |
| 537 | const heldAway = await page.evaluate((id) => window.__held(id), AWAY_ID); |
| 538 | const backList = (() => { try { return JSON.parse(back); } catch (e) { return null; } })(); |
| 539 | const carried = (backList && Array.isArray(backList.carried)) ? backList.carried : []; |
| 540 | check('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 | |
| 544 | const twiceRaw = await page.evaluate((id) => window.__held(id), AWAY_ID); |
| 545 | const readBack = (() => { try { return JSON.parse(heldAway); } catch (e) { return null; } })(); |
| 546 | check('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. |
| 553 | const twice = (() => { try { return JSON.parse(twiceRaw); } catch (e) { return null; } })(); |
| 554 | check('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. |
| 566 | const LATE_ID = 'run-3-late'; |
| 567 | const 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. |
| 576 | const late = (() => { try { return JSON.parse(lateRaw) || {}; } catch (e) { return {}; } })(); |
| 577 | const latePid = Number((/LEFT=(\d+)/.exec(late.stdout || '') || [])[1] || 0); |
| 578 | check('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. |
| 584 | await page.goto('about:blank', { waitUntil: 'domcontentloaded' }); |
| 585 | note(`waiting out the ${Math.round(HOLD_S)}s hold with the page away`); |
| 586 | await sleep(HOLD_S * 1000 + 6000); |
| 587 | |
| 588 | check('the grace runs out and the standing group is stopped', !alive(latePid), `pid ${latePid}`); |
| 589 | |
| 590 | await page.goto(`http://127.0.0.1:${PORT}/`, { waitUntil: 'domcontentloaded' }); |
| 591 | await sleep(600); |
| 592 | const status3P = page.evaluate(() => window.__status()); |
| 593 | await grant('allow', 8000); |
| 594 | await status3P; |
| 595 | const late2 = await page.evaluate(() => window.__runs()); |
| 596 | const lateList = (() => { try { return JSON.parse(late2); } catch (e) { return null; } })(); |
| 597 | check('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. |
| 606 | if (alive(leftPid)) { spawnSync('kill', ['-9', String(leftPid)]); note(`cleared pid ${leftPid} from outside`); } |
| 607 | await b.close(); |
| 608 | server.close(); |
| 609 | if (!KEEP) fs.rmSync(BASE, { recursive: true, force: true }); |
| 610 | |
| 611 | console.log(`\n${ok.length} ok, ${bad.length} failed`); |
| 612 | if (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 | } |
| 622 | process.exit(bad.length ? 1 : 0); |