oxedyne/daimond/dev/verify_worldports.mjs
19.4 KiB, 1 run
created by r2519314175:829, 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_worldports.mjs — two worlds at once, and neither one can answer for the |
| 2 | // other. |
| 3 | // |
| 4 | // WHAT THIS IS FOR. `dev/world.sh` numbers a world's app server and mock provider |
| 5 | // off the world number, and until 2026-08-25 it did not number the third thing a |
| 6 | // world reaches. Every world's `/api` was reverse-proxied to one fixed port, so |
| 7 | // which account state a verifier read was decided by which OTHER lane happened to |
| 8 | // be running something on it at that moment. `verify_attachfocus` and `verify_chatworkspace` went red |
| 9 | // four runs out of six on a tree nobody had changed; `dev/pro.mjs` posted a signed |
| 10 | // Pro licence event at a stranger's gateway; and `dev/reflux_brief.mjs` lost five |
| 11 | // consecutive runs to a foreign 403 that left the model list empty. Three separate |
| 12 | // symptoms, one cause, and each was answered where it was seen. |
| 13 | // |
| 14 | // A wrong answer with nothing saying it is wrong is the failure this file exists |
| 15 | // against, so what it asserts is not "the ports differ". It is: |
| 16 | // |
| 17 | // 1. Two worlds ask for two different ports, and no world asks for the one a |
| 18 | // hand-started server lands on when it was given no world at all. |
| 19 | // 2. A world that HAS a gateway reaches its own and reads its own answer. |
| 20 | // 3. A world that has NOT started one is REFUSED, in a sentence that names its |
| 21 | // own port, and never reaches the neighbour that does have one. |
| 22 | // 4. The refusal a caller reads says whose port it is, not merely that |
| 23 | // something is absent. |
| 24 | // 5. No two rows of the port register collide for any world 0..9. |
| 25 | // 6. AND `world.sh --up` HANDS THE PORT TO THE SERVER IT STARTS. This one is |
| 26 | // here because the fix shipped without it: the row was added, `--env` |
| 27 | // exported it, and the `node dev/serve.mjs` line still passed only |
| 28 | // DAIMOND_PORT -- so two worlds came up on 9711 and 9712 and both proxied to |
| 29 | // 9002, exactly as before. An env block is what a caller reads; it is not |
| 30 | // what the server was told. So this asks the server, through `/__world`. |
| 31 | // |
| 32 | // It starts its own stand-in on a port of its own. No gateway binary, no browser |
| 33 | // and no mock provider: about ten seconds, and nothing to build. |
| 34 | // |
| 35 | // EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST: |
| 36 | // |
| 37 | // node dev/verify_worldports.mjs --break register # 1 fails: one port for all worlds |
| 38 | // node dev/verify_worldports.mjs --break shared # 2,3 fail: one shared port |
| 39 | // node dev/verify_worldports.mjs --break quiet # 4 fails: the old sentence |
| 40 | // node dev/verify_worldports.mjs --break collide # 5 fails: a duplicated row |
| 41 | // node dev/verify_worldports.mjs --break handoff # 6 fails: --up drops the port |
| 42 | // node dev/verify_worldports.mjs # and then, clean |
| 43 | // |
| 44 | // The register rows are 8480 + 2N (two app servers) and 9480 + 2N (their two |
| 45 | // gateways); see dev/world.sh, which is where a port is claimed. Check 6 borrows |
| 46 | // a whole spare WORLD instead -- the first of 21..30 whose ports are free -- so |
| 47 | // that it drives the real `world.sh --up` rather than a second copy of it. |
| 48 | |
| 49 | import fs from 'node:fs'; |
| 50 | import net from 'node:net'; |
| 51 | import http from 'node:http'; |
| 52 | import path from 'node:path'; |
| 53 | import { spawn, spawnSync, execFileSync } from 'node:child_process'; |
| 54 | import { fileURLToPath } from 'node:url'; |
| 55 | |
| 56 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 57 | const ROOT = path.join(HERE, '..'); |
| 58 | const WORLD = Math.max(0, Number(process.env.DAIMOND_PORT || 8777) - 8777); |
| 59 | // Both spellings are in the tree -- `--break name` and `--break=name` -- and a |
| 60 | // parser that quietly took the next argv entry read node's own path as a break |
| 61 | // name on the very first run of this file. |
| 62 | const BREAK = (() => { |
| 63 | const eq = process.argv.find(a => a.startsWith('--break=')); |
| 64 | if (eq) return eq.slice(8); |
| 65 | const i = process.argv.indexOf('--break'); |
| 66 | if (i < 0) return ''; |
| 67 | const next = process.argv[i + 1]; |
| 68 | return next && !next.startsWith('-') ? next : ''; |
| 69 | })(); |
| 70 | |
| 71 | const APP_A = 8480 + 2 * WORLD, APP_B = 8481 + 2 * WORLD; |
| 72 | const GW_A = 9480 + 2 * WORLD, GW_B = 9481 + 2 * WORLD; |
| 73 | |
| 74 | // The stand-in's own word for itself. A 401 alone would not distinguish "the |
| 75 | // neighbour refused me" from "my own gateway refused me", and that ambiguity is |
| 76 | // the whole fault -- so the body carries a name only this process writes. |
| 77 | const MARK = `world-A-standin-${process.pid}`; |
| 78 | |
| 79 | const SCRATCH = process.env.DAIMOND_SCRATCH || path.join(process.env.HOME, '.cache/daimond'); |
| 80 | const OUT = path.join(SCRATCH, 'worldports'); |
| 81 | fs.mkdirSync(OUT, { recursive: true }); |
| 82 | |
| 83 | let ok = 0, bad = 0; |
| 84 | const check = (name, cond, detail = '') => { |
| 85 | if (cond) { ok++; console.log(` ok ${name}`); } |
| 86 | else { bad++; console.log(` FAIL ${name}${detail ? ' — ' + detail : ''}`); } |
| 87 | }; |
| 88 | |
| 89 | const free = (port) => new Promise((res) => { |
| 90 | const s = net.createServer(); |
| 91 | s.once('error', () => res(false)); |
| 92 | s.once('listening', () => s.close(() => res(true))); |
| 93 | s.listen(port, '127.0.0.1'); |
| 94 | }); |
| 95 | |
| 96 | const waitFor = async (fn, ms = 15000) => { |
| 97 | const end = Date.now() + ms; |
| 98 | for (;;) { |
| 99 | try { if (await fn()) return true; } catch (e) { /* not up yet */ } |
| 100 | if (Date.now() > end) return false; |
| 101 | await new Promise(r => setTimeout(r, 150)); |
| 102 | } |
| 103 | }; |
| 104 | |
| 105 | /// One world's environment, straight out of `dev/world.sh` rather than derived |
| 106 | /// here a second time -- deriving it twice is the staleness the register exists |
| 107 | /// against. |
| 108 | /// |
| 109 | /// The three variables cleared below are the ones `world.sh` HONOURS when a |
| 110 | /// caller has already set them, and clearing them is the whole point of this |
| 111 | /// helper. `dev/run_all.sh:62` exports `DAIMOND_GW_PORT` for the entire suite, |
| 112 | /// so inside a suite run every world answered with the suite's own port: checks |
| 113 | /// 1 and 2 read 9709 twice and could not have read anything else. Check 2 passed |
| 114 | /// on that -- 9709 is not 9002 -- which is worse than the failure beside it. A |
| 115 | /// caller that means to pre-empt the register passes the variable in `extra`, |
| 116 | /// where the check that asks for it can be read. |
| 117 | function worldEnv(n, extra = {}, reg = path.join(HERE, 'world.sh')) { |
| 118 | const env = { ...process.env, ...extra }; |
| 119 | for (const k of ['DAIMOND_GW_PORT', 'DAIMOND_IMAP_PORT', 'SMTPD_PORT']) { |
| 120 | if (!(k in extra)) delete env[k]; |
| 121 | } |
| 122 | const txt = execFileSync('bash', [reg, String(n), '--env'], |
| 123 | { encoding: 'utf8', env }); |
| 124 | const out = {}; |
| 125 | for (const line of txt.split('\n')) { |
| 126 | const m = /^export ([A-Z_0-9]+)=(.*)$/.exec(line.trim()); |
| 127 | if (m) out[m[1]] = m[2]; |
| 128 | } |
| 129 | return out; |
| 130 | } |
| 131 | |
| 132 | const procs = []; |
| 133 | let standin = null; |
| 134 | let spare = null; |
| 135 | |
| 136 | async function main() { |
| 137 | // ── 1. The register answers, and it answers differently per world ───── |
| 138 | // |
| 139 | // `--break register` is the fault this row is against: one gateway port for |
| 140 | // every world, written in the register rather than arrived at by accident. |
| 141 | let numbered = path.join(HERE, 'world.sh'); |
| 142 | if (BREAK === 'register') { |
| 143 | numbered = path.join(OUT, 'world-register.sh'); |
| 144 | const txt = fs.readFileSync(path.join(HERE, 'world.sh'), 'utf8'); |
| 145 | const damaged = txt.replace('GW_PORT=${DAIMOND_GW_PORT:-$((9700 + N))}', |
| 146 | 'GW_PORT=${DAIMOND_GW_PORT:-9700}'); |
| 147 | if (damaged === txt) { |
| 148 | console.log(' break register: the GW_PORT line did not match, so this run would ' |
| 149 | + 'prove nothing. Has world.sh\'s gateway row moved?'); |
| 150 | process.exit(2); |
| 151 | } |
| 152 | fs.writeFileSync(numbered, damaged); |
| 153 | } |
| 154 | const w3 = worldEnv(3, {}, numbered), w4 = worldEnv(4, {}, numbered); |
| 155 | check('two worlds are given two different gateway ports', |
| 156 | !!w3.DAIMOND_GW_PORT && w3.DAIMOND_GW_PORT !== w4.DAIMOND_GW_PORT, |
| 157 | `${w3.DAIMOND_GW_PORT} / ${w4.DAIMOND_GW_PORT}`); |
| 158 | |
| 159 | // 9002 is where a gateway started by hand with no world lands: it is the |
| 160 | // deployed `gateway/app.jdat` listen port and `dev/devgw.sh`'s own default. A |
| 161 | // world reaching it is a world reaching whatever got there first. |
| 162 | const nines = []; |
| 163 | for (let n = 0; n <= 12; n++) { |
| 164 | const p = worldEnv(n, {}, numbered).DAIMOND_GW_PORT; |
| 165 | if (String(p) === '9002') nines.push(n); |
| 166 | } |
| 167 | check('no world 0..12 is given :9002, the port a stray gateway lands on', |
| 168 | nines.length === 0, nines.length ? 'worlds ' + nines.join(', ') : ''); |
| 169 | |
| 170 | // A caller that has already chosen one has asked for a gateway and must keep it. |
| 171 | check('a caller that sets DAIMOND_GW_PORT itself keeps it', |
| 172 | worldEnv(3, { DAIMOND_GW_PORT: '9911' }).DAIMOND_GW_PORT === '9911', |
| 173 | worldEnv(3, { DAIMOND_GW_PORT: '9911' }).DAIMOND_GW_PORT); |
| 174 | |
| 175 | // ── 2. The register does not collide with itself ────────────────────── |
| 176 | // |
| 177 | // Read out of `dev/world.sh`'s own table rather than restated here. A second |
| 178 | // copy of the register would be a second thing to keep in step, which is the |
| 179 | // failure the table was written to end. |
| 180 | let regPath = path.join(HERE, 'world.sh'); |
| 181 | if (BREAK === 'collide') { |
| 182 | regPath = path.join(OUT, 'world-collide.sh'); |
| 183 | const txt = fs.readFileSync(path.join(HERE, 'world.sh'), 'utf8'); |
| 184 | fs.writeFileSync(regPath, txt.replace( |
| 185 | '# 9099 + N mock provider dev/world.sh', |
| 186 | '# 9099 + N mock provider dev/world.sh\n' |
| 187 | + '# 9100 + N a deliberately colliding row --break collide')); |
| 188 | } |
| 189 | const rows = []; |
| 190 | for (const line of fs.readFileSync(regPath, 'utf8').split('\n')) { |
| 191 | const m = /^#\s+(\d{4})(?:\s*\+\s*(\d*)N)?\s{2,}(\S.*?)\s*$/.exec(line); |
| 192 | if (!m) continue; |
| 193 | rows.push({ base: Number(m[1]), step: m[2] === undefined ? 0 : Number(m[2] || 1), |
| 194 | what: m[3].slice(0, 46) }); |
| 195 | } |
| 196 | check('the port register is machine-readable at all', rows.length >= 12, `${rows.length} row(s)`); |
| 197 | const seen = new Map(); const clashes = []; |
| 198 | for (const r of rows) { |
| 199 | for (let n = 0; n <= 9; n++) { |
| 200 | const p = r.base + r.step * n; |
| 201 | const prev = seen.get(p); |
| 202 | if (prev && prev !== r.what) clashes.push(`:${p} ${prev} / ${r.what}`); |
| 203 | else seen.set(p, r.what); |
| 204 | } |
| 205 | } |
| 206 | // 0..9 because that is the band of world numbers the register's own rows are |
| 207 | // written for -- `dev/gate.sh` defaults to 9 and the lanes take 0..9. Higher |
| 208 | // numbers are borrowed rather than assigned, and the borrower checks the ports |
| 209 | // are free before it takes one. |
| 210 | check('no two register rows claim one port for any world 0..9', |
| 211 | clashes.length === 0, clashes.slice(0, 3).join(' | ')); |
| 212 | |
| 213 | // ── 3. Two worlds, side by side ─────────────────────────────────────── |
| 214 | for (const [name, p] of [['A app', APP_A], ['B app', APP_B], ['A gw', GW_A], ['B gw', GW_B]]) { |
| 215 | if (!await free(p)) { |
| 216 | console.log(`SKIP verify_worldports — :${p} (${name}) is held by something else. ` |
| 217 | + 'This file needs four ports of its own; see the 8480 + 2N and 9480 + 2N rows ' |
| 218 | + 'in dev/world.sh, or run it in a world of its own.'); |
| 219 | process.exit(0); |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | // World A's gateway. Nothing else on this machine answers with MARK, so an |
| 224 | // answer carrying it is proof of WHICH gateway was reached, not merely that |
| 225 | // one was. |
| 226 | standin = http.createServer((req, res) => { |
| 227 | res.writeHead(401, { 'content-type': 'application/json' }); |
| 228 | res.end(JSON.stringify({ error: 'no session', standin: MARK, port: GW_A })); |
| 229 | }); |
| 230 | await new Promise(r => standin.listen(GW_A, '127.0.0.1', r)); |
| 231 | |
| 232 | // `--break quiet` runs a COPY of dev/serve.mjs whose refusal is the sentence |
| 233 | // this file replaced: true, useless, and naming a port the run was never on. |
| 234 | let serveJs = 'dev/serve.mjs'; |
| 235 | if (BREAK === 'quiet') { |
| 236 | const txt = fs.readFileSync(path.join(HERE, 'serve.mjs'), 'utf8'); |
| 237 | const damaged = txt.replace(/res\.end\(JSON\.stringify\(\{ error: `No gateway[\s\S]*?\}\)\);/, |
| 238 | "res.end(JSON.stringify({ error: 'The gateway is not running on :9002. " |
| 239 | + "Start it, or use the browser-only features.' }));"); |
| 240 | if (damaged === txt) { |
| 241 | console.log(' break quiet: the refusal anchor did not match, so this run would ' |
| 242 | + 'prove nothing. Has serve.mjs\'s 502 body moved?'); |
| 243 | process.exit(2); |
| 244 | } |
| 245 | const p = path.join(ROOT, 'dev/serve-quiet-break.mjs'); |
| 246 | fs.writeFileSync(p, damaged); |
| 247 | procs.push({ path: p }); |
| 248 | serveJs = 'dev/serve-quiet-break.mjs'; |
| 249 | } |
| 250 | |
| 251 | // `--break shared` is the tree as it stood: one gateway port for every world. |
| 252 | const gwForB = BREAK === 'shared' ? GW_A : GW_B; |
| 253 | for (const [port, gw, log] of [[APP_A, GW_A, 'a'], [APP_B, gwForB, 'b']]) { |
| 254 | const out = fs.openSync(path.join(OUT, `serve-${log}.log`), 'w'); |
| 255 | const rec = { proc: spawn('node', [serveJs], { |
| 256 | cwd: ROOT, stdio: ['ignore', out, out], |
| 257 | env: { ...process.env, DAIMOND_PORT: String(port), DAIMOND_GW_PORT: String(gw) }, |
| 258 | }) }; |
| 259 | // `exitCode` is null until node has reaped the child, so a teardown reading |
| 260 | // it 400 ms after SIGTERM reports every ordinary stop as a process that |
| 261 | // would not die. The event is the fact. |
| 262 | rec.proc.on('exit', () => { rec.gone = true; }); |
| 263 | procs.push(rec); |
| 264 | } |
| 265 | const up = await waitFor(async () => |
| 266 | (await fetch(`http://localhost:${APP_A}/index.html`)).status < 500 |
| 267 | && (await fetch(`http://localhost:${APP_B}/index.html`)).status < 500); |
| 268 | check('two dev servers are up at once', up, `:${APP_A} and :${APP_B}`); |
| 269 | |
| 270 | const ask = async (port) => { |
| 271 | const r = await fetch(`http://localhost:${port}/api/account`, { method: 'POST' }); |
| 272 | return { status: r.status, body: await r.text() }; |
| 273 | }; |
| 274 | const a = await ask(APP_A); |
| 275 | const b = await ask(APP_B); |
| 276 | |
| 277 | check('world A reaches the gateway world A started', |
| 278 | a.status === 401 && a.body.includes(MARK), `${a.status} ${a.body.slice(0, 90)}`); |
| 279 | |
| 280 | check('world B, which started none, is REFUSED rather than answered', |
| 281 | b.status === 502, `${b.status} ${b.body.slice(0, 90)}`); |
| 282 | |
| 283 | // THE CHECK THIS FILE IS FOR. Before 2026-08-25 this is the line that went the |
| 284 | // other way, silently, four runs out of six. |
| 285 | check('and world B never reaches world A\'s gateway', |
| 286 | !b.body.includes(MARK) && !b.body.includes(String(GW_A)), |
| 287 | `${b.status} ${b.body.slice(0, 120)}`); |
| 288 | |
| 289 | check('world B\'s refusal names world B\'s OWN port', |
| 290 | b.body.includes(String(GW_B)), b.body.slice(0, 120)); |
| 291 | |
| 292 | // The sentence a caller reads. A refusal that says only "not running" leaves a |
| 293 | // reader unable to tell an absent gateway from one they were never entitled to. |
| 294 | check('and says whose port it is, not merely that something is absent', |
| 295 | /this world's own/.test(b.body), b.body.slice(0, 160)); |
| 296 | |
| 297 | console.log(`\n the sentence a caller reads: ${JSON.parse(b.body).error}`); |
| 298 | |
| 299 | // ── 4. The real `world.sh --up`, asked what it actually started ─────── |
| 300 | // |
| 301 | // Everything above drives servers this file spawned itself with the right |
| 302 | // environment, which is why it could not have caught the handoff bug: the |
| 303 | // fault was in the one line this file was not exercising. So a whole spare |
| 304 | // world, brought up the way every lane brings one up. |
| 305 | let spareN = 21; |
| 306 | for (; spareN <= 30; spareN++) { |
| 307 | if (await free(8777 + spareN) && await free(9099 + spareN) && await free(9700 + spareN)) break; |
| 308 | } |
| 309 | if (spareN > 30) { |
| 310 | check('a spare world 21..30 was free for the handoff check', false, |
| 311 | 'every one of worlds 21..30 has a port held; nothing was measured'); |
| 312 | return; |
| 313 | } |
| 314 | let worldSh = path.join(HERE, 'world.sh'); |
| 315 | if (BREAK === 'handoff') { |
| 316 | // IN dev/, not in the scratch root: world.sh resolves the app root from its |
| 317 | // own location, so a copy anywhere else serves the wrong tree and refuses |
| 318 | // before it can demonstrate anything. Removed in teardown. |
| 319 | worldSh = path.join(HERE, 'world-handoff-break.sh'); |
| 320 | const txt = fs.readFileSync(path.join(HERE, 'world.sh'), 'utf8'); |
| 321 | const damaged = txt.replace('DAIMOND_PORT=$PORT DAIMOND_GW_PORT=$GW_PORT \\\n\t\t\texec node dev/serve.mjs', |
| 322 | 'DAIMOND_PORT=$PORT exec node dev/serve.mjs'); |
| 323 | if (damaged === txt) { |
| 324 | console.log(' break handoff: the --up anchor did not match, so this run would ' |
| 325 | + 'prove nothing. Has world.sh\'s serve.mjs line moved?'); |
| 326 | process.exit(2); |
| 327 | } |
| 328 | // The identity check would catch the damage before the browser could, which |
| 329 | // is the point of it -- but then nothing downstream runs, and the check |
| 330 | // being proved is the one below. So the copy keeps the fault and drops the |
| 331 | // guard, and the guard has a check of its own further down. |
| 332 | fs.writeFileSync(worldSh, damaged.replace('\tgot_gw=$(identify', '\tgot_gw=$GW_PORT #$(identify')); |
| 333 | procs.push({ path: worldSh }); |
| 334 | } |
| 335 | spare = { n: spareN, sh: worldSh }; |
| 336 | const upRes = spawnSync('bash', [worldSh, String(spareN), '--up'], { encoding: 'utf8' }); |
| 337 | if (upRes.status !== 0) { |
| 338 | check('world.sh --up brought a spare world up at all', false, |
| 339 | `world ${spareN}: ` + String(upRes.stderr || upRes.stdout || '').trim().split('\n').pop()); |
| 340 | return; |
| 341 | } |
| 342 | const upOut = upRes.stdout; |
| 343 | const spareEnv = {}; |
| 344 | for (const line of upOut.split('\n')) { |
| 345 | const m = /^export ([A-Z_0-9]+)=(.*)$/.exec(line.trim()); |
| 346 | if (m) spareEnv[m[1]] = m[2]; |
| 347 | } |
| 348 | const world = await (await fetch(`http://localhost:${spareEnv.DAIMOND_PORT}/__world`)).json(); |
| 349 | check('world.sh --up hands the gateway port to the server it starts', |
| 350 | String(world.gateway) === String(spareEnv.DAIMOND_GW_PORT), |
| 351 | `world ${spareN}: the server proxies /api to :${world.gateway}, ` |
| 352 | + `the env block says :${spareEnv.DAIMOND_GW_PORT}`); |
| 353 | |
| 354 | // And the guard that would have caught it. `--break handoff` disables this one |
| 355 | // deliberately, so it is asserted on the REAL script, where the fault is absent |
| 356 | // and the guard must therefore be present. |
| 357 | check('and world.sh asks the server where it proxies rather than assuming', |
| 358 | /got_gw=\$\(identify/.test(fs.readFileSync(path.join(HERE, 'world.sh'), 'utf8')), |
| 359 | 'verify_identity does not read /__world\'s gateway field'); |
| 360 | } |
| 361 | |
| 362 | // STOP WHAT WAS STARTED, AND SAY WHEN A KILL FAILED. An orphan holding a port for |
| 363 | // hours is how this whole class of fault gets a second afternoon; a teardown that |
| 364 | // swallows its own failure is how nobody finds out. |
| 365 | async function teardown() { |
| 366 | // The world FIRST, and through the real `dev/world.sh` rather than through the |
| 367 | // copy a break may have made: `--down` reads pid files under the world's own |
| 368 | // scratch, which both copies compute identically, and the copy is about to be |
| 369 | // deleted. Deleting it first left world 21 running once. |
| 370 | if (spare) { |
| 371 | // `--down` promises not to report success while a port is held, and its |
| 372 | // exit status is the promise. Swallowing it is how a world outlives the run |
| 373 | // that started it. |
| 374 | const r = spawnSync('bash', [path.join(HERE, 'world.sh'), String(spare.n), '--down'], |
| 375 | { encoding: 'utf8' }); |
| 376 | if (r.status !== 0) { |
| 377 | console.log(` note world ${spare.n} did NOT shut down cleanly: ` |
| 378 | + String(r.stderr || r.stdout || '').trim()); |
| 379 | } |
| 380 | } |
| 381 | for (const p of procs) { |
| 382 | if (p.path) { try { fs.unlinkSync(p.path); } catch (e) { |
| 383 | console.log(` note could not remove ${p.path}: ${e.message}`); } continue; } |
| 384 | if (!p.proc || p.gone) continue; |
| 385 | try { p.proc.kill('SIGTERM'); } catch (e) { |
| 386 | console.log(` note could not signal pid ${p.proc.pid}: ${e.message}`); |
| 387 | } |
| 388 | } |
| 389 | if (standin) await new Promise(r => standin.close(r)); |
| 390 | await new Promise(r => setTimeout(r, 400)); |
| 391 | for (const p of procs) { |
| 392 | if (!p.proc || p.gone) continue; |
| 393 | try { p.proc.kill('SIGKILL'); } catch (e) { /* already gone */ } |
| 394 | console.log(` note pid ${p.proc.pid} did not stop on SIGTERM and was killed`); |
| 395 | } |
| 396 | const held = []; |
| 397 | for (const port of [APP_A, APP_B, GW_A, GW_B]) if (!await free(port)) held.push(port); |
| 398 | if (held.length) { |
| 399 | console.log(` note STILL HELD after teardown: ${held.map(p => ':' + p).join(' ')}. ` |
| 400 | + `Find the owner with ss -ltnp | grep -E ':(${held.join('|')}) '`); |
| 401 | } |
| 402 | } |
| 403 | |
| 404 | try { |
| 405 | await main(); |
| 406 | } catch (e) { |
| 407 | check('the run completed', false, String((e && e.stack) || e)); |
| 408 | } finally { |
| 409 | await teardown(); |
| 410 | } |
| 411 | |
| 412 | console.log(`\n${ok} ok, ${bad} failed`); |
| 413 | if (BREAK) { |
| 414 | console.log(bad ? `break '${BREAK}' correctly failed ${bad} check(s)` |
| 415 | : `break '${BREAK}': NOTHING FAILED, so the checks above prove nothing`); |
| 416 | process.exit(bad ? 0 : 1); // a break MUST fail something |
| 417 | } |
| 418 | process.exit(bad ? 1 : 0); |