oxedyne/daimond/dev/verify_handstop.mjs
20.0 KiB, 1 run
created by r2519314175:475, 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_handstop.mjs — a daimon can stop what it started, through the real chain. |
| 2 | // |
| 3 | // WHY THIS FILE EXISTS. On 2026-08-23 a daimon started two servers in front of |
| 4 | // the owner and could not stop either. That was not one defect but four in a |
| 5 | // line, and three of them were still there after the first was fixed: |
| 6 | // |
| 7 | // the hand kept no record of a group a finished run left standing, and |
| 8 | // scoped signals to the Landlock domain that sent them, so a LATER |
| 9 | // command's `kill` answered "Operation not permitted". FIXED 2026-08-23 |
| 10 | // in `hand/`: `Runner.left`, `Req::Runs`, `Req::Signal` by identifier. |
| 11 | // ext/hand.js default-DENIED `{"t":"runs"}` at its message switch, so the |
| 12 | // question never left the browser. |
| 13 | // www/js/hand.js dropped the answer for having no `id` — and it has no id |
| 14 | // by design, because a filter on that message would be a selector. |
| 15 | // src/tools.rs offered no daimon-facing tool that could send either. |
| 16 | // |
| 17 | // Each of the three was invisible to every existing test, because each sits in a |
| 18 | // different file and none of them is reached by the one below it. `hand/`'s own |
| 19 | // unit tests drive the runner in process and never meet the browser; |
| 20 | // `dev/verify_hand.mjs` drives the extension from a stub page that speaks the |
| 21 | // port directly and never loads `www/js/hand.js`; and the Rust tests cannot |
| 22 | // reach a wasm-only call site at all. So this file is the join: REAL Chrome, the |
| 23 | // REAL extension, the REAL `www/js/hand.js`, and the REAL `daimond-hand` binary |
| 24 | // with the REAL Landlock fence, and it asks the one question none of them can — |
| 25 | // does a command that outlives itself come back? |
| 26 | // |
| 27 | // WHAT IT DOES NOT PROVE. There is no app and no model here: the page calls |
| 28 | // `DaimondHand.runs()` where `Tool::runs` would. What the model is HANDED is |
| 29 | // decided by `runs_step` and `runs_report`, which are pure and tested natively |
| 30 | // in `src/tools.rs`. This file proves the transport those two sit on top of. |
| 31 | // |
| 32 | // THE PROOF THAT A LEAK IS A LEAK. A `sleep` is started in the background by a |
| 33 | // command that then exits, so the run ENDS and its process group does not. The |
| 34 | // pid is read out of the command's own output and checked FROM NODE, outside the |
| 35 | // browser and outside the fence, at every step: it is alive after the run has |
| 36 | // ended, and it is gone after the stop. A listing that agreed with itself would |
| 37 | // prove nothing; the kernel is the oracle. |
| 38 | // |
| 39 | // xvfb-run -a -s "-screen 0 1400x900x24" node dev/verify_handstop.mjs |
| 40 | // |
| 41 | // --break denyruns put the extension's default-deny back (ext/hand.js) |
| 42 | // --break dropruns put the page's drop-for-no-id back (www/js/hand.js) |
| 43 | // --keep leave the scratch tree behind |
| 44 | // |
| 45 | // Both breaks are the code as it stood at commit 3e9ac52, restored into a COPY of |
| 46 | // the file and never into the tree. Each names the checks it must redden, and a |
| 47 | // break that reddens none of them fails the run: a check that has never been seen |
| 48 | // failing is not a check. |
| 49 | // |
| 50 | // Headed, because Chromium loads an unpacked extension in no other mode. Needs |
| 51 | // nothing else running: it serves its own two files. |
| 52 | import fs from 'node:fs'; |
| 53 | import os from 'node:os'; |
| 54 | import http from 'node:http'; |
| 55 | import path from 'node:path'; |
| 56 | import { spawnSync } from 'node:child_process'; |
| 57 | import { fileURLToPath, pathToFileURL } from 'node:url'; |
| 58 | |
| 59 | // Chromium's ozone platform is chosen by autodetection and prefers Wayland whenever |
| 60 | // `WAYLAND_DISPLAY` is set -- which it is in every rc session on argonaut -- so a headed |
| 61 | // run under `xvfb-run` still went to the compositor and opened a window on the owner's |
| 62 | // desktop. Importing this strips the two variables from `process.env`, which is all a |
| 63 | // launcher that spreads `process.env` needs. See dev/display.mjs. |
| 64 | import './display.mjs'; |
| 65 | const PW = process.env.DAIMOND_PW |
| 66 | || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs'); |
| 67 | const { chromium } = await import(pathToFileURL(PW).href); |
| 68 | const CHROME = process.env.DAIMOND_CHROME |
| 69 | || `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`; |
| 70 | |
| 71 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 72 | const ROOT = path.join(HERE, '..'); |
| 73 | const WWW = path.join(ROOT, 'www'); |
| 74 | const INSTALL = path.join(ROOT, 'hand/install/install.sh'); |
| 75 | const HAND = path.join(ROOT, 'hand/target/release/daimond-hand'); |
| 76 | const EXTID = 'mpliijponglmmffjnonahhignkpkhmij'; |
| 77 | // Not /tmp -- it is a tmpfs, and what is written there is RAM charged to this |
| 78 | // machine's agent fleet. See the SCRATCH note in harness.mjs. |
| 79 | const SCRATCH = process.env.DAIMOND_SCRATCH || path.join(os.homedir(), '.cache/daimond'); |
| 80 | |
| 81 | const argv = process.argv.slice(2); |
| 82 | const KEEP = argv.includes('--keep'); |
| 83 | const BREAK = (() => { const i = argv.indexOf('--break'); return i >= 0 ? String(argv[i + 1] || '') : ''; })(); |
| 84 | |
| 85 | // Each break is the line as it stood before this work, put back in a copy. The |
| 86 | // `must` list is what it has to redden; a break that reddens none of them has |
| 87 | // proved that the checks aimed at it are measuring nothing. |
| 88 | const BREAKS = { |
| 89 | // ext/hand.js:783 — `runs` reached the default arm, which refuses. |
| 90 | denyruns: { |
| 91 | file: 'ext', |
| 92 | find: "\t\t\tcase 'runs':\n\t\t\t\tbreak;", |
| 93 | with: "\t\t\tcase '__runs_never':\n\t\t\t\tbreak;", |
| 94 | must: ['the hand answers what it is running', 'a background process is listed as standing'], |
| 95 | }, |
| 96 | // www/js/hand.js:360 — the answer carries no id, so the run switch dropped it. |
| 97 | dropruns: { |
| 98 | file: 'www', |
| 99 | find: "\t\tif (msg.t === 'runs') {", |
| 100 | with: "\t\tif (false && msg.t === 'runs') {", |
| 101 | must: ['the hand answers what it is running', 'a background process is listed as standing'], |
| 102 | }, |
| 103 | }; |
| 104 | if (BREAK && !BREAKS[BREAK]) { |
| 105 | console.error(`unknown break '${BREAK}'; there are: ${Object.keys(BREAKS).join(', ')}`); |
| 106 | process.exit(2); |
| 107 | } |
| 108 | |
| 109 | const ok = [], bad = []; |
| 110 | const check = (name, pass, detail) => { |
| 111 | (pass ? ok : bad).push(name); |
| 112 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 113 | }; |
| 114 | const note = (s) => console.log(' · ' + s); |
| 115 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 116 | |
| 117 | /// Is this pid a process that is still going, asked of the kernel and not of the |
| 118 | /// hand? A zombie does not count: a reaped child keeps its `/proc` entry for a |
| 119 | /// moment and counting it would make every successful stop look like a failure. |
| 120 | function alive(pid) { |
| 121 | if (!pid || !Number.isInteger(pid)) return false; |
| 122 | try { |
| 123 | const st = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'); |
| 124 | return st.slice(st.lastIndexOf(')') + 2).split(' ')[0] !== 'Z'; |
| 125 | } catch (e) { return false; } |
| 126 | } |
| 127 | |
| 128 | // ── One tree, and nothing of this run survives it ─────────────────── |
| 129 | const BASE = path.join(SCRATCH, `handstop-${process.pid}`); |
| 130 | const PROFILE = path.join(BASE, 'profile'); |
| 131 | const JOURNAL = path.join(BASE, 'journal'); |
| 132 | const GRANT = path.join(BASE, 'work'); |
| 133 | const HOSTS = path.join(PROFILE, 'NativeMessagingHosts'); |
| 134 | fs.rmSync(BASE, { recursive: true, force: true }); |
| 135 | for (const d of [PROFILE, JOURNAL, GRANT, HOSTS]) fs.mkdirSync(d, { recursive: true }); |
| 136 | // 0700, and it is load-bearing: `root.txt` beside the journal makes |
| 137 | // `journal::is_ours` answer no, so the hand will not tighten the directory |
| 138 | // itself and a mode anyone else can read is a refusal to start. |
| 139 | fs.chmodSync(JOURNAL, 0o700); |
| 140 | |
| 141 | // ── The extension, and the page, as this run will serve them ──────── |
| 142 | // |
| 143 | // The dev build is the only one that will speak to a page on loopback. It is |
| 144 | // written to a shared directory, so it is COPIED here before a break touches it: |
| 145 | // patching the shared one would damage every other harness on this machine. |
| 146 | const { extDev } = await import(pathToFileURL(path.join(HERE, 'extdev.mjs')).href); |
| 147 | const PORT = await (async () => { |
| 148 | for (let p = 8837; p < 8877; p++) { |
| 149 | try { |
| 150 | await new Promise((res, rej) => { |
| 151 | const s = http.createServer(() => {}); |
| 152 | s.once('error', rej); |
| 153 | s.listen(p, '127.0.0.1', () => s.close(res)); |
| 154 | }); |
| 155 | return p; |
| 156 | } catch (e) { if (e.code !== 'EADDRINUSE') throw e; } |
| 157 | } |
| 158 | console.error('no free port from 8837'); |
| 159 | process.exit(2); |
| 160 | })(); |
| 161 | const SHARED_EXT = await extDev(PORT); |
| 162 | const EXT = path.join(BASE, 'ext'); |
| 163 | fs.cpSync(SHARED_EXT, EXT, { recursive: true }); |
| 164 | |
| 165 | /// Puts one break back into a copy of the file it was in, and refuses where the |
| 166 | /// line it names is not there — a break whose anchor has moved damages nothing |
| 167 | /// and passes quietly, which is worse than not running at all. |
| 168 | function damage(text, name) { |
| 169 | const b = BREAKS[name]; |
| 170 | if (!text.includes(b.find)) { |
| 171 | console.error(`\nbreak '${name}' cannot be applied: its anchor is not in the file.\n${b.find}`); |
| 172 | process.exit(2); |
| 173 | } |
| 174 | return text.replace(b.find, b.with); |
| 175 | } |
| 176 | if (BREAK && BREAKS[BREAK].file === 'ext') { |
| 177 | const f = path.join(EXT, 'hand.js'); |
| 178 | fs.writeFileSync(f, damage(fs.readFileSync(f, 'utf8'), BREAK)); |
| 179 | note(`break '${BREAK}' applied to the extension copy`); |
| 180 | } |
| 181 | |
| 182 | const PAGE = `<!doctype html><meta charset="utf-8"><title>handstop</title> |
| 183 | <body><h1>handstop</h1> |
| 184 | <script src="/js/hand.js"></script> |
| 185 | <script> |
| 186 | window.__why = function (e) { return 'ERR ' + ((e && e.message) || e); }; |
| 187 | window.__status = function () { return DaimondHand.status().then(function (s) { return String(s); }, window.__why); }; |
| 188 | window.__runs = function () { return DaimondHand.runs().then(function (s) { return JSON.stringify(s); }, window.__why); }; |
| 189 | window.__signal = function (id, sig) { return DaimondHand.signal(id, sig).then(function () { return 'sent'; }, window.__why); }; |
| 190 | window.__run = function (spec) { return DaimondHand.run(JSON.stringify(spec)).then(function (r) { return r; }, window.__why); }; |
| 191 | </script></body>`; |
| 192 | |
| 193 | const server = http.createServer((req, res) => { |
| 194 | if (/^\/js\/hand\.js/.test(req.url || '')) { |
| 195 | let js = fs.readFileSync(path.join(WWW, 'js/hand.js'), 'utf8'); |
| 196 | if (BREAK && BREAKS[BREAK].file === 'www') js = damage(js, BREAK); |
| 197 | res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' }); |
| 198 | res.end(js); |
| 199 | return; |
| 200 | } |
| 201 | res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); |
| 202 | res.end(PAGE); |
| 203 | }); |
| 204 | await new Promise((r) => server.listen(PORT, '127.0.0.1', r)); |
| 205 | if (BREAK && BREAKS[BREAK].file === 'www') note(`break '${BREAK}' applied to the page's copy of hand.js`); |
| 206 | |
| 207 | // ── The hand: built, granted a folder, and registered ─────────────── |
| 208 | // |
| 209 | // `CARGO_TARGET_DIR` is removed from the build's environment, and that is not |
| 210 | // tidying: an agent working in this tree usually has one set, cargo would write |
| 211 | // the binary there, and `HAND` — the path `install.sh` registers and this file |
| 212 | // therefore runs — would still be whatever was built last. See the same note in |
| 213 | // verify_handreal.mjs, where an inherited one made a security test pass against a |
| 214 | // binary from before the fix. |
| 215 | const buildEnv = { ...process.env }; |
| 216 | delete buildEnv.CARGO_TARGET_DIR; |
| 217 | if (!process.env.DAIMOND_NO_BUILD) { |
| 218 | const r = spawnSync('cargo', ['build', '--release', '--manifest-path', 'hand/Cargo.toml'], |
| 219 | { cwd: ROOT, encoding: 'utf8', env: buildEnv }); |
| 220 | if (r.status !== 0) { |
| 221 | console.log((r.stderr || '').split('\n').filter((l) => /^error/.test(l)).slice(0, 5).join('\n')); |
| 222 | } |
| 223 | } |
| 224 | check('the hand is built', fs.existsSync(HAND), HAND); |
| 225 | if (!fs.existsSync(HAND)) { console.log(`\n${ok.length} ok, ${bad.length} failed`); process.exit(1); } |
| 226 | |
| 227 | // The granted root, named the way a browser-launched hand actually reads it: a |
| 228 | // line in `root.txt` beside the journal. Chrome hands a native messaging host |
| 229 | // its OWN environment, so a variable exported in a terminal is not there. |
| 230 | fs.writeFileSync(path.join(JOURNAL, 'root.txt'), |
| 231 | `# The one folder Daimond's machine hand may work in.\n${GRANT}\n`); |
| 232 | process.env.DAIMOND_HAND_JOURNAL_DIR = JOURNAL; |
| 233 | delete process.env.DAIMOND_HAND_ROOT; |
| 234 | |
| 235 | const inst = spawnSync('bash', [INSTALL, '--dir', HOSTS, HAND], { cwd: ROOT, encoding: 'utf8' }); |
| 236 | check('install.sh registers the real binary in this run\'s own profile', |
| 237 | inst.status === 0 && fs.existsSync(path.join(HOSTS, 'com.oxedyne.daimond.hand.json')), |
| 238 | (inst.stderr || inst.stdout || '').trim().split('\n').slice(-2).join(' ')); |
| 239 | |
| 240 | // ── The browser ───────────────────────────────────────────────────── |
| 241 | const b = await chromium.launchPersistentContext(PROFILE, { |
| 242 | executablePath: CHROME, |
| 243 | headless: false, |
| 244 | args: ['--no-sandbox', '--disable-dev-shm-usage', |
| 245 | `--disable-extensions-except=${EXT}`, `--load-extension=${EXT}`], |
| 246 | viewport: { width: 1100, height: 700 }, |
| 247 | }); |
| 248 | const page = await b.newPage(); |
| 249 | await page.goto(`http://127.0.0.1:${PORT}/`, { waitUntil: 'domcontentloaded' }); |
| 250 | await sleep(500); |
| 251 | |
| 252 | /// Finds the grant window and answers it. It is the extension's own page, so the |
| 253 | /// click is a real one and there is no second Chrome prompt behind it. |
| 254 | async function grant(answer = 'allow', ms = 15000) { |
| 255 | const until = Date.now() + ms; |
| 256 | while (Date.now() < until) { |
| 257 | for (const p of b.pages()) { |
| 258 | if (/grant\.html/.test(p.url())) { |
| 259 | await p.waitForLoadState('domcontentloaded'); |
| 260 | await sleep(250); |
| 261 | await p.click(answer === 'allow' ? '#allow' : '#deny'); |
| 262 | return true; |
| 263 | } |
| 264 | } |
| 265 | await sleep(150); |
| 266 | } |
| 267 | return false; |
| 268 | } |
| 269 | |
| 270 | const found = await page.evaluate(() => document.documentElement.dataset.daimondHands || ''); |
| 271 | check('the page finds the extension the way the app finds it', found === EXTID, found || '(no stamp)'); |
| 272 | |
| 273 | // The first message is what raises the grant window, so the answer is armed |
| 274 | // before it is sent and awaited after. |
| 275 | const statusP = page.evaluate(() => window.__status()); |
| 276 | const granted = await grant('allow'); |
| 277 | check('the grant window opened and was answered', granted); |
| 278 | const status = JSON.parse((await statusP) || '{}'); |
| 279 | check('the real hand answered and named the folder it was granted', |
| 280 | status && status.transport === 'machine' && status.root === GRANT, |
| 281 | JSON.stringify(status).slice(0, 300)); |
| 282 | // `paired` is FALSE here and that is correct, not a fault: `hand/REVIEW.md` §1.14 |
| 283 | // refuses a command where the page cannot prove that the folder it has open is |
| 284 | // the folder the hand was granted, and a stub page holds no folder at all. It is |
| 285 | // asserted rather than worked around, because everything below happens IN SPITE |
| 286 | // of it -- `Tool::runs` deliberately does not gate on the folder proof, since it |
| 287 | // runs nothing and reads nothing, and gating on it would make a leaked server |
| 288 | // unstoppable on exactly the workspaces where one is easiest to leak. |
| 289 | check('the folder proof refuses this page, and stopping a run works regardless', |
| 290 | status && status.paired === false && /folder|workspace/i.test(String(status.reason || '')), |
| 291 | JSON.stringify(status.reason || '(no reason)').slice(0, 200)); |
| 292 | // The hand's own home, which is how a granted toolchain's roots are expressed and |
| 293 | // how the extension recognises one. Without it a CORRECTLY granted toolchain is |
| 294 | // refused too -- a second, independent way to be told a fence reaches outside the |
| 295 | // grant, and one that would not show in the sentence the first produces. |
| 296 | check('the hand reports the home a granted toolchain would sit in', |
| 297 | (status.caps || []).some((c) => typeof c === 'string' && c.indexOf('home:/') === 0), |
| 298 | JSON.stringify(status.caps || []).slice(0, 300)); |
| 299 | |
| 300 | // ── 1. THE QUESTION ITSELF REACHES THE HAND ───────────────────────── |
| 301 | // |
| 302 | // Red against the extension's default-deny AND against the page's drop, and it |
| 303 | // is the cheapest of the three checks that are: nothing has been started yet, so |
| 304 | // what it measures is only whether the message can make the round trip at all. |
| 305 | const idle = await page.evaluate(() => window.__runs()); |
| 306 | check('the hand answers what it is running', |
| 307 | !String(idle).startsWith('ERR') && Array.isArray(JSON.parse(idle || '{}').runs), |
| 308 | String(idle).slice(0, 300)); |
| 309 | check('and it is running nothing before anything is started', |
| 310 | (() => { try { return JSON.parse(idle).runs.length === 0; } catch (e) { return false; } })(), |
| 311 | String(idle).slice(0, 200)); |
| 312 | |
| 313 | // ── 2. A COMMAND THAT OUTLIVES ITSELF ─────────────────────────────── |
| 314 | // |
| 315 | // `sleep` is put in the background and the shell exits, so the RUN ends and its |
| 316 | // process group does not. Output is redirected, or the survivor holds the write |
| 317 | // end of the pipe open and the run cannot be reported as ended at all. |
| 318 | const RUN_ID = 'run-1-bash'; |
| 319 | const spec = { |
| 320 | t: 'exec', id: RUN_ID, |
| 321 | argv: ['bash', '-c', 'sleep 300 </dev/null >/dev/null 2>&1 & echo LEFT=$!'], |
| 322 | cwd: GRANT, env: [], stdin: null, timeout_ms: 30000, capture: 'both', |
| 323 | fence: { rw: [GRANT], ro: [], deny: [], net: false }, toolkits: [], |
| 324 | }; |
| 325 | const ran = JSON.parse(await page.evaluate((s) => window.__run(s), spec)); |
| 326 | const leftPid = Number((/LEFT=(\d+)/.exec(ran.stdout || '') || [])[1] || 0); |
| 327 | check('a command that backgrounds a process runs and ends', |
| 328 | ran.exit === 0 && leftPid > 0, JSON.stringify(ran).slice(0, 300)); |
| 329 | check('and the daimon is TOLD the run left something standing', |
| 330 | /standing|still running|left/i.test(String(ran.note || '')), |
| 331 | JSON.stringify(ran.note || '(no note)').slice(0, 300)); |
| 332 | // The kernel, not the listing: at this point the run is over and the process it |
| 333 | // left is alive, which is the whole shape of the leak. |
| 334 | check('and the process it left is alive on the machine', alive(leftPid), `pid ${leftPid}`); |
| 335 | |
| 336 | // The hand looks at a group after the command ends, so the listing is asked for |
| 337 | // a moment later rather than in the same breath. |
| 338 | await sleep(600); |
| 339 | const standing = await page.evaluate(() => window.__runs()); |
| 340 | const rows = (() => { try { return JSON.parse(standing).runs || []; } catch (e) { return []; } })(); |
| 341 | const row = rows.find((r) => r && r.id === RUN_ID); |
| 342 | check('a background process is listed as standing, under the run\'s own identifier', |
| 343 | !!row && row.state === 'standing', String(standing).slice(0, 400)); |
| 344 | check('and the listing names the command line it came from', |
| 345 | !!row && /sleep 300/.test(String(row.what || '')), row ? String(row.what) : '(no row)'); |
| 346 | |
| 347 | // ── 3. AND IT CAN BE STOPPED, BY THAT IDENTIFIER ──────────────────── |
| 348 | const sent = await page.evaluate((id) => window.__signal(id, 'term'), RUN_ID); |
| 349 | check('the signal is accepted by the identifier the run was given', sent === 'sent', String(sent)); |
| 350 | await sleep(800); |
| 351 | const after = await page.evaluate(() => window.__runs()); |
| 352 | const left = (() => { try { return (JSON.parse(after).runs || []); } catch (e) { return [{ id: 'unreadable' }]; } })(); |
| 353 | check('the run is gone from the listing after it is stopped', |
| 354 | !left.some((r) => r && r.id === RUN_ID), String(after).slice(0, 400)); |
| 355 | // And the oracle again, which is the half that cannot be faked by bookkeeping. |
| 356 | check('AND THE PROCESS IS GONE FROM THE MACHINE', !alive(leftPid), `pid ${leftPid}`); |
| 357 | |
| 358 | // ── 4. AND ONLY WHAT THIS HAND STARTED CAN BE NAMED ───────────────── |
| 359 | // |
| 360 | // Not a check on the argument: the fence is that the argument cannot express |
| 361 | // anything else. A pid is not an identifier this hand ever issued, so naming one |
| 362 | // reaches nothing — and a `sleep` started by THIS PROCESS, which the hand never |
| 363 | // launched, is untouched by it. |
| 364 | const mine = spawnSync('bash', ['-c', 'setsid sleep 120 >/dev/null 2>&1 & echo $!'], { encoding: 'utf8' }); |
| 365 | const minePid = Number((mine.stdout || '').trim()); |
| 366 | await sleep(200); |
| 367 | if (alive(minePid)) { |
| 368 | await page.evaluate((p) => window.__signal(String(p), 'kill'), minePid); |
| 369 | await sleep(600); |
| 370 | check('a pid is not a name this hand answers to, so a process it did not start survives', |
| 371 | alive(minePid), `pid ${minePid}`); |
| 372 | spawnSync('kill', ['-9', String(minePid)]); |
| 373 | } else { |
| 374 | check('a pid is not a name this hand answers to', false, 'the fixture process never started'); |
| 375 | } |
| 376 | |
| 377 | // ── The verdict ───────────────────────────────────────────────────── |
| 378 | await b.close(); |
| 379 | server.close(); |
| 380 | if (!KEEP) fs.rmSync(BASE, { recursive: true, force: true }); |
| 381 | |
| 382 | console.log(`\n${ok.length} ok, ${bad.length} failed`); |
| 383 | if (BREAK) { |
| 384 | const must = BREAKS[BREAK].must; |
| 385 | const missed = must.filter((m) => !bad.some((n) => n.startsWith(m))); |
| 386 | if (missed.length) { |
| 387 | console.log(`THE BREAK PROVED NOTHING about: ${missed.join('; ')}`); |
| 388 | process.exit(1); |
| 389 | } |
| 390 | console.log(`break '${BREAK}' reddened every check it names, and ${bad.length} in all`); |
| 391 | process.exit(0); |
| 392 | } |
| 393 | process.exit(bad.length ? 1 : 0); |