@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.
- package/dist/task/auto-orchestrator.js +2 -2
- package/dist/task/boot-probe.d.ts +298 -0
- package/dist/task/boot-probe.js +806 -0
- package/dist/task/child-runner.d.ts +17 -0
- package/dist/task/child-runner.js +6 -0
- package/dist/task/final-gate.d.ts +33 -281
- package/dist/task/final-gate.js +24 -834
- package/dist/task/launch-manifest.d.ts +5 -0
- package/dist/task/launch-manifest.js +21 -0
- package/dist/task/orchestrator.js +3 -3
- package/dist/task/phases.d.ts +18 -0
- package/dist/task/phases.js +4 -3
- package/dist/task/task-gates.d.ts +69 -0
- package/dist/task/task-gates.js +114 -90
- package/dist/workers/docs-core.d.ts +0 -4
- package/dist/workers/docs-core.js +10 -34
- package/dist/workers/docs-project.js +3 -3
- package/dist/workers/docs-resolve.d.ts +18 -0
- package/dist/workers/docs-resolve.js +39 -0
- package/dist/workers/docs-retrieve.d.ts +13 -0
- package/dist/workers/docs-retrieve.js +17 -2
- package/dist/workers/fetch-core.d.ts +0 -4
- package/dist/workers/fetch-core.js +2 -5
- package/dist/workers/phantom-imports.d.ts +3 -3
- package/dist/workers/phantom-imports.js +16 -29
- package/dist/workers/pi-worker-docs.d.ts +49 -0
- package/dist/workers/pi-worker-docs.js +33 -9
- package/dist/workers/pi-worker-fetch.d.ts +18 -0
- package/dist/workers/pi-worker-fetch.js +19 -4
- package/package.json +1 -1
|
@@ -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
|
+
}
|