@mjasnikovs/pi-task 0.38.10 → 0.38.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,806 @@
1
+ /**
2
+ * boot-probe — does the assembled product actually START, and does the page it
3
+ * serves actually render?
4
+ *
5
+ * Lifted out of final-gate.ts, where it was 42% of a 2185-line file and the largest
6
+ * of seven unrelated concerns. Nothing inside `src/` imported any of it except the
7
+ * one call site in `runFinalIntegrationGate`; the de-facto module boundary already
8
+ * existed in the CONSUMERS — seven validation harnesses under `scripts/` import
9
+ * exactly this surface and nothing else from the gate.
10
+ *
11
+ * The public surface is those six names: `discoverBootCommand`, `detectsServedApp`,
12
+ * `runBootCheck`, `bootSkipVerdict`, `nonLaunchScriptReason` and `BootDeps`. The
13
+ * listener parsers stay exported because their tests are worth keeping, but they are
14
+ * now a sibling module's surface rather than noise in the gate's. Everything else —
15
+ * orphan-port recovery, pgid probing, the HTTP evidence probe, port reservation —
16
+ * is private, which it could not be while it shared a file with the gate.
17
+ *
18
+ * The boot check is deliberately NOT a CLOSURE_SCANS row: it is an async, stateful,
19
+ * port-binding exercise, and every row would need its own escape hatch. This is a
20
+ * file move, not a re-shaping.
21
+ */
22
+ import { spawn, spawnSync } from 'node:child_process';
23
+ import { existsSync, readFileSync } from 'node:fs';
24
+ import * as net from 'node:net';
25
+ import * as path from 'node:path';
26
+ import { resolveRunner, runnerEnv, isCommandNotFound } from './runner-resolve.js';
27
+ import { outputTail } from './command-run.js';
28
+ import { packageScripts, makeHasTarget } from './launch-manifest.js';
29
+ import { collectProjectEnv, pinnedLocalPort } from './deep-render-check.js';
30
+ /** Leading `FOO=bar` env assignments and `sudo`/`exec` wrappers carry no verb. */
31
+ function commandTokens(member) {
32
+ const t = member.trim().split(/\s+/).filter(Boolean);
33
+ while (t.length > 0
34
+ && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t[0]) || /^(?:sudo|exec|env)$/.test(t[0]))) {
35
+ t.shift();
36
+ }
37
+ return t;
38
+ }
39
+ /** The chain members of a shell script body, in order (`&&`, `||`, `;`, `|`). */
40
+ function chainMembers(body) {
41
+ return body
42
+ .split(/&&|\|\||;|\|/)
43
+ .map(s => s.trim())
44
+ .filter(s => s.length > 0);
45
+ }
46
+ /** Container/infra orchestration: `docker compose … up`, `docker-compose … up -d`,
47
+ * `podman-compose … up`, `docker run …`. The verb must be a bare token, so a
48
+ * filename like `docker-compose.dev.yml` never counts as one. */
49
+ function isContainerOrchestration(member) {
50
+ const t = commandTokens(member);
51
+ if (t.length === 0)
52
+ return false;
53
+ const bin = path.posix.basename(t[0]);
54
+ if (!/^(?:docker|podman|nerdctl)(?:-compose)?$/.test(bin))
55
+ return false;
56
+ const verbs = new Set(['up', 'start', 'run']);
57
+ return t.slice(1).some(tok => verbs.has(tok));
58
+ }
59
+ const MULTIPLEXER_RE = /^(?:concurrently|npm-run-all|run-p|run-s|turbo)$/;
60
+ /** A watcher that recompiles ASSETS and never listens: the tool is a
61
+ * bundler/compiler/preprocessor AND it is in watch mode. `bun run --watch x.ts`
62
+ * is deliberately NOT here — that re-executes an entrypoint, which may serve. */
63
+ const ASSET_TOOL_RE = /(?:^|[\s/@])(?:tailwindcss|postcss|sass|node-sass|less|stylus|esbuild|rollup|webpack|parcel|swc|babel|tsc|tsup|chokidar)(?:$|[\s"'])/;
64
+ const WATCH_FLAG_RE = /(?:^|\s)(?:--watch|-w|--watch=[^\s]*)(?:\s|$)/;
65
+ /** The quoted commands a multiplexer runs, or its bare script-name arguments
66
+ * resolved through the manifest (`run-p dev:css dev:js`). One level only. */
67
+ function multiplexerChildren(member, scripts) {
68
+ const quoted = [...member.matchAll(/"([^"]+)"|'([^']+)'/g)].map(m => m[1] ?? m[2]);
69
+ if (quoted.length > 0)
70
+ return quoted;
71
+ const t = commandTokens(member)
72
+ .slice(1)
73
+ .filter(a => !a.startsWith('-'));
74
+ return t.flatMap(name => (scripts[name] !== undefined ? [scripts[name]] : []));
75
+ }
76
+ /** Every member of the chain that could plausibly stay up and serve. Members that
77
+ * are one-shot setup (`mkdir`, `sleep`, an `until … done` wait loop) are not
78
+ * themselves launches, but they are not disqualifying either — only the two
79
+ * shapes below are. */
80
+ function isWatcherOnlyMultiplexer(member, scripts) {
81
+ const t = commandTokens(member);
82
+ if (t.length === 0)
83
+ return false;
84
+ const bin = path.posix.basename(t[0]);
85
+ const runner = /^(?:npx|bunx|pnpm|yarn|npm)$/.test(bin);
86
+ const head = runner ?
87
+ (t.slice(1).find(a => !a.startsWith('-') && a !== 'exec' && a !== 'dlx' && a !== 'run')
88
+ ?? '')
89
+ : bin;
90
+ if (!MULTIPLEXER_RE.test(path.posix.basename(head)))
91
+ return false;
92
+ const children = multiplexerChildren(member, scripts);
93
+ if (children.length === 0)
94
+ return false;
95
+ // Every child is an ASSET watcher ⇒ nothing in here ever listens.
96
+ return children.every(c => ASSET_TOOL_RE.test(c) && WATCH_FLAG_RE.test(c));
97
+ }
98
+ /**
99
+ * Why this script is NOT a launch of the shipped app, or null when it plausibly
100
+ * is one (mx5 run 18, validated).
101
+ *
102
+ * Run 18's boot command resolved to `bun run dev`, whose body is
103
+ * `docker compose -f docker-compose.dev.yml up -d && until docker compose … pg_isready
104
+ * … && concurrently "bun run dev:css" "bun run dev:js" "bun run --watch
105
+ * src/server/index.ts"`. The gate sandbox has no docker, so the chain died at 127 and
106
+ * the boot SKIPPED as an environment gap — while the shipped app had no HTTP listener
107
+ * at all. A script whose first act is `docker compose up` cannot distinguish "the app
108
+ * is broken" from "this box has no docker", so it is not evidence either way: better
109
+ * to discover NO boot command — reported as "nothing to boot" — and let the static
110
+ * serve-entry check (serve-entry.ts) carry the signal, than to spend the grace window
111
+ * producing an unfalsifiable skip.
112
+ *
113
+ * CONSERVATIVE AND LEXICAL BY CONSTRUCTION. Only two shapes are rejected, both
114
+ * decidable from the script text alone:
115
+ * 1. the chain OPENS with container orchestration (docker/podman/nerdctl … up|start|run);
116
+ * 2. the whole body is a multiplexer (concurrently/npm-run-all/run-p/run-s/turbo)
117
+ * whose every child is an ASSET watcher in watch mode (tailwind/tsc/esbuild/…),
118
+ * i.e. nothing in it can ever listen.
119
+ * Anything else — `vite`, `next dev`, `node dist/index.js`, `nodemon`, `bun --watch
120
+ * src/index.ts`, and any multiplexer with one non-asset child — is accepted
121
+ * unchanged. Deciding whether a watcher actually SERVES is not attempted here; that
122
+ * is exactly what the static serve-entry check is for.
123
+ */
124
+ export function nonLaunchScriptReason(body, scripts = {}) {
125
+ const members = chainMembers(body);
126
+ if (members.length === 0)
127
+ return null;
128
+ if (isContainerOrchestration(members[0])) {
129
+ return 'it opens with container orchestration, which starts infrastructure rather than the app';
130
+ }
131
+ if (members.every(m => isWatcherOnlyMultiplexer(m, scripts))) {
132
+ return 'its only long-running member multiplexes asset watchers, none of which serves';
133
+ }
134
+ return null;
135
+ }
136
+ /**
137
+ * The project's OWN launch command, if it declares one (package.json `start`,
138
+ * else `dev`; Makefile `run`). null means the project has nothing to boot —
139
+ * the boot check degrades to nothing-to-run.
140
+ *
141
+ * A script that is not a LAUNCH at all (nonLaunchScriptReason — mx5 run 18's
142
+ * `docker compose up` orchestrator) is rejected here and falls through to the
143
+ * next candidate, then to null. Discovering nothing is strictly better than
144
+ * discovering something unfalsifiable: an env-gap skip of an orchestration script
145
+ * says nothing about the app, and null is reported as "nothing to boot".
146
+ */
147
+ export function discoverBootCommand(cwd) {
148
+ if (existsSync(path.join(cwd, 'package.json'))) {
149
+ const s = packageScripts(cwd);
150
+ for (const name of ['start', 'dev']) {
151
+ if (s[name] && nonLaunchScriptReason(s[name], s) === null)
152
+ return ['bun', ['run', name]];
153
+ }
154
+ return null;
155
+ }
156
+ if (existsSync(path.join(cwd, 'Makefile')) && makeHasTarget(cwd, 'run')) {
157
+ return ['make', ['run']];
158
+ }
159
+ return null;
160
+ }
161
+ /**
162
+ * The launch script that EXISTS but was rejected as not-a-launch, if any. Without
163
+ * this the rejection would trade run 18's unfalsifiable skip for pure silence: no
164
+ * boot command means bootSkipVerdict has no label to name, and a project whose test
165
+ * suite ran still reports `observed > 0`, so unobservedVerdict stays quiet too. A
166
+ * served app whose only declared launch script cannot start it was not observed to
167
+ * run, and must say so.
168
+ */
169
+ export function rejectedLaunchScript(cwd) {
170
+ if (!existsSync(path.join(cwd, 'package.json')))
171
+ return null;
172
+ const s = packageScripts(cwd);
173
+ for (const name of ['start', 'dev']) {
174
+ if (!s[name])
175
+ continue;
176
+ const reason = nonLaunchScriptReason(s[name], s);
177
+ if (reason === null)
178
+ return null; // this one IS a launch — it was chosen
179
+ return { name, reason };
180
+ }
181
+ return null;
182
+ }
183
+ /** Recognise an "address already in use" bind failure across runtimes (Node
184
+ * EADDRINUSE, Bun "Is port N in use?", Go "address already in use", generic). */
185
+ function isAddressInUse(text) {
186
+ return /EADDRINUSE|address already in use|address in use|port \d+ (?:is |already )?in use/i.test(text);
187
+ }
188
+ /** Best-effort port number from a bind-failure message, for the diagnosis line. The
189
+ * digit run ends on any non-digit (a `(?!\d)` lookahead, NOT `\b`): runtimes often
190
+ * print ":3000" flush against the next token with no separating space/newline
191
+ * ("…:3000error: script exited"), where a trailing `\b` would never match. */
192
+ function extractPort(text) {
193
+ const m = /(?:port|:)\s*(\d{2,5})(?!\d)/i.exec(text) ?? /\baddress[^0-9]*(\d{2,5})(?!\d)/i.exec(text);
194
+ if (!m)
195
+ return null;
196
+ const n = Number(m[1]);
197
+ return n > 0 && n < 65536 ? n : null;
198
+ }
199
+ /** Stamped on a PASS the boot check could not actually observe, so the trail says
200
+ * so out loud instead of implying the listener requirement was met. */
201
+ const UNOBSERVED_LISTENER_NOTE = 'listener check UNOBSERVED: no socket-enumeration tool (ss/netstat/lsof) in this '
202
+ + 'environment and the app never answered on the port it was given — passed on the '
203
+ + 'survival rule (the process stayed up), NOT on observed serving';
204
+ /** Package deps that mean "this project stands up an HTTP server" — the deterministic
205
+ * proxy for "the plan/spec promised a served app". Bare framework names plus the
206
+ * scoped families whose presence implies a listener at runtime. */
207
+ function isServerFrameworkDep(name) {
208
+ return (/^(?:hono|express|fastify|koa|polka|restify|next|nuxt|http-server|serve|ws|socket\.io)$/.test(name) || /^@(?:hono|fastify|koa|nestjs|sveltejs|remix-run)\//.test(name));
209
+ }
210
+ /** Spec/plan phrasings that promise a listening server, for the text signal. */
211
+ const SERVE_TEXT_RE = /\b(?:https?\s+server|web\s+server|serves?\b|listen(?:s|ing)?\b|Bun\.serve|app\.listen|createServer|serve\s+(?:static|the)|\/api\/|endpoints?\b)/i;
212
+ /**
213
+ * Does the finished run stand up a listening HTTP server? Deterministic, from the
214
+ * built manifest (a server-framework dependency is the plan's own artifact) OR, when
215
+ * available, the plan/spec text. Used to decide whether the boot check must observe a
216
+ * LISTENER (served app) or may pass on mere survival / quick exit (CLI project).
217
+ */
218
+ export function detectsServedApp(cwd, planText) {
219
+ try {
220
+ const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
221
+ const all = { ...(j.dependencies ?? {}), ...(j.devDependencies ?? {}) };
222
+ if (Object.keys(all).some(isServerFrameworkDep))
223
+ return true;
224
+ }
225
+ catch {
226
+ // no/unreadable manifest → fall through to the text signal
227
+ }
228
+ return planText !== undefined && SERVE_TEXT_RE.test(planText);
229
+ }
230
+ /** `ss -tlnpH` rows → {pid, port}. Column 4 (0-based 3) is the local address; the
231
+ * port is its last `:`-suffixed number ("0.0.0.0:3000", "[::]:3000"). */
232
+ export function parseSsListeners(stdout) {
233
+ const out = [];
234
+ for (const line of stdout.split('\n')) {
235
+ const pm = /pid=(\d+)/.exec(line);
236
+ if (!pm)
237
+ continue;
238
+ const local = line.trim().split(/\s+/)[3] ?? '';
239
+ const portm = /:(\d+)$/.exec(local);
240
+ if (!portm)
241
+ continue;
242
+ out.push({ pid: Number(pm[1]), port: Number(portm[1]) });
243
+ }
244
+ return out;
245
+ }
246
+ /**
247
+ * `netstat -tlnp` rows → {pid, port} (mx5 run 14, validated: the agent-sandbox
248
+ * image ships NEITHER ss NOR lsof — only ps and netstat — so the served-app boot
249
+ * check could never observe a listener and failed unfalsifiably). The pid rides
250
+ * in the trailing "PID/Program name" column ("1234/bun"); rows the kernel will
251
+ * not attribute to us print "-" there and are skipped.
252
+ */
253
+ export function parseNetstatListeners(stdout) {
254
+ const out = [];
255
+ for (const line of stdout.split('\n')) {
256
+ if (!/^\s*tcp/i.test(line))
257
+ continue;
258
+ const cols = line.trim().split(/\s+/);
259
+ const local = cols[3] ?? '';
260
+ const portm = /:(\d+)$/.exec(local);
261
+ if (!portm)
262
+ continue;
263
+ const pidm = /^(\d+)\//.exec(cols[cols.length - 1] ?? '');
264
+ if (!pidm)
265
+ continue;
266
+ out.push({ pid: Number(pidm[1]), port: Number(portm[1]) });
267
+ }
268
+ return out;
269
+ }
270
+ /** `lsof -iTCP -sTCP:LISTEN -n -P` rows → {pid, port}. */
271
+ export function parseLsofListeners(stdout) {
272
+ const out = [];
273
+ for (const line of stdout.split('\n').slice(1)) {
274
+ const cols = line.trim().split(/\s+/);
275
+ const pid = Number(cols[1]);
276
+ const name = cols.find(c => /:\d+$/.test(c)) ?? '';
277
+ const portm = /:(\d+)$/.exec(name);
278
+ if (Number.isInteger(pid) && pid > 0 && portm) {
279
+ out.push({ pid, port: Number(portm[1]) });
280
+ }
281
+ }
282
+ return out;
283
+ }
284
+ /** The socket-enumeration tools we can attribute listeners with, in preference
285
+ * order: ss (richest), netstat (present where ss is not), lsof (BSD/macOS). */
286
+ const LISTENER_TOOLS = [
287
+ { bin: 'ss', args: ['-tlnpH'], parse: parseSsListeners },
288
+ { bin: 'netstat', args: ['-tlnp'], parse: parseNetstatListeners },
289
+ { bin: 'lsof', args: ['-iTCP', '-sTCP:LISTEN', '-n', '-P'], parse: parseLsofListeners }
290
+ ];
291
+ /** Listening TCP sockets as {pid, port} pairs (best-effort; ss, then netstat, then
292
+ * lsof). Empty on any failure — the caller then cannot attribute a listener to our
293
+ * group and the served-app check degrades to survival (never a false FAIL). */
294
+ function listeningSockets() {
295
+ for (const { bin, args, parse } of LISTENER_TOOLS) {
296
+ try {
297
+ const t = spawnSync(bin, args, { encoding: 'utf8', timeout: 4000 });
298
+ if (t.error || !t.stdout)
299
+ continue;
300
+ const rows = parse(t.stdout);
301
+ if (rows.length > 0)
302
+ return rows;
303
+ }
304
+ catch {
305
+ // tool missing/unusable — try the next one
306
+ }
307
+ }
308
+ return [];
309
+ }
310
+ /**
311
+ * Can ANY socket-enumeration tool run here at all? (mx5 run 14: the sandbox had
312
+ * none, so `groupHasListener` returned false forever and the boot check emitted
313
+ * "never opened a listening socket" no matter what the app did — an unfalsifiable
314
+ * FAIL that failed a run whose app demonstrably served.) This is a CAPABILITY
315
+ * question, deliberately separate from "did we see a listener": a tool that ran
316
+ * and found nothing is an observation; no tool at all is blindness, and blindness
317
+ * must degrade to the survival rule exactly like win32 — never a false FAIL on a
318
+ * platform we cannot probe.
319
+ *
320
+ * "Ran" = spawned without ENOENT and either exited 0 or printed something (lsof
321
+ * exits 1 on an empty match set; a netstat that rejects `-p` prints nothing).
322
+ * Memoised: the answer is a property of the box, not of the run.
323
+ */
324
+ let listenerToolCapability = null;
325
+ export function canEnumerateListeners() {
326
+ if (listenerToolCapability !== null)
327
+ return listenerToolCapability;
328
+ listenerToolCapability = LISTENER_TOOLS.some(({ bin, args }) => {
329
+ try {
330
+ const r = spawnSync(bin, args, { encoding: 'utf8', timeout: 4000 });
331
+ if (r.error)
332
+ return false;
333
+ return r.status === 0 || (r.stdout ?? '').trim().length > 0;
334
+ }
335
+ catch {
336
+ return false;
337
+ }
338
+ });
339
+ return listenerToolCapability;
340
+ }
341
+ /**
342
+ * A free TCP port on the loopback interface, or null if one cannot be reserved.
343
+ * The boot check hands this to the child as PORT so that a successful HTTP
344
+ * request to it is OWNERSHIP evidence: nobody else knows the number (mx5 runs
345
+ * 8/10/11 — orphaned servers from earlier checks answered curl on the
346
+ * conventional :3000 and passed checks the app had not earned).
347
+ */
348
+ export function pickFreePort() {
349
+ return new Promise(resolve => {
350
+ try {
351
+ const srv = net.createServer();
352
+ srv.once('error', () => resolve(null));
353
+ srv.listen(0, '127.0.0.1', () => {
354
+ const a = srv.address();
355
+ const port = typeof a === 'object' && a !== null ? a.port : null;
356
+ srv.close(() => resolve(port));
357
+ });
358
+ }
359
+ catch {
360
+ resolve(null);
361
+ }
362
+ });
363
+ }
364
+ /** Can we bind 127.0.0.1:`port` right now? (Free ⇒ the boot child can have it.) */
365
+ export function isPortFree(port) {
366
+ return new Promise(resolve => {
367
+ try {
368
+ const srv = net.createServer();
369
+ srv.once('error', () => resolve(false));
370
+ srv.listen(port, '127.0.0.1', () => srv.close(() => resolve(true)));
371
+ }
372
+ catch {
373
+ resolve(false);
374
+ }
375
+ });
376
+ }
377
+ /**
378
+ * The project's own declared local port, but only if nothing is holding it — the
379
+ * default `preferredPort` for the gate. A declared port that is BUSY falls back to
380
+ * a reserved one rather than colliding: a stranger's server on :3000 must never be
381
+ * mistaken for the app we just booted.
382
+ */
383
+ export async function preferredDeclaredPort(cwd) {
384
+ const port = pinnedLocalPort(collectProjectEnv(cwd));
385
+ if (port === null)
386
+ return null;
387
+ return (await isPortFree(port)) ? port : null;
388
+ }
389
+ /**
390
+ * Does anything answer HTTP on 127.0.0.1:`port`? Any response at all (404, 500 —
391
+ * a status is a listener) counts; only a connection error or timeout is a no.
392
+ * Runs in a throwaway child of our own runtime so it needs no curl on PATH and
393
+ * stays synchronous inside the boot poll.
394
+ */
395
+ function defaultHttpProbe(port) {
396
+ const script = `fetch('http://127.0.0.1:${port}/').then(()=>process.exit(0),()=>process.exit(1));`
397
+ + `setTimeout(()=>process.exit(1),2000)`;
398
+ try {
399
+ const r = spawnSync(process.execPath, ['-e', script], {
400
+ encoding: 'utf8',
401
+ timeout: 5000
402
+ });
403
+ return !r.error && r.status === 0;
404
+ }
405
+ catch {
406
+ return false;
407
+ }
408
+ }
409
+ /** Process-group id of `pid`, or null if it cannot be read. */
410
+ function pgidOf(pid) {
411
+ try {
412
+ const r = spawnSync('ps', ['-o', 'pgid=', '-p', String(pid)], {
413
+ encoding: 'utf8',
414
+ timeout: 4000
415
+ });
416
+ const n = Number((r.stdout ?? '').trim());
417
+ return Number.isInteger(n) && n > 0 ? n : null;
418
+ }
419
+ catch {
420
+ return null;
421
+ }
422
+ }
423
+ /** Default listener probe: any LISTENing socket owned by a pid in process group
424
+ * `pgid` (the detached boot child IS its own group leader, so pgid === child.pid). */
425
+ function defaultGroupHasListener(pgid) {
426
+ for (const { pid } of listeningSockets()) {
427
+ if (pgidOf(pid) === pgid)
428
+ return true;
429
+ }
430
+ return false;
431
+ }
432
+ /** Default port lookup for the render check: the LOWEST port among the group's
433
+ * listeners (a dev toolchain may open an HMR socket too; the app's own server
434
+ * conventionally sits on the lower, configured port). Null when undeterminable. */
435
+ function defaultGroupListeningPort(pgid) {
436
+ const ports = listeningSockets()
437
+ .filter(({ pid }) => pgidOf(pid) === pgid)
438
+ .map(({ port }) => port);
439
+ return ports.length > 0 ? Math.min(...ports) : null;
440
+ }
441
+ /** Default port-holder lookup: `lsof` first, then `ss`/`fuser`. Returns null on any
442
+ * failure (the diagnosis then omits the pid — never blocks). */
443
+ export function defaultFindPortHolder(port) {
444
+ try {
445
+ const t = spawnSync('lsof', ['-i', `:${port}`, '-sTCP:LISTEN', '-t', '-P', '-n'], {
446
+ encoding: 'utf8',
447
+ timeout: 4000
448
+ });
449
+ const pid = Number((t.stdout ?? '').split('\n')[0]?.trim());
450
+ if (!Number.isInteger(pid) || pid <= 0)
451
+ return null;
452
+ const ps = spawnSync('ps', ['-o', 'args=', '-p', String(pid)], {
453
+ encoding: 'utf8',
454
+ timeout: 4000
455
+ });
456
+ return { pid, command: (ps.stdout ?? '').trim() || `pid ${pid}` };
457
+ }
458
+ catch {
459
+ return null;
460
+ }
461
+ }
462
+ function defaultReap(pid) {
463
+ try {
464
+ process.kill(pid, 'SIGTERM');
465
+ setTimeout(() => {
466
+ try {
467
+ process.kill(pid, 'SIGKILL');
468
+ }
469
+ catch {
470
+ // already gone
471
+ }
472
+ }, 1_000).unref();
473
+ return true;
474
+ }
475
+ catch {
476
+ return false;
477
+ }
478
+ }
479
+ /** Does the port holder look like one of OUR gate children (a `dev`/`start` run of
480
+ * the discovered boot command)? Only then do we reap it — never a foreign process
481
+ * the user happens to be running. */
482
+ function holderIsOurs(command, boot) {
483
+ const script = boot[1][boot[1].length - 1] ?? ''; // 'start' | 'dev' | 'run'
484
+ const c = command.toLowerCase();
485
+ return ((c.includes('bun') || c.includes('node') || c.includes('npm') || c.includes('make'))
486
+ && (c.includes(` ${script}`) || c.endsWith(script)));
487
+ }
488
+ /**
489
+ * Exercise the start command ONCE. For a CLI project (`expectServer` false) the
490
+ * command's own fate within the grace window decides:
491
+ *
492
+ * - non-zero exit (or signal death) before the window closes → FAIL, output tail;
493
+ * - exit 0 before the window closes → PASS (a CLI-style "run" that finished);
494
+ * - still alive when the window closes → PASS, then the whole process group is
495
+ * killed (detached spawn = own group; SIGTERM, escalating to SIGKILL).
496
+ *
497
+ * For a SERVED app (`expectServer` true — the spec/plan promised an HTTP server) mere
498
+ * survival is not enough: a watcher (`dev` = tailwind/bundler --watch) stays alive
499
+ * forever without ever listening, and a type-only entrypoint exits 0 in <1s having
500
+ * served nothing (mx5 run 10 — both were blessed by the survival rule). The boot then
501
+ * PASSes only once a LISTENing socket owned by our process group is observed; if the
502
+ * command exits, or the grace window closes, with no listener ever seen → FAIL naming
503
+ * that a listening server was expected.
504
+ *
505
+ * OBSERVABILITY is a precondition of that FAIL (mx5 run 14, validated). The listener
506
+ * requirement needs pgid-attributed socket enumeration; win32 has none, and neither
507
+ * does a Linux image shipping no ss/netstat/lsof — run 14's sandbox was exactly that,
508
+ * so the check emitted "never opened a listening socket" against an app that
509
+ * demonstrably served, three autofix passes could not falsify it, and the run was
510
+ * recorded failed. Two defences, in order:
511
+ *
512
+ * - the child is spawned with a freshly reserved, otherwise-unused PORT, and an
513
+ * HTTP answer on THAT port proves a listener regardless of tooling. The private
514
+ * port is what makes the HTTP probe trustworthy: an orphaned server from an
515
+ * earlier check answers on :3000, but nobody else knows this number.
516
+ * - if nothing can enumerate listeners AND the assigned port never answered, the
517
+ * served-app requirement is unobservable here, so `expectServer` collapses to
518
+ * the survival rule and the PASS is stamped UNOBSERVED. An app that ignores PORT
519
+ * is indistinguishable from one that never listened — an observer limitation,
520
+ * not an app defect, and it may not be reported as one.
521
+ *
522
+ * A child that EXITS non-zero still FAILs in every environment: "the process died"
523
+ * needs no socket probe, so run 14's original true positive (a `--hot` runtime
524
+ * pinning a crashed app) stays reportable wherever the tooling exists.
525
+ *
526
+ * Env-gap contract as everywhere: spawn error (ENOENT) or a command-not-found
527
+ * inside the chain (exit 127, or the runner's own wording where the platform
528
+ * reports it that way — see isCommandNotFound) → skip.
529
+ */
530
+ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}) {
531
+ const expectServer = (opts.expectServer ?? false) && process.platform !== 'win32';
532
+ const groupHasListener = opts.deps?.groupHasListener ?? defaultGroupHasListener;
533
+ const httpProbe = opts.deps?.httpProbe ?? defaultHttpProbe;
534
+ const canEnumerate = expectServer ? (opts.deps?.enumerationCapable ?? canEnumerateListeners)() : true;
535
+ // Only served apps get an assigned port: a CLI project has nothing to bind, and
536
+ // an unexpected PORT in its env is noise.
537
+ // The app's OWN declared local port wins when it is free (see pinnedLocalPort):
538
+ // a client whose base URL was baked in at build time calls that origin and no
539
+ // other, so serving it anywhere else makes the whole authenticated half
540
+ // unobservable. Anything else — no declaration, a port already held — falls back
541
+ // to the freshly reserved private port that run 14's ownership evidence needs.
542
+ const noPreference = () => Promise.resolve(null);
543
+ const preferred = expectServer ? await (opts.deps?.preferredPort ?? noPreference)() : null;
544
+ const assignedPort = preferred ?? (expectServer ? await (opts.deps?.pickPort ?? pickFreePort)() : null);
545
+ // Runner resolution (mx5 run 16): same contract as runGateCommand — resolve
546
+ // the runner and carry its directory on PATH so the boot script's own chain
547
+ // can re-invoke it.
548
+ const runner = resolveRunner(bin);
549
+ return new Promise(resolve => {
550
+ const child = spawn(runner.bin, args, {
551
+ cwd,
552
+ detached: true,
553
+ stdio: ['ignore', 'pipe', 'pipe'],
554
+ env: {
555
+ ...runnerEnv(runner),
556
+ ...(assignedPort !== null ? { PORT: String(assignedPort) } : {})
557
+ }
558
+ });
559
+ // Best-effort cleanup only: killGroup below can silently fail to reap the
560
+ // process (platform/sandbox-specific — observed on a GH Actions Linux
561
+ // runner where the group-kill did not take, hanging the whole `bun test
562
+ // --isolate` run on the leaked child's piped stdio). unref() so a child
563
+ // we already tried to kill can never itself keep this process alive.
564
+ child.unref();
565
+ let out = '';
566
+ let err = '';
567
+ let listenerSeen = false;
568
+ const cap = (s) => (s.length > 8000 ? s.slice(-8000) : s);
569
+ child.stdout?.on('data', (d) => (out = cap(out + String(d))));
570
+ child.stderr?.on('data', (d) => (err = cap(err + String(d))));
571
+ let settled = false;
572
+ const settle = (r) => {
573
+ if (settled)
574
+ return;
575
+ settled = true;
576
+ clearTimeout(timer);
577
+ if (poll)
578
+ clearInterval(poll);
579
+ resolve(r);
580
+ };
581
+ const killGroup = (sig) => {
582
+ try {
583
+ if (!child.pid)
584
+ return;
585
+ if (process.platform === 'win32') {
586
+ // Windows has no process groups / negative-pid kill. taskkill
587
+ // /T tears down the whole tree (the detached child plus any
588
+ // grandchildren it spawned); /F forces it, so the SIGTERM→
589
+ // SIGKILL escalation collapses to one idempotent call.
590
+ spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F']);
591
+ }
592
+ else {
593
+ process.kill(-child.pid, sig);
594
+ }
595
+ }
596
+ catch {
597
+ // group already gone
598
+ }
599
+ };
600
+ const passAndKill = (renderNote) => {
601
+ settle(renderNote ? { outcome: 'pass', renderNote } : { outcome: 'pass' });
602
+ killGroup('SIGTERM');
603
+ setTimeout(() => killGroup('SIGKILL'), 2_000).unref();
604
+ };
605
+ const failAndKill = (detail) => {
606
+ settle({ outcome: 'fail', detail });
607
+ killGroup('SIGTERM');
608
+ setTimeout(() => killGroup('SIGKILL'), 2_000).unref();
609
+ };
610
+ // Served apps only: poll for a listening socket owned by our process group.
611
+ // As soon as one appears the boot has demonstrably served → run the render
612
+ // check against the LIVE listener (mx5 runs 8/11: a listener that serves a
613
+ // permanently blank page passed every curl-shaped check), then PASS/FAIL.
614
+ // The probe is spawnSync, so the interval cannot re-enter mid-check.
615
+ // The deep probe is asynchronous (it drives a browser session), so the
616
+ // interval body must not re-enter while one is in flight — a second session
617
+ // would race the first for the same still-booting child.
618
+ let probing = false;
619
+ const poll = expectServer ?
620
+ setInterval(() => {
621
+ if (settled || probing || !child.pid)
622
+ return;
623
+ // pgid attribution first (precise, cheap). If it saw nothing — or
624
+ // cannot see anything here — fall back to the private assigned
625
+ // port: an HTTP answer on a number only this child was told is
626
+ // proof of OUR listener, not of some orphan on :3000.
627
+ const byGroup = canEnumerate && groupHasListener(child.pid);
628
+ const byPort = !byGroup && assignedPort !== null && httpProbe(assignedPort);
629
+ if (!byGroup && !byPort)
630
+ return;
631
+ listenerSeen = true;
632
+ const probe = opts.deps?.renderProbe;
633
+ if (!probe)
634
+ return passAndKill();
635
+ const port = byGroup ?
636
+ (opts.deps?.groupListeningPort ?? defaultGroupListeningPort)(child.pid)
637
+ : assignedPort;
638
+ if (port === null) {
639
+ return passAndKill('render check UNOBSERVED: a listener was seen but its port could not be determined');
640
+ }
641
+ const url = `http://127.0.0.1:${port}/`;
642
+ const rr = probe(url);
643
+ if (rr.outcome === 'fail') {
644
+ return failAndKill(`listens on :${port} but ${rr.detail}`);
645
+ }
646
+ const deep = opts.deps?.deepRenderProbe;
647
+ if (rr.outcome !== 'pass' || !deep) {
648
+ return passAndKill(rr.outcome === 'skip' ?
649
+ `render check UNOBSERVED: ${rr.note}`
650
+ : undefined);
651
+ }
652
+ // The page renders. Now sign in and prove the AUTHENTICATED half
653
+ // is alive (mx5 run 17): the server accepted the login and the
654
+ // client never used it. Async, so the interval is held off by
655
+ // `probing` until this settles.
656
+ probing = true;
657
+ void Promise.resolve(deep(url)).then(dr => {
658
+ if (settled)
659
+ return;
660
+ if (dr.outcome === 'fail') {
661
+ return failAndKill(`listens on :${port} but ${dr.detail}`);
662
+ }
663
+ passAndKill(dr.outcome === 'skip' ?
664
+ `authenticated render check UNOBSERVED: ${dr.note}`
665
+ : undefined);
666
+ }, () => {
667
+ // The deep probe may never fail the gate on its own fault.
668
+ if (!settled)
669
+ passAndKill();
670
+ });
671
+ }, 500)
672
+ : null;
673
+ const onGrace = () => {
674
+ // A browser session in flight outlives the grace window by design (it
675
+ // signs in and waits for the app's data calls). Settling here would kill
676
+ // the server under it and discard its verdict, so the window re-arms
677
+ // until the probe resolves — which it always does, on its own hard
678
+ // timeout (DEEP_RENDER_TIMEOUT_MS).
679
+ if (probing) {
680
+ timer = setTimeout(onGrace, 500);
681
+ return;
682
+ }
683
+ if (expectServer && !listenerSeen) {
684
+ // Blind here (no enumeration tool, and the assigned port never
685
+ // answered) ⇒ we cannot tell "never listened" from "ignores PORT".
686
+ // Survival rule, stamped UNOBSERVED — an observer limitation is not
687
+ // an app defect (mx5 run 14).
688
+ if (!canEnumerate)
689
+ return passAndKill(UNOBSERVED_LISTENER_NOTE);
690
+ settle({
691
+ outcome: 'fail',
692
+ detail: `still running after ${graceMs}ms but never opened a listening socket — the spec/dependencies promise an HTTP server`
693
+ });
694
+ killGroup('SIGTERM');
695
+ setTimeout(() => killGroup('SIGKILL'), 2_000).unref();
696
+ return;
697
+ }
698
+ passAndKill();
699
+ };
700
+ let timer = setTimeout(onGrace, graceMs);
701
+ child.on('error', () => settle({ outcome: 'skip', spawnFailed: true }));
702
+ child.on('exit', (status, signal) => {
703
+ if (status === 0) {
704
+ if (expectServer && !listenerSeen) {
705
+ if (!canEnumerate) {
706
+ return settle({ outcome: 'pass', renderNote: UNOBSERVED_LISTENER_NOTE });
707
+ }
708
+ return settle({
709
+ outcome: 'fail',
710
+ detail: 'exited 0 without ever opening a listening socket — the spec/dependencies '
711
+ + 'promise an HTTP server, so a boot that serves nothing is not a launch'
712
+ });
713
+ }
714
+ return settle({ outcome: 'pass' });
715
+ }
716
+ // Command-not-found inside the boot chain — 127 on a posix shell, or
717
+ // the runner's own wording where it isn't (Windows bun exits 1). Either
718
+ // way the boot never RAN, so it is an environment gap, not an app fault.
719
+ if (isCommandNotFound(status, `${out}\n${err}`)
720
+ || (status === null && signal === null)) {
721
+ return settle({ outcome: 'skip' });
722
+ }
723
+ const what = status !== null ? `exited ${status}` : `was killed by ${signal}`;
724
+ const tail = outputTail(out, err);
725
+ // A bind collision is an environment condition, not an app defect — hand
726
+ // it back distinctly so the gate can reap our own orphan and retry rather
727
+ // than reporting the app "crashed" (mx5 run 9 item 3).
728
+ if (isAddressInUse(`${out}\n${err}`)) {
729
+ settle({
730
+ outcome: 'orphan-port',
731
+ port: extractPort(`${out}\n${err}`),
732
+ detail: `${what}${tail ? ` — ${tail}` : ''}`
733
+ });
734
+ return;
735
+ }
736
+ settle({ outcome: 'fail', detail: `${what}${tail ? ` — ${tail}` : ''}` });
737
+ });
738
+ });
739
+ }
740
+ /**
741
+ * The SAME third verdict, at the door unobservedVerdict cannot reach: the boot
742
+ * check specifically (mx5 run 18, validated).
743
+ *
744
+ * Run 18 shipped an app with no HTTP server behind a converged final gate. Its
745
+ * `src/server/index.ts` ends at `export {app}` — no `Bun.serve`, no
746
+ * `export default app`, no `start` script — so `bun run src/server/index.ts` exits
747
+ * 0 immediately and the product cannot be started at all. The gate's boot command
748
+ * resolved to `bun run dev`, whose body begins `docker compose … up -d`; the gate
749
+ * sandbox had no docker, so the boot SKIPPED as an environment gap. Skips
750
+ * contribute nothing to `dynObserved`, and `bun run test`, `test:ct`, `build`,
751
+ * `lint`, `seed` and `migrate` all ran — so `dynObserved > 0`, the full-skip
752
+ * blindness guard (observabilityGapFailure) stayed correctly quiet, and the trail
753
+ * read `final-gate: autofix converged — statics + … passed` with 24/24 tasks green.
754
+ *
755
+ * The defect is that "the app was never observed to boot" and "the app booted
756
+ * fine" produced BYTE-IDENTICAL gate output. That is the class scripts/ab-verdict.ts
757
+ * exists to kill one layer up: absence of evidence rendered in the shape of
758
+ * evidence. So a discovered-but-skipped boot now names itself, and — unlike every
759
+ * other skip — it CANNOT be cancelled by observations from other commands.
760
+ * Component tests are the trap here, not the alibi: run 18 had 51 green Playwright
761
+ * CT tests, and CT mounts components in a browser without ever assembling or
762
+ * starting the server.
763
+ *
764
+ * DECIDED, do not silently re-open:
765
+ * - NOT a FAIL. A boot skip on a docker-less box is a genuine environment gap, and
766
+ * failing it re-creates run 16's unfalsifiable-FAIL mistake pointing the other
767
+ * way. UNOBSERVED blocks nothing while being loud and durable (the caller records
768
+ * it as final-gate debt the next run re-surfaces), and it keeps "boot never ran"
769
+ * out of the autofix child's seed — a child cannot fix a missing docker, so the
770
+ * highest-probability response would be to FABRICATE a bootable command, the
771
+ * class that refuted the `## verified tooling` harvest.
772
+ * - BOTH skip flavours count. Run 18's skip carried `spawnFailed: false` (127 inside
773
+ * the script chain, not an ENOENT on the runner), so keying off spawnFailed would
774
+ * have missed the actual defect.
775
+ * - SERVED APPS ONLY. `expectServer === false` (a CLI/library project) is fenced off
776
+ * deliberately: a CLI whose `dev` script needs an absent tool has no server to be
777
+ * unobserved, and widening the lever there buys warnings nobody can act on.
778
+ */
779
+ export function bootSkipVerdict(args) {
780
+ if (args.label === null || !args.skipped || !args.expectServer)
781
+ return null;
782
+ // Deliberately short: the run-level trail slices the reason at 300 chars and this
783
+ // note leads it, so the command name always survives.
784
+ return (`boot check: \`${args.label}\` NEVER RAN (environment gap) — the app was not observed `
785
+ + 'to start, and no test suite substitutes for that.');
786
+ }
787
+ /**
788
+ * Boot check hit an address-in-use bind failure. If the port is held by one of OUR
789
+ * own orphaned gate children (a `dev`/`start` run), reap it and retry the boot once
790
+ * so the app gets a fair launch; otherwise leave the (foreign) holder alone and let
791
+ * the caller emit the harness diagnosis. Never reaps a process we cannot attribute
792
+ * to ourselves.
793
+ */
794
+ export async function recoverOrphanPort(cwd, boot, first, bootGraceMs, deps, expectServer) {
795
+ if (first.port === null)
796
+ return first;
797
+ const holder = (deps.findPortHolder ?? defaultFindPortHolder)(first.port);
798
+ if (!holder || !holderIsOurs(holder.command, boot))
799
+ return first;
800
+ const reaped = (deps.reap ?? defaultReap)(holder.pid);
801
+ if (!reaped)
802
+ return first;
803
+ // Give the OS a moment to release the socket, then re-run the boot once.
804
+ await new Promise(r => setTimeout(r, 1_500));
805
+ return runBootCheck(cwd, boot, bootGraceMs, { expectServer, deps });
806
+ }