staysfixed 0.8.0 → 0.9.1

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.
@@ -124,7 +124,11 @@ function tally(run) {
124
124
  missing: pictures.filter((p) => p.status === 'missing').length,
125
125
  broken: pictures.filter((p) => p.status === 'failed').length,
126
126
  wobbled: pictures.filter((p) => p.status === 'flaky').length,
127
- guardsFailed: guards.filter((g) => g.status === 'failed').length,
127
+ // Two different things wear the same status, and calling both of them "a bug is back"
128
+ // sends somebody hunting a regression that never happened. A guard that asked no
129
+ // question at all has not caught anything; it has admitted it cannot.
130
+ guardsFailed: guards.filter((g) => g.status === 'failed' && !(/** @type {any} */ (g).assertedNothing)).length,
131
+ guardsEmpty: guards.filter((g) => /** @type {any} */ (g).assertedNothing === true).length,
128
132
  };
129
133
  }
130
134
 
@@ -139,6 +143,20 @@ export function verdictFor(run) {
139
143
  const parts = [];
140
144
  if (t.guardsFailed === 1) parts.push({ n: 1, text: '1 guard failed — a bug that was already fixed is back.' });
141
145
  else if (t.guardsFailed > 1) parts.push({ n: t.guardsFailed, text: `${countText(t.guardsFailed)} guards failed — bugs that were already fixed are back.` });
146
+ // Said even on a green run, because that is the run it changes the meaning of.
147
+ const left = /** @type {any} */ (run).leftOut;
148
+ if (left && (left.screens > 0 || left.guards > 0)) {
149
+ const bits = [];
150
+ if (left.screens > 0) bits.push(`${left.screens} ${left.screens === 1 ? 'screen' : 'screens'}`);
151
+ if (left.guards > 0) bits.push(`${left.guards} ${left.guards === 1 ? 'guard' : 'guards'}`);
152
+ const how_many = (left.screens ?? 0) + (left.guards ?? 0);
153
+ parts.push({
154
+ n: 0,
155
+ text: `${bits.join(' and ')} ${how_many === 1 ? 'was' : 'were'} left out by --only, so this covers a slice and not the whole.`,
156
+ });
157
+ }
158
+ if (t.guardsEmpty === 1) parts.push({ n: 1, text: '1 guard checks nothing, so it is not protecting anything.' });
159
+ else if (t.guardsEmpty > 1) parts.push({ n: t.guardsEmpty, text: `${countText(t.guardsEmpty)} guards check nothing, so they are not protecting anything.` });
142
160
  if (t.changed === 1) parts.push({ n: 1, text: '1 thing changed. Look at it before you ship.' });
143
161
  else if (t.changed > 1) parts.push({ n: t.changed, text: `${countText(t.changed)} things changed. Look at them before you ship.` });
144
162
  if (t.fresh === 1) parts.push({ n: 1, text: '1 new screen is waiting for a person to approve it.' });
@@ -162,9 +180,12 @@ export function verdictFor(run) {
162
180
  * @param {import('../types.js').RunSummary} run
163
181
  * @returns {boolean}
164
182
  */
165
- function allClear(run) {
183
+ export function allClear(run) {
166
184
  const t = tally(run);
167
- return t.changed + t.fresh + t.missing + t.broken + t.wobbled + t.guardsFailed === 0;
185
+ // `guardsEmpty` counts too. Splitting it out of `guardsFailed` was so the SENTENCE could
186
+ // tell a returned bug from a guard that asks nothing — not so that one of them could
187
+ // quietly become a pass.
188
+ return t.changed + t.fresh + t.missing + t.broken + t.wobbled + t.guardsFailed + t.guardsEmpty === 0;
168
189
  }
169
190
 
170
191
  /**
@@ -561,6 +582,8 @@ export function printFlakes(history, flakeLimit = 2) {
561
582
  * @property {number} [markers] Known-good markers saved.
562
583
  * @property {{label: string, at?: string}|null} [lastMarker]
563
584
  * @property {import('../types.js').RunSummary|null} [lastRun]
585
+ * @property {{at: string, verdict: string, reference: string|null, findings: number}|null} [v2]
586
+ * What version 2 has recorded here. Version 1's counts say nothing about it.
564
587
  * @property {string[]} [condemned] Names from the flake register, if the CLI already has them.
565
588
  * @property {string} [configFile]
566
589
  * @property {string} [root]
@@ -591,7 +614,15 @@ export function printStatus(status) {
591
614
 
592
615
  blank();
593
616
  const run = s.lastRun ?? null;
594
- if (!run) {
617
+ if (!run && s.v2) {
618
+ // Version 2 has run here even though version 1's picture record has not. Saying
619
+ // "nothing has been checked here yet" one command after a real run is the sort of
620
+ // wrongness that costs a person their trust in everything else the tool says.
621
+ say(paint.grey(` last checked ${ago(s.v2.at)} — ${s.v2.verdict}`));
622
+ if (s.v2.findings > 0) say(paint.grey(` ${s.v2.findings} ${plural(s.v2.findings, 'thing', 'things')} nobody had accounted for`));
623
+ if (s.v2.reference) say(paint.grey(` compared against ${s.v2.reference}`));
624
+ else say(` Nothing is on record as working yet — run ${paint.cyan('staysfixed check')}, then ${paint.cyan('staysfixed ship')}.`);
625
+ } else if (!run) {
595
626
  say(' Nothing has been checked here yet.');
596
627
  say(` Start with: ${paint.cyan('staysfixed check')}`);
597
628
  } else {
package/src/run.js CHANGED
@@ -226,6 +226,17 @@ export async function runCheck(project, opts = {}) {
226
226
  tool: TOOL,
227
227
  platform: platformTag(),
228
228
  condemned: condemnedNames,
229
+ // What `--only` left out. A narrowed run that says "everything that worked still works"
230
+ // is describing a slice and sounding like the whole: measured 2026-08-30 with five of
231
+ // six guards filtered away and one of the five failing, and the run still exited 0
232
+ // saying everything works. A pass has to carry the size of what it looked at.
233
+ leftOut: terms
234
+ ? {
235
+ screens: Math.max(0, allScreens.length - screens.length),
236
+ guards: Math.max(0, allGuards.length - guards.length),
237
+ terms,
238
+ }
239
+ : undefined,
229
240
  // Read here rather than at the very end: what follows is writing files, and
230
241
  // where the run spent its time is a fact about the run, not about the report.
231
242
  timings: timings.get(),
package/src/types.js CHANGED
@@ -328,6 +328,9 @@
328
328
  * @property {string} tool
329
329
  * @property {string} platform
330
330
  * @property {string[]} [condemned] Names of checks that have flaked past the limit.
331
+ * @property {{screens: number, guards: number, terms: string[]}} [leftOut]
332
+ * What `--only` filtered away. A narrowed run that reads as a full pass is describing a
333
+ * slice and sounding like the whole.
331
334
  */
332
335
 
333
336
  /**
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Starting and stopping the product's own server.
3
+ *
4
+ * A start command is run through a shell, because that is what people write: `npm run dev`,
5
+ * `sh dev.sh`, `poetry run uvicorn ...`. So the thing that is spawned is the SHELL, and the
6
+ * server is its child — often its grandchild, since `npm run dev` is npm, which runs next,
7
+ * which runs node.
8
+ *
9
+ * Killing the shell therefore does not kill the server. And because the shell's stdout and
10
+ * stderr are pipes, every survivor inherits the writing end of them — so the pipes never
11
+ * close, this process's event loop never empties, and `staysfixed check` prints its whole
12
+ * answer and then hangs for ever at nothing per cent of a CPU. Measured on 2026-08-30 on a
13
+ * start command that spawns its server and waits, which is the shape `npm run dev` has: the
14
+ * verdict appeared in about thirty seconds and the command never returned.
15
+ *
16
+ * So the shell is started as its own process GROUP and the whole group is signalled. And
17
+ * after that, the pipes are torn down here rather than trusted to close, because a survivor
18
+ * this file did not start — a stray `node` somebody's dev server left behind — must not be
19
+ * able to hold a finished check open.
20
+ */
21
+
22
+ import { spawn } from 'node:child_process';
23
+
24
+ /**
25
+ * Start the product, in a group of its own.
26
+ *
27
+ * @param {string} command
28
+ * @param {{cwd: string, env: any, stdio?: any}} opts
29
+ * @returns {import('node:child_process').ChildProcess}
30
+ */
31
+ export function spawnServer(command, opts) {
32
+ return spawn(String(command), {
33
+ shell: true,
34
+ cwd: opts.cwd,
35
+ env: opts.env,
36
+ stdio: opts.stdio ?? ['ignore', 'pipe', 'pipe'],
37
+ // The whole point. On Windows there are no process groups of this kind, and killing the
38
+ // child is the best that can be done there.
39
+ detached: process.platform !== 'win32',
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Stop it, and everything it started.
45
+ *
46
+ * @param {import('node:child_process').ChildProcess|null|undefined} child
47
+ * @param {{graceMs?: number}} [opts]
48
+ * @returns {Promise<void>}
49
+ */
50
+ export async function stopServer(child, opts = {}) {
51
+ if (!child) return;
52
+ const pid = child.pid;
53
+ const graceMs = opts.graceMs ?? 500;
54
+
55
+ /** @param {NodeJS.Signals} signal */
56
+ const tellTheGroup = (signal) => {
57
+ if (!pid) return;
58
+ try {
59
+ // A negative pid is the GROUP. This is the line that makes the difference.
60
+ if (process.platform === 'win32') child.kill(signal);
61
+ else process.kill(-pid, signal);
62
+ } catch {
63
+ // No group, or already gone. Ask the one process we definitely know about.
64
+ try {
65
+ child.kill(signal);
66
+ } catch {
67
+ // Already gone, which is the outcome wanted.
68
+ }
69
+ }
70
+ };
71
+
72
+ if (child.exitCode === null && child.signalCode === null) {
73
+ tellTheGroup('SIGTERM');
74
+ await new Promise((done) => {
75
+ let settled = false;
76
+ const finish = () => {
77
+ if (settled) return;
78
+ settled = true;
79
+ done(undefined);
80
+ };
81
+ child.once('exit', finish);
82
+ const timer = setTimeout(finish, graceMs);
83
+ if (typeof timer.unref === 'function') timer.unref();
84
+ });
85
+ if (child.exitCode === null && child.signalCode === null) tellTheGroup('SIGKILL');
86
+ }
87
+
88
+ // And never let what it left behind hold this process open.
89
+ for (const stream of [child.stdout, child.stderr, child.stdin]) {
90
+ try {
91
+ stream?.destroy();
92
+ } catch {
93
+ // Nothing to close.
94
+ }
95
+ }
96
+ try {
97
+ child.unref();
98
+ } catch {
99
+ // Not every child can be unreferenced. It has been signalled either way.
100
+ }
101
+ }
@@ -31,7 +31,6 @@
31
31
  import fsp from 'node:fs/promises';
32
32
  import net from 'node:net';
33
33
  import path from 'node:path';
34
- import { spawn } from 'node:child_process';
35
34
  import {
36
35
  defineAdapter, joinPath, notCovered, observation, sizeBucket, stableValue,
37
36
  howLongItTook, timeBucket, trimForStorage, undoOurFootprint,
@@ -40,6 +39,7 @@ import {
40
39
  compareTrees, copyForScratch, frozenEnvironment, readWatcher, snapshotTree, watcherScript,
41
40
  } from './process.js';
42
41
  import { readContract, readFileRoutes } from './source.js';
42
+ import { spawnServer, stopServer } from './child.js';
43
43
 
44
44
  // ---------------------------------------------------------------------------
45
45
  // Headers
@@ -456,7 +456,7 @@ export const httpAdapter = defineAdapter({
456
456
  notes.push(verdict.why);
457
457
  } else {
458
458
  const result = await new Promise((resolve) => {
459
- const child = spawn(String(config.restore), { shell: true, cwd: work, env, stdio: ['ignore', 'pipe', 'pipe'] });
459
+ const child = spawnServer(String(config.restore), { cwd: work, env });
460
460
  /** @type {Buffer[]} */
461
461
  const err = [];
462
462
  child.stderr?.on('data', (c) => err.push(c));
@@ -483,7 +483,7 @@ export const httpAdapter = defineAdapter({
483
483
  /** @type {Buffer[]} */
484
484
  const bootOut = [];
485
485
  let exited = /** @type {string|null} */ (null);
486
- const child = spawn(String(config.start), { shell: true, cwd: work, env, stdio: ['ignore', 'pipe', 'pipe'] });
486
+ const child = spawnServer(String(config.start), { cwd: work, env });
487
487
  child.stdout?.on('data', (c) => bootOut.push(c));
488
488
  child.stderr?.on('data', (c) => bootErr.push(c));
489
489
  child.on('close', (code, signal) => {
@@ -496,11 +496,11 @@ export const httpAdapter = defineAdapter({
496
496
  });
497
497
 
498
498
  if (!up.up) {
499
- child.kill('SIGTERM');
499
+ await stopServer(child);
500
500
  return {
501
501
  build, root: work, ready: false,
502
502
  why: `${up.why} What it printed while trying: ${trimForStorage(Buffer.concat(bootErr).toString('utf8') || Buffer.concat(bootOut).toString('utf8'), 1500).text || '(nothing)'}`,
503
- dispose: async () => { child.kill('SIGKILL'); await fsp.rm(base, { recursive: true, force: true }); },
503
+ dispose: async () => { await stopServer(child); await fsp.rm(base, { recursive: true, force: true }); },
504
504
  };
505
505
  }
506
506
 
@@ -522,9 +522,7 @@ export const httpAdapter = defineAdapter({
522
522
  if (!held) return;
523
523
  // Only ever the process we started. Somebody else's server on this machine is
524
524
  // somebody else's business.
525
- held.child.kill('SIGTERM');
526
- await new Promise((r) => setTimeout(r, 500));
527
- if (held.child.exitCode === null) held.child.kill('SIGKILL');
525
+ await stopServer(held.child);
528
526
  await fsp.rm(base, { recursive: true, force: true });
529
527
  },
530
528
  };
@@ -612,7 +610,7 @@ export const httpAdapter = defineAdapter({
612
610
 
613
611
  async teardown() {
614
612
  for (const [, held] of running) {
615
- held.child.kill('SIGTERM');
613
+ await stopServer(held.child);
616
614
  }
617
615
  running.clear();
618
616
  },
@@ -180,10 +180,48 @@ export function watcherScript(opts) {
180
180
  " write('reached out', { host: host || 'somewhere it did not name', port: port ?? null });",
181
181
  " // Refused, not allowed through. Whatever this was going to do out there, it does not",
182
182
  " // do it twice, and the run is reported as having a hole rather than as having passed.",
183
- " const error = new Error('Stays Fixed refused a connection to ' + (host || 'an unnamed host') + ': nothing irreversible is allowed out during a check.');",
184
- " error.code = 'ECONNREFUSED';",
185
- " process.nextTick(() => this.emit('error', error));",
186
- " return this;",
183
+ " //",
184
+ " // HOW the refusal arrives matters as much as that it happens. Emitting 'error' on the",
185
+ " // socket ourselves reads correctly and kills the product: at that moment nothing is",
186
+ " // listening on the socket yet, and in Node an 'error' event with no listener is a",
187
+ " // thrown exception. `http.get` and `https.get` on Node 22 - the floor this package",
188
+ " // declares in its own engines field - and a bare `net.connect` on EVERY version all",
189
+ " // died that way, exit 1, and the run then reported the product as broken. A tool",
190
+ " // blaming a product for something the tool itself did is the exact failure this whole",
191
+ " // package exists to prevent. Measured 2026-08-30 against the published 0.8.0 watcher;",
192
+ " // its own CI had been red on this for four releases and nobody read it.",
193
+ " //",
194
+ " // So the refusal is made real rather than simulated: the socket is pointed at a port on",
195
+ " // this machine that nothing can be listening on, and the operating system produces the",
196
+ " // refusal through Node's own plumbing - by which time every listener the runtime wires",
197
+ " // up is in place. The product gets an ordinary ECONNREFUSED, which is exactly what it",
198
+ " // would get if the host were unreachable, and cannot tell the difference.",
199
+ " const named = host || 'an unnamed host';",
200
+ " const explain = 'Stays Fixed refused a connection to ' + named + ': nothing irreversible is allowed out during a check.';",
201
+ " const refusal = () => Object.assign(new Error(explain), { code: 'ECONNREFUSED', refusedBy: 'staysfixed' });",
202
+ " // Said in the error the product actually catches, without swallowing it: prepending a",
203
+ " // listener rewrites the message and still leaves every other handler to run as it would.",
204
+ " this.prependListener('error', (e) => {",
205
+ " if (e && e.code === 'ECONNREFUSED') { e.message = explain; e.refusedBy = 'staysfixed'; }",
206
+ " });",
207
+ " // Belt and braces. If something really is listening down there, the connection is cut",
208
+ " // before one byte can cross it: a boundary that fails open is not a boundary.",
209
+ " this.prependOnceListener('connect', () => { this.destroy(refusal()); });",
210
+ " // And a machine where that port is silently dropped rather than refused would hang",
211
+ " // here instead of failing, which is worse than the bug this replaced: a check that",
212
+ " // never finishes tells you nothing at all. A refusal is owed promptly, so if the",
213
+ " // operating system has not produced one shortly, produce it. Safe to do now, and only",
214
+ " // now, because the listener above means this can never be an unhandled error.",
215
+ " const soon = setTimeout(() => { if (!this.destroyed) this.destroy(refusal()); }, 250);",
216
+ " if (typeof soon.unref === 'function') soon.unref();",
217
+ " this.once('close', () => clearTimeout(soon));",
218
+ " try {",
219
+ " return connect.call(this, { port: 1, host: '127.0.0.1' });",
220
+ " } catch {",
221
+ " // Even the refusal failed. Still never throw into the product.",
222
+ " process.nextTick(() => { if (!this.destroyed) this.destroy(refusal()); });",
223
+ " return this;",
224
+ " }",
187
225
  " };",
188
226
  "} catch { /* no net module, nothing to refuse */ }",
189
227
  "",
@@ -1090,7 +1128,19 @@ export const processAdapter = defineAdapter({
1090
1128
  */
1091
1129
  export function importProbeCommand(moduleId) {
1092
1130
  const probe = [
1093
- "const m = await import(process.argv[1].startsWith('.') || process.argv[1].includes('/') ? new URL(process.argv[1], 'file://' + process.cwd() + '/').href : process.argv[1]);",
1131
+ // A FILE unless it is really a package. The old rule was "starts with a dot, or has a
1132
+ // slash in it" — and `index.js` has neither, so Node was asked for a PACKAGE called
1133
+ // "index.js" and answered ERR_MODULE_NOT_FOUND. `staysfixed init` writes exactly
1134
+ // `{ module: "index.js" }` for an ordinary package entry, so on those projects this
1135
+ // journey failed on every run, failed the SAME way on both builds, produced no
1136
+ // difference, and the check said "Nothing that worked has changed" for ever. Measured
1137
+ // 2026-08-30. So: if a file of that name is really there, it is a file.
1138
+ "const id = process.argv[1];",
1139
+ "const { existsSync } = await import('node:fs');",
1140
+ "const { fileURLToPath } = await import('node:url');",
1141
+ "const asFile = new URL(id, 'file://' + process.cwd() + '/').href;",
1142
+ "const onDisk = (() => { try { return existsSync(fileURLToPath(asFile)); } catch { return false; } })();",
1143
+ "const m = await import(id.startsWith('.') || id.startsWith('/') || id.includes('/') || onDisk ? asFile : id);",
1094
1144
  "const out = {};",
1095
1145
  "for (const key of Object.keys(m).sort()) {",
1096
1146
  " const v = m[key];",
@@ -174,6 +174,8 @@ export async function loadPlaywright(opts = {}) {
174
174
  // So: ask it. Only when there is no browser on the machine at all is this a real "no".
175
175
  /** @type {string|undefined} */
176
176
  let borrowedFrom;
177
+ /** True when the browser found is the one the PERSON uses, not a separate one. */
178
+ let borrowedTheirOwn = false;
177
179
  if (!there) {
178
180
  try {
179
181
  const { surveyBrowsers } = await import('../browsers.js');
@@ -181,6 +183,7 @@ export async function loadPlaywright(opts = {}) {
181
183
  if (survey.chosen?.binary && (await exists(survey.chosen.binary))) {
182
184
  executable = survey.chosen.binary;
183
185
  borrowedFrom = survey.chosen.name;
186
+ borrowedTheirOwn = survey.borrowingHis === true || survey.chosen.everyday === true;
184
187
  there = true;
185
188
  }
186
189
  } catch {
@@ -206,12 +209,35 @@ export async function loadPlaywright(opts = {}) {
206
209
  chromium: mod.chromium,
207
210
  version,
208
211
  executable,
209
- why: borrowedFrom
210
- ? `The browser driver ${version ?? ''} is here and it will open ${borrowedFrom}, which is a separate application from the browser you use, so pages can be opened.`.trim()
211
- : `The browser driver ${version ?? ''} is here and its Chromium is downloaded, so pages can be opened.`.trim(),
212
+ why: browserNote(version, borrowedFrom, borrowedTheirOwn),
212
213
  };
213
214
  }
214
215
 
216
+ /**
217
+ * What to say about the browser a check will open.
218
+ *
219
+ * "a separate application from the browser you use" used to be said whatever was found — and
220
+ * the one case where that sentence matters is the case where it is false. With no downloaded
221
+ * test browser anywhere, the survey falls back to the person's OWN browser, and this then
222
+ * told them the opposite of what was about to happen. A reassurance is only worth anything
223
+ * if it is withheld when it is not true.
224
+ *
225
+ * Exported so the wording is a test rather than a thing somebody has to notice.
226
+ *
227
+ * @param {string|undefined} version The driver version, if it said one.
228
+ * @param {string|undefined} borrowedFrom The browser found, if one had to be borrowed.
229
+ * @param {boolean} borrowedTheirOwn True when that browser is the person's own.
230
+ * @returns {string}
231
+ */
232
+ export function browserNote(version, borrowedFrom, borrowedTheirOwn) {
233
+ const v = version ?? '';
234
+ if (!borrowedFrom) return `The browser driver ${v} is here and its Chromium is downloaded, so pages can be opened.`.trim();
235
+ if (borrowedTheirOwn) {
236
+ return `The browser driver ${v} is here and the only browser on this machine is the one you use yourself (${borrowedFrom}). It will be opened invisibly with a throwaway profile, so your own settings, cookies and tabs are never touched — but it is your browser, not a separate one. \`npx playwright install chromium\` gives checks one of their own.`.trim();
237
+ }
238
+ return `The browser driver ${v} is here and it will open ${borrowedFrom}, which is a separate application from the browser you use, so pages can be opened.`.trim();
239
+ }
240
+
215
241
  /**
216
242
  * @param {string} file
217
243
  * @returns {Promise<boolean>}
@@ -1489,6 +1515,20 @@ export async function runStep(page, step, opts = {}) {
1489
1515
  break;
1490
1516
  }
1491
1517
  }
1518
+ // A step that did nothing at all is almost always a word this tool does not know, and
1519
+ // saying nothing about it is the worst outcome available: the journey walks on, the sign-in
1520
+ // never happens, every page behind the login wall photographs the login page, and the run
1521
+ // comes back clean. `staysfixed init` itself shipped `{ fill: '#email', with: 'a@b.c' }`
1522
+ // as its sign-in example, and neither word is in the vocabulary.
1523
+ if (did.length === 0) {
1524
+ const known = new Set([...ACTION_ORDER, 'text', 'timeoutMs', 'name', 'note', 'act', 'checkpoint', 'describe']);
1525
+ const unknown = Object.keys(step).filter((k) => !known.has(k));
1526
+ if (unknown.length > 0) {
1527
+ throw new Error(
1528
+ `This step does nothing: ${unknown.map((k) => `\`${k}\``).join(', ')} ${unknown.length === 1 ? 'is not a word' : 'are not words'} this tool knows, so the step was skipped and whatever it was meant to do did not happen. The steps it understands are: ${ACTION_ORDER.join(', ')} — with \`text\` beside \`type\`. To type into a field: { type: '#email', text: 'a@b.c' }.`,
1529
+ );
1530
+ }
1531
+ }
1492
1532
  return did;
1493
1533
  }
1494
1534
 
@@ -40,7 +40,6 @@ import crypto from 'node:crypto';
40
40
  import fs from 'node:fs';
41
41
  import fsp from 'node:fs/promises';
42
42
  import path from 'node:path';
43
- import { spawn } from 'node:child_process';
44
43
 
45
44
  import {
46
45
  countBucket, defineAdapter, howLongItTook, joinPath, notCovered, observation, sizeBucket,
@@ -50,6 +49,7 @@ import { copyForScratch, frozenEnvironment } from './process.js';
50
49
  import { freePort, looksDestructive, waitForServer } from './http.js';
51
50
  import { applyFreeze, prepareForShutter } from '../../freeze/index.js';
52
51
  import { settle } from '../../freeze/settle.js';
52
+ import { spawnServer, stopServer } from './child.js';
53
53
  import {
54
54
  actOf, countRoles, flattenAria, inkOf, loadPlaywright, openWindow, parseAria, runStep, short,
55
55
  watchTheWire, whereItIs, withLimit,
@@ -537,7 +537,7 @@ export const webAdapter = defineAdapter({
537
537
  if (!verdict.safe) notes.push(verdict.why);
538
538
  else {
539
539
  const done = await new Promise((resolve) => {
540
- const child = spawn(String(config.restore), { shell: true, cwd: work, env, stdio: 'ignore' });
540
+ const child = spawnServer(String(config.restore), { cwd: work, env, stdio: 'ignore' });
541
541
  child.on('error', () => resolve(false));
542
542
  child.on('close', (code) => resolve(code === 0));
543
543
  });
@@ -549,7 +549,7 @@ export const webAdapter = defineAdapter({
549
549
  const said = [];
550
550
  /** @type {string|null} */
551
551
  let exited = null;
552
- const child = spawn(String(config.start), { shell: true, cwd: work, env, stdio: ['ignore', 'pipe', 'pipe'] });
552
+ const child = spawnServer(String(config.start), { cwd: work, env });
553
553
  child.stdout?.on('data', (c) => said.push(c));
554
554
  child.stderr?.on('data', (c) => said.push(c));
555
555
  child.on('close', (code, signal) => {
@@ -558,14 +558,14 @@ export const webAdapter = defineAdapter({
558
558
 
559
559
  const up = await waitForServer(port, { timeoutMs: config.startTimeoutMs ?? 90000, crashed: () => exited });
560
560
  if (!up.up) {
561
- child.kill('SIGTERM');
561
+ await stopServer(child);
562
562
  return {
563
563
  build,
564
564
  root: work,
565
565
  ready: false,
566
566
  why: `${up.why} What it printed while trying: ${trimForStorage(Buffer.concat(said).toString('utf8'), 1500).text || '(nothing)'}`,
567
567
  dispose: async () => {
568
- child.kill('SIGKILL');
568
+ await stopServer(child);
569
569
  await fsp.rm(base, { recursive: true, force: true });
570
570
  },
571
571
  };
@@ -584,9 +584,7 @@ export const webAdapter = defineAdapter({
584
584
  running.delete(build.id);
585
585
  if (!held) return;
586
586
  // Only ever the process we started ourselves.
587
- held.child?.kill('SIGTERM');
588
- await new Promise((r) => setTimeout(r, 400));
589
- if (held.child && held.child.exitCode === null) held.child.kill('SIGKILL');
587
+ await stopServer(held.child);
590
588
  await fsp.rm(base, { recursive: true, force: true });
591
589
  },
592
590
  };
@@ -779,7 +777,7 @@ export const webAdapter = defineAdapter({
779
777
  },
780
778
 
781
779
  async teardown() {
782
- for (const [, held] of running) held.child?.kill('SIGTERM');
780
+ for (const [, held] of running) await stopServer(held.child);
783
781
  running.clear();
784
782
  },
785
783
  });
@@ -270,6 +270,16 @@ function testingBrowsers() {
270
270
  take('chromium', path.join(inner, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'));
271
271
  take('chrome-for-testing', path.join(inner, 'chrome-linux', 'chrome'));
272
272
  take('chrome-for-testing', path.join(inner, 'chrome-win', 'chrome.exe'));
273
+ // The 64 matters, and leaving it off made this tool blind to full Chrome on every
274
+ // machine that is not a Mac. Playwright unpacks Linux into `chrome-linux64` and
275
+ // Windows into `chrome-win64`; Puppeteer does the same. Only macOS uses the names
276
+ // above, which is why it was never noticed here. On Linux this meant `npx playwright
277
+ // install chromium` - the command THIS FILE tells people to run - left a browser the
278
+ // survey could not see, and checks quietly fell back to the headless shell, or said
279
+ // there was no browser at all when the shell was not there too. Measured on a real
280
+ // Linux box on 2026-08-30: three Chromes present, none of them found.
281
+ take('chrome-for-testing', path.join(inner, 'chrome-linux64', 'chrome'));
282
+ take('chrome-for-testing', path.join(inner, 'chrome-win64', 'chrome.exe'));
273
283
  take('headless-shell', path.join(inner, 'chrome-headless-shell-mac-arm64', 'chrome-headless-shell'), true);
274
284
  take('headless-shell', path.join(inner, 'chrome-headless-shell-mac-x64', 'chrome-headless-shell'), true);
275
285
  take('headless-shell', path.join(inner, 'chrome-headless-shell-linux64', 'chrome-headless-shell'), true);
@@ -534,11 +544,56 @@ function killNow(pid, home) {
534
544
  } catch {
535
545
  // Already gone. That is the outcome we wanted.
536
546
  }
547
+ // SIGKILL is a request to the kernel, not something that has already happened, and a
548
+ // browser is not one process. While the parent is being reaped its children are still
549
+ // writing into the profile, so deleting the folder in the same breath loses the race:
550
+ // the sweep starts, a file appears behind it, the directory is not empty, and the
551
+ // profile outlives the run - which is the one thing `nothing it opened outlives the
552
+ // run` promises. It passed on macOS and on an idle Linux box and failed on a loaded CI
553
+ // runner for four releases, which is exactly how a race behaves.
554
+ for (let i = 0; i < 40 && stillThere(pid); i++) waitSync(10);
537
555
  }
556
+ // Then remove it, and more than once. One rmSync is a snapshot; a file recreated a
557
+ // millisecond after the sweep began turns it into ENOTEMPTY and a leftover folder.
558
+ for (let attempt = 0; attempt < 5; attempt += 1) {
559
+ try {
560
+ fs.rmSync(home, { recursive: true, force: true });
561
+ return;
562
+ } catch {
563
+ waitSync(20);
564
+ }
565
+ }
566
+ // A profile left in the temporary folder is untidy, not harmful — and it is exactly what
567
+ // `staysfixed browsers --clean` is for.
568
+ }
569
+
570
+ /**
571
+ * Is that process still running? Signal 0 asks without sending anything.
572
+ * @param {number} pid
573
+ * @returns {boolean}
574
+ */
575
+ function stillThere(pid) {
576
+ try {
577
+ process.kill(pid, 0);
578
+ return true;
579
+ } catch {
580
+ return false;
581
+ }
582
+ }
583
+
584
+ /**
585
+ * A real sleep, in the one place nothing may be awaited: this runs inside
586
+ * `process.on('exit')`, where the event loop is already over and a promise never
587
+ * resolves. `Atomics.wait` is the only thing that actually pauses here.
588
+ *
589
+ * @param {number} ms
590
+ */
591
+ function waitSync(ms) {
538
592
  try {
539
- fs.rmSync(home, { recursive: true, force: true });
593
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
540
594
  } catch {
541
- // A profile left in the temporary folder is untidy, not harmful.
595
+ // No SharedArrayBuffer here. Falling straight through is still better than throwing
596
+ // out of a cleanup handler.
542
597
  }
543
598
  }
544
599