staysfixed 0.9.1 → 0.11.0

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +182 -0
  2. package/README.md +17 -5
  3. package/docs/getting-started.md +10 -0
  4. package/docs/how-v2-works.md +5 -2
  5. package/package.json +2 -2
  6. package/src/guard/api.js +107 -3
  7. package/src/guard/run.js +154 -20
  8. package/src/report/console.js +235 -17
  9. package/src/report/html.js +75 -19
  10. package/src/types.js +5 -0
  11. package/src/v2/adapters/android-driver.js +62 -12
  12. package/src/v2/adapters/contract.js +18 -4
  13. package/src/v2/adapters/electron.js +96 -14
  14. package/src/v2/adapters/http.js +264 -23
  15. package/src/v2/adapters/ios-driver.js +22 -4
  16. package/src/v2/adapters/ios.js +5 -2
  17. package/src/v2/adapters/isolate.js +78 -5
  18. package/src/v2/adapters/process.js +350 -92
  19. package/src/v2/adapters/web-driver.js +23 -1
  20. package/src/v2/adapters/web.js +42 -3
  21. package/src/v2/adapters/windows.js +32 -15
  22. package/src/v2/check.js +526 -19
  23. package/src/v2/cli.js +345 -3
  24. package/src/v2/cluster.js +112 -4
  25. package/src/v2/coverage.js +293 -8
  26. package/src/v2/detect.js +182 -9
  27. package/src/v2/doctor.js +253 -30
  28. package/src/v2/init.js +102 -10
  29. package/src/v2/mcp/server.js +4 -1
  30. package/src/v2/mcp/tools.js +291 -24
  31. package/src/v2/normalise.js +11 -0
  32. package/src/v2/observation.js +57 -5
  33. package/src/v2/reference.js +133 -14
  34. package/src/v2/refusal.js +389 -0
  35. package/src/v2/remote.js +24 -3
  36. package/src/v2/run.js +306 -16
  37. package/src/v2/sealed.js +14 -2
  38. package/src/v2/ship.js +286 -22
  39. package/src/v2/store.js +101 -2
  40. package/src/v2/types.js +5 -0
  41. package/src/v2/waiver.js +9 -2
  42. package/src/watch/panel.js +12 -1
@@ -167,15 +167,85 @@ export function shapeOf(value, at = '') {
167
167
  // ---------------------------------------------------------------------------
168
168
 
169
169
  /**
170
- * Find a port nobody is using, by briefly being the one using it.
170
+ * The two things "this machine" can mean, and why both have to be knocked on.
171
171
  *
172
- * There is a gap between letting go of the port and the server taking it, and something
173
- * else can slip in. Nothing can close that gap on any operating system, so the boot retries
174
- * instead of pretending it cannot happen.
172
+ * A server told to listen on `localhost` does not choose between IPv4 and IPv6 the
173
+ * operating system chooses, when it resolves the name, and the two answers are different
174
+ * addresses. Measured on this Mac on 2026-08-31 against a stock Vite app: `vite preview`
175
+ * bound `[::1]:PORT` and NOTHING was listening on `127.0.0.1:PORT`. A boot check that
176
+ * knocked only on `127.0.0.1` was refused instantly, two hundred milliseconds apart, for
177
+ * the whole ninety seconds — on the most ordinary kind of project there is. So both are
178
+ * knocked on, and whichever one answers is the address the rest of the run uses.
179
+ */
180
+ const LOOPBACK = ['127.0.0.1', '::1'];
181
+
182
+ /**
183
+ * An address written the way a URL has to have it.
184
+ *
185
+ * IPv6 needs square brackets and nothing else may have them, and getting it wrong is
186
+ * silent rather than loud: `http://::1:5173/` parses as a hostname nobody has.
175
187
  *
188
+ * @param {string} host
189
+ * @param {number} port
190
+ * @returns {string}
191
+ */
192
+ export function loopbackUrl(host, port) {
193
+ const bare = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
194
+ return bare.includes(':') ? `http://[${bare}]:${port}` : `http://${bare}:${port}`;
195
+ }
196
+
197
+ /**
198
+ * Knock once on one address, and say which of the three things happened.
199
+ *
200
+ * "Refused" is worth keeping apart from "timed out" because they mean opposite things.
201
+ * Refused is this machine answering straight away that nothing is listening on that port;
202
+ * no amount of waiting changes that while it stays true. Timed out is something that never
203
+ * answered at all, which really can be a server still starting up.
204
+ *
205
+ * @param {number} port
206
+ * @param {string} host
207
+ * @param {number} [timeoutMs]
208
+ * @returns {Promise<{open: boolean, refused: boolean}>}
209
+ */
210
+ function knock(port, host, timeoutMs = 1000) {
211
+ return new Promise((resolve) => {
212
+ /** @type {import('node:net').Socket} */
213
+ let socket;
214
+ try {
215
+ socket = net.connect({ port, host });
216
+ } catch {
217
+ // No IPv6 on this machine at all, or an address that cannot be parsed. Either way
218
+ // nothing is listening there, which is the answer this function exists to give.
219
+ resolve({ open: false, refused: false });
220
+ return;
221
+ }
222
+ let settled = false;
223
+ /**
224
+ * @param {boolean} open
225
+ * @param {boolean} refused
226
+ */
227
+ const done = (open, refused) => {
228
+ if (settled) return;
229
+ settled = true;
230
+ try {
231
+ socket.destroy();
232
+ } catch {
233
+ // Already gone.
234
+ }
235
+ resolve({ open, refused });
236
+ };
237
+ socket.setTimeout(timeoutMs);
238
+ socket.on('connect', () => done(true, false));
239
+ socket.on('error', (/** @type {any} */ error) => done(false, error?.code === 'ECONNREFUSED' || error?.code === 'EADDRNOTAVAIL'));
240
+ socket.on('timeout', () => done(false, false));
241
+ });
242
+ }
243
+
244
+ /**
245
+ * Take one port off the operating system by briefly being the one using it.
176
246
  * @returns {Promise<number>}
177
247
  */
178
- export function freePort() {
248
+ function claimPort() {
179
249
  return new Promise((resolve, reject) => {
180
250
  const probe = net.createServer();
181
251
  probe.on('error', reject);
@@ -188,30 +258,188 @@ export function freePort() {
188
258
  }
189
259
 
190
260
  /**
191
- * Wait until something is listening, or give up.
261
+ * Find a port nobody is using.
262
+ *
263
+ * There is a gap between letting go of the port and the server taking it, and something
264
+ * else can slip in. Nothing can close that gap on any operating system, so the boot retries
265
+ * instead of pretending it cannot happen.
266
+ *
267
+ * The second half of this is new on 2026-08-31, and it is here to stop a false all-clear
268
+ * rather than to make anything work. Claiming a port on `127.0.0.1` says nothing at all
269
+ * about IPv6: a port can be free on one family and held by somebody else's program on the
270
+ * other. Now that the boot check knocks on `::1` too, a port free on IPv4 and taken on IPv6
271
+ * would have this tool connect to a STRANGER'S server and walk it as though it were the
272
+ * build being checked — the worst kind of wrong answer this tool can give. So the port is
273
+ * only handed back once both families are quiet.
274
+ *
275
+ * @returns {Promise<number>}
276
+ */
277
+ export async function freePort() {
278
+ /** @type {number} */
279
+ let port = 0;
280
+ for (let attempt = 0; attempt < 25; attempt += 1) {
281
+ port = await claimPort();
282
+ const alreadyThere = await knock(port, '::1', 250);
283
+ if (!alreadyThere.open) return port;
284
+ }
285
+ throw new Error(`every port this machine offered was already in use on IPv6 (the last one tried was ${port})`);
286
+ }
287
+
288
+ /** Colour codes a terminal eats, which a plain-text search should not have to see. */
289
+ const ANSI = /\u001B\[[0-9;]*[A-Za-z]/g;
290
+
291
+ /** `http://host:port` inside something a server printed. The port is the part that matters. */
292
+ const PRINTED_ADDRESS = /https?:\/\/(\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9._-]+):(\d{2,5})/g;
293
+
294
+ /**
295
+ * Every address a starting server said about itself.
296
+ *
297
+ * This is the whole answer to "why did it wait ninety seconds". A server that came up
298
+ * somewhere else almost always SAYS so on its first line: Vite prints
299
+ * `Local: http://localhost:5173/`, Next prints `- Local: http://localhost:3000`,
300
+ * uvicorn prints `Uvicorn running on http://127.0.0.1:8000`. If it printed a port that is
301
+ * not the port it was handed, then nothing is ever going to answer where the check is
302
+ * knocking, and that is knowable a second after it starts rather than a minute and a half
303
+ * later.
304
+ *
305
+ * @param {string|null|undefined} text Everything the process has printed so far.
306
+ * @returns {{host: string, port: number, url: string}[]}
307
+ */
308
+ export function announcedAddresses(text) {
309
+ /** @type {{host: string, port: number, url: string}[]} */
310
+ const found = [];
311
+ const seen = new Set();
312
+ for (const match of String(text ?? '').replace(ANSI, '').matchAll(PRINTED_ADDRESS)) {
313
+ const host = match[1].toLowerCase();
314
+ const port = Number(match[2]);
315
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) continue;
316
+ const url = loopbackUrl(host, port);
317
+ if (seen.has(url)) continue;
318
+ seen.add(url);
319
+ found.push({ host, port, url });
320
+ }
321
+ return found;
322
+ }
323
+
324
+ /**
325
+ * @typedef {object} WaitResult
326
+ * @property {boolean} up
327
+ * @property {string} why Plain English, whether it worked or not.
328
+ * @property {number} ms
329
+ * @property {'answered'|'exited'|'wrong address'|'never answered'} outcome
330
+ * @property {string} [host] The address that actually answered.
331
+ * @property {string} [baseUrl] That address, written as a URL.
332
+ */
333
+
334
+ /**
335
+ * Wait until something is listening, or say — quickly, and in words — why it never will be.
336
+ *
337
+ * ## The defect this was rewritten for, 2026-08-31
338
+ *
339
+ * On a stock Vite app on macOS this waited the full ninety seconds and then gave back a
340
+ * sentence nobody could act on. Two separate things were wrong, and only the first is about
341
+ * Vite. The server was listening on `[::1]` while the check knocked on `127.0.0.1`; and
342
+ * every one of those knocks came back REFUSED in under a millisecond, which is this machine
343
+ * saying "there is nothing here", not "not yet". Ninety seconds of that, on each side of the
344
+ * comparison, is three minutes in which the tool is indistinguishable from a broken one.
345
+ *
346
+ * Three things are knowable early, and all three are now said early:
347
+ *
348
+ * 1. The command exited. That was already handled, and still is.
349
+ * 2. The command printed an address on a DIFFERENT port from the one it was handed. It
350
+ * ignored `$PORT`, so nothing will ever answer where the check is knocking. Stop now,
351
+ * and name the command, the address it printed and the address that was waited on.
352
+ * 3. Nothing has answered yet and every knock was refused. That one is deliberately NOT
353
+ * stopped early, because a server that has not opened its port yet is refused in exactly
354
+ * the same way, and cutting the wait short there would turn a slow build into a failure.
355
+ * Instead the wait says out loud what it is waiting for while it waits, so that the
356
+ * difference between working and hung is visible rather than guessed at.
357
+ *
192
358
  * @param {number} port
193
359
  * @param {object} [opts]
194
360
  * @param {number} [opts.timeoutMs]
195
361
  * @param {() => string|null} [opts.crashed] Called between tries; a string means stop now.
196
- * @returns {Promise<{up: boolean, why: string, ms: number}>}
362
+ * @param {() => string} [opts.announced] Everything the process has printed so far.
363
+ * @param {string} [opts.command] The command that was run, for the message.
364
+ * @param {string[]} [opts.hosts] Addresses to knock on. Both loopbacks by default.
365
+ * @param {(message: string) => void} [opts.say] Told what is being waited for, as it waits.
366
+ * @returns {Promise<WaitResult>}
197
367
  */
198
368
  export async function waitForServer(port, opts = {}) {
199
369
  const timeoutMs = opts.timeoutMs ?? 60000;
370
+ const named = opts.command ? `\`${opts.command}\`` : 'the start command';
371
+ /** @type {string[]} */
372
+ const hosts = [...(opts.hosts ?? LOOPBACK)];
200
373
  const started = Date.now();
374
+ /** Every knock so far was refused outright, so nothing has ever been listening there. */
375
+ let onlyRefusals = true;
376
+ /** So the running commentary is written every few seconds, not five times a second. */
377
+ let saidAt = 0;
378
+
379
+ const where = () => hosts.map((host) => loopbackUrl(host, port)).join(' or ');
380
+
201
381
  for (;;) {
202
382
  const crash = opts.crashed?.();
203
- if (crash) return { up: false, why: crash, ms: Date.now() - started };
204
- const open = await new Promise((resolve) => {
205
- const socket = net.connect({ port, host: '127.0.0.1' });
206
- const done = (/** @type {boolean} */ answer) => { socket.destroy(); resolve(answer); };
207
- socket.setTimeout(1000);
208
- socket.on('connect', () => done(true));
209
- socket.on('error', () => done(false));
210
- socket.on('timeout', () => done(false));
211
- });
212
- if (open) return { up: true, why: `The server answered on port ${port}.`, ms: Date.now() - started };
213
- if (Date.now() - started > timeoutMs) {
214
- return { up: false, why: `The server never answered on port ${port} within ${timeoutMs / 1000} seconds.`, ms: Date.now() - started };
383
+ if (crash) {
384
+ return {
385
+ up: false,
386
+ outcome: 'exited',
387
+ ms: Date.now() - started,
388
+ why: `${crash} It was started with ${named}, and nothing ever listened at ${where()}.`,
389
+ };
390
+ }
391
+
392
+ for (const host of hosts) {
393
+ const hit = await knock(port, host, 1000);
394
+ if (hit.open) {
395
+ const baseUrl = loopbackUrl(host, port);
396
+ return {
397
+ up: true,
398
+ outcome: 'answered',
399
+ host,
400
+ baseUrl,
401
+ ms: Date.now() - started,
402
+ why: `The server answered at ${baseUrl}.`,
403
+ };
404
+ }
405
+ if (!hit.refused) onlyRefusals = false;
406
+ }
407
+
408
+ // What it said about itself. This is the fast answer, and the reason the wait no longer
409
+ // spends a minute and a half proving something it could have read off the first line.
410
+ const printed = announcedAddresses(opts.announced?.() ?? '');
411
+ if (printed.length > 0 && !printed.some((one) => one.port === port)) {
412
+ const seconds = Math.max(1, Math.round((Date.now() - started) / 1000));
413
+ return {
414
+ up: false,
415
+ outcome: 'wrong address',
416
+ ms: Date.now() - started,
417
+ why: `${named} came up at ${printed.map((one) => one.url).join(', ')}, not on the port it was handed (${port}). Nothing was ever going to answer at ${where()}, so the wait was stopped after ${seconds} second${seconds === 1 ? '' : 's'} instead of running out the clock. Make the command listen on the PORT it is given: for Vite that is \`--port $PORT --strictPort --host 127.0.0.1\`, and for most other things reading process.env.PORT is enough.`,
418
+ };
419
+ }
420
+ // It named an address on OUR port but on a host nobody is knocking on — a server told to
421
+ // listen on the network rather than on loopback. The port is one this tool handed out and
422
+ // proved free, so what is there is its own child. Knock there too rather than time out
423
+ // beside a server that is up.
424
+ for (const one of printed) {
425
+ if (one.port !== port) continue;
426
+ const bare = one.host.startsWith('[') && one.host.endsWith(']') ? one.host.slice(1, -1) : one.host;
427
+ if (bare === 'localhost' || bare === '0.0.0.0' || bare === '::' || hosts.includes(bare)) continue;
428
+ hosts.push(bare);
429
+ }
430
+
431
+ const waited = Date.now() - started;
432
+ if (waited > timeoutMs) {
433
+ return {
434
+ up: false,
435
+ outcome: 'never answered',
436
+ ms: waited,
437
+ why: `Nothing answered at ${where()} in ${Math.round(timeoutMs / 1000)} seconds. ${named} was still running${onlyRefusals ? ', and every single knock was refused outright — that is this machine saying nothing is listening on that port at all, rather than that the server is slow' : ''}. ${printed.length > 0 ? `The only address it printed was ${printed.map((one) => one.url).join(', ')}.` : 'It never printed an address saying where it came up.'}`,
438
+ };
439
+ }
440
+ if (opts.say && waited - saidAt >= 5000) {
441
+ saidAt = waited;
442
+ opts.say(`Still waiting for ${named} to answer at ${where()} — ${Math.round(waited / 1000)} seconds so far${onlyRefusals ? ', with every knock refused, which means nothing has opened that port yet' : ''}.`);
215
443
  }
216
444
  await new Promise((r) => setTimeout(r, 200));
217
445
  }
@@ -490,9 +718,17 @@ export const httpAdapter = defineAdapter({
490
718
  exited = `The server stopped before it answered — exit code ${code}${signal ? `, killed by ${signal}` : ''}.`;
491
719
  });
492
720
 
721
+ // The same handover the web adapter makes, for the same reason and against the same
722
+ // measurement of 2026-08-31: a wait that only knows a port number can only ever report a
723
+ // port number, and "nothing answered on 64912" is not something a person can act on. With
724
+ // the command and its output in hand, a server that ignored `$PORT` or came up on the
725
+ // other loopback address is named in the first second or two instead of the sixtieth.
493
726
  const up = await waitForServer(port, {
494
727
  timeoutMs: config.startTimeoutMs ?? 60000,
495
728
  crashed: () => exited,
729
+ command: String(config.start),
730
+ announced: () => Buffer.concat([...bootOut, ...bootErr]).toString('utf8'),
731
+ say: (message) => ctx.log?.(message),
496
732
  });
497
733
 
498
734
  if (!up.up) {
@@ -508,14 +744,19 @@ export const httpAdapter = defineAdapter({
508
744
  // writes one line the moment it loads, so the file existing after boot is the proof. It
509
745
  // decides whether a route the project called irreversible may be walked at all.
510
746
  const watcherInForce = (await readWatcher(reportFile)).inForce;
511
- running.set(build.id, { base, work, home, tmp, port, reportFile, child, config, bootErr, watcherInForce });
747
+ // The address that ANSWERED, kept so every route is asked for at the place the server
748
+ // really is. A server that listened on `localhost` is on the IPv6 loopback on this Mac,
749
+ // and every request sent to a hard-coded `127.0.0.1` would be refused by a machine that
750
+ // is running the server perfectly well.
751
+ const baseUrl = up.baseUrl ?? `http://127.0.0.1:${port}`;
752
+ running.set(build.id, { base, work, home, tmp, port, baseUrl, reportFile, child, config, bootErr, watcherInForce });
512
753
 
513
754
  return {
514
755
  build,
515
756
  root: work,
516
757
  ready: true,
517
- why: `${copy.why} ${restored} It came up on port ${port} in ${timeBucket(up.ms)}. ${watcherInForce ? 'Outbound connections are being watched and refused, so a route that calls a payment provider can be walked safely.' : 'Nothing is watching this server from the inside — it is not a Node program, or it replaced the environment it was started with — so routes that reach off this machine are left alone.'}${notes.length > 0 ? ` ${notes.join(' ')}` : ''}`,
518
- facts: { port, work, base: `http://127.0.0.1:${port}` },
758
+ why: `${copy.why} ${restored} It came up at ${baseUrl} in ${timeBucket(up.ms)}. ${watcherInForce ? 'Outbound connections are being watched and refused, so a route that calls a payment provider can be walked safely.' : 'Nothing is watching this server from the inside — it is not a Node program, or it replaced the environment it was started with — so routes that reach off this machine are left alone.'}${notes.length > 0 ? ` ${notes.join(' ')}` : ''}`,
759
+ facts: { port, work, base: baseUrl },
519
760
  dispose: async () => {
520
761
  const held = running.get(build.id);
521
762
  running.delete(build.id);
@@ -582,7 +823,7 @@ export const httpAdapter = defineAdapter({
582
823
  /** @type {string|null} */
583
824
  let failure = null;
584
825
  try {
585
- answer = await fetch(`http://127.0.0.1:${held.port}${detail.url}`, {
826
+ answer = await fetch(`${held.baseUrl ?? `http://127.0.0.1:${held.port}`}${detail.url}`, {
586
827
  method: detail.method,
587
828
  headers: { accept: '*/*', ...detail.headers },
588
829
  body: detail.body === undefined || detail.method === 'GET' || detail.method === 'HEAD'
@@ -56,6 +56,10 @@ import os from 'node:os';
56
56
  import crypto from 'node:crypto';
57
57
  import { execFile } from 'node:child_process';
58
58
 
59
+ // One place for "every wait has a limit and every limit says something", shared with the
60
+ // other adapters rather than written out three times.
61
+ import { boundedMs, letGoOf } from './process.js';
62
+
59
63
  /** @typedef {import('../types.js').ObservedValue} JsonValue */
60
64
 
61
65
  // ---------------------------------------------------------------------------
@@ -96,7 +100,13 @@ function runOnce(file, args, opts = {}) {
96
100
  file,
97
101
  args,
98
102
  {
99
- timeout: opts.timeoutMs ?? 60_000,
103
+ // `boundedMs` and not the number as handed over, and this is load-bearing. `execFile`
104
+ // arms its timeout only when the value is greater than zero, so a limit of NaN — which
105
+ // is what `Number(journey.timeoutMs)` becomes the moment somebody writes `"4m"` in
106
+ // their settings — does not shorten the limit, it REMOVES it. And `xcrun simctl` is
107
+ // already known to hang on this machine, so the result would be a check that produced
108
+ // no output and never came back, which is the exact symptom recorded on 2026-08-30.
109
+ timeout: boundedMs(opts.timeoutMs, 60_000),
100
110
  killSignal: 'SIGKILL',
101
111
  maxBuffer: 64 * 1024 * 1024,
102
112
  signal: opts.signal,
@@ -105,6 +115,10 @@ function runOnce(file, args, opts = {}) {
105
115
  (error, stdout, stderr) => {
106
116
  const err = /** @type {any} */ (error);
107
117
  if (err && (err.killed || err.signal === 'SIGKILL')) timedOut = true;
118
+ // Nothing this file starts may hold the tool open after the answer is in. `execFile`
119
+ // tears the pipes down itself when its own limit fires, but not when the program simply
120
+ // ends — and a simulator helper that outlived it is still holding the writing end.
121
+ letGoOf(child);
108
122
  resolve({
109
123
  code: err?.code === undefined ? (error ? 1 : 0) : Number(err.code),
110
124
  stdout: String(stdout ?? ''),
@@ -1007,7 +1021,11 @@ export async function openApp(opts) {
1007
1021
  const seq = String(sequence).padStart(5, '0');
1008
1022
  const reply = path.join(channel, `reply-${seq}.json`);
1009
1023
  await fsp.writeFile(path.join(channel, `cmd-${seq}.json`), JSON.stringify({ act, ...args }), 'utf8');
1010
- const until = Date.now() + timeoutMs;
1024
+ // Guarded so the sentence at the bottom of this can never read "did not answer within NaN
1025
+ // seconds", which is a limit nobody can act on and, on the other side of the comparison,
1026
+ // a loop that gives up instantly and reports a working app as unreadable.
1027
+ const limitMs = boundedMs(timeoutMs, ANSWER_TIMEOUT_MS, 10 * 60_000);
1028
+ const until = Date.now() + limitMs;
1011
1029
  while (Date.now() < until) {
1012
1030
  if (opts.signal?.aborted) throw new Error('The run was cancelled while the app was being asked something.');
1013
1031
  try {
@@ -1018,7 +1036,7 @@ export async function openApp(opts) {
1018
1036
  await wait(40);
1019
1037
  }
1020
1038
  }
1021
- return { ok: false, timedOut: true, why: `The app did not answer within ${Math.round(timeoutMs / 1000)} seconds when it was asked to ${act}. It may be busy, or it may have stopped.` };
1039
+ return { ok: false, timedOut: true, why: `The app did not answer within ${Math.round(limitMs / 1000)} seconds when it was asked to ${act}. It may be busy, or it may have stopped.` };
1022
1040
  };
1023
1041
 
1024
1042
  t = Date.now();
@@ -1029,7 +1047,7 @@ export async function openApp(opts) {
1029
1047
  // took eighteen seconds on a warm device took over seven minutes on a cold one. A
1030
1048
  // window that is too short does not fail loudly - it reports the screen as unreadable,
1031
1049
  // which is a hole where there was no problem.
1032
- const until = Date.now() + (opts.firstAnswerMs ?? 60_000);
1050
+ const until = Date.now() + boundedMs(opts.firstAnswerMs, 60_000, 15 * 60_000);
1033
1051
  while (Date.now() < until && !probeAnswered) {
1034
1052
  const pong = await ask('ping', {}, 4_000);
1035
1053
  probeAnswered = pong?.ok === true;
@@ -56,6 +56,7 @@ import {
56
56
  countBucket,
57
57
  sizeBucket,
58
58
  } from './contract.js';
59
+ import { boundedMs } from './process.js';
59
60
 
60
61
  import {
61
62
  readMachine,
@@ -472,7 +473,9 @@ export function journeysFrom(input) {
472
473
  channels: ['meaning', 'effects', 'complaints', 'counters', 'pixels'],
473
474
  steps,
474
475
  irreversible: Boolean(journey.irreversible),
475
- timeoutMs: Number(journey.timeoutMs ?? 240_000),
476
+ // Guarded rather than `Number(...)`: a journey is written by hand, and `timeoutMs: "4m"`
477
+ // is NaN, which every limit downstream reads as "no limit at all".
478
+ timeoutMs: boundedMs(journey.timeoutMs, 240_000),
476
479
  });
477
480
  }
478
481
 
@@ -973,7 +976,7 @@ async function walkObservations(journey, kept, ctx) {
973
976
  for (const step of steps) {
974
977
  if (ctx.signal?.aborted) break;
975
978
  if (step.act === 'wait') {
976
- await new Promise((resolve) => setTimeout(resolve, Math.min(Number(step.ms ?? 500), 10_000)));
979
+ await new Promise((resolve) => setTimeout(resolve, boundedMs(step.ms, 500, 10_000)));
977
980
  continue;
978
981
  }
979
982
  if (step.act === 'open') {
@@ -37,6 +37,12 @@ import path from 'node:path';
37
37
  import { execFile, spawn } from 'node:child_process';
38
38
  import { promisify } from 'node:util';
39
39
 
40
+ // Every wait in this file has to have a limit on it and every limit has to produce a sentence,
41
+ // so the pieces that do that live in one place rather than three. `process.js` is where they
42
+ // are, because it is the file that already owns running a program and waiting for it, and
43
+ // `electron.js` already reads its folder-watching out of there too.
44
+ import { letGoOf, withLimit } from './process.js';
45
+
40
46
  const execFileAsync = promisify(execFile);
41
47
 
42
48
  /**
@@ -173,6 +179,12 @@ export function appNameFor(binary) {
173
179
  * holds the app open — measured, not guessed.
174
180
  * @property {(child: import('node:child_process').ChildProcess) => void} own
175
181
  * Register a process this run started.
182
+ * @property {() => void} [markReleased]
183
+ * Told by the teardown. After it, anything registered
184
+ * through `closeFirst` or `own` is closed or stopped on
185
+ * arrival instead of being added to a list nobody reads
186
+ * again — an abandoned open can still finish connecting
187
+ * after the sweep, and one live socket holds the tool open.
176
188
  * @property {string[]} notes Plain English, for the run's own report.
177
189
  */
178
190
 
@@ -466,6 +478,9 @@ export async function reserveIsolation(opts) {
466
478
  const closers = [];
467
479
  /** @type {import('node:child_process').ChildProcess[]} */
468
480
  const children = [];
481
+ // True once the teardown has run. Anything that arrives after that has to be dealt with on
482
+ // the spot rather than added to a list nobody reads again — see the two handlers below.
483
+ let releasedAlready = false;
469
484
 
470
485
  /** @type {Isolation} */
471
486
  const isolation = {
@@ -504,8 +519,27 @@ export async function reserveIsolation(opts) {
504
519
  ...identityEnv,
505
520
  ...opts.env,
506
521
  },
507
- closeFirst(close) { closers.push(close); },
508
- own(child) { children.push(child); },
522
+ // A connection or a process that arrives AFTER the teardown has already run is not
523
+ // hypothetical: opening an app is a wait with a limit on it, and when that limit fires the
524
+ // open is abandoned while it is still half way through — so it can still finish connecting
525
+ // a moment later, on an isolation that has already been swept. Pushed onto these lists it
526
+ // would be held by nobody and closed by nobody, and one live debugging socket is all it
527
+ // takes to keep Node's event loop awake for ever, which is the exact hang this whole pass
528
+ // exists to remove. So a latecomer is closed or stopped immediately instead.
529
+ closeFirst(close) {
530
+ if (releasedAlready) { void Promise.resolve(close()).catch(() => {}); return; }
531
+ closers.push(close);
532
+ },
533
+ own(child) {
534
+ if (releasedAlready) {
535
+ try { child.kill('SIGKILL'); } catch { /* already gone */ }
536
+ letGoOf(child);
537
+ return;
538
+ }
539
+ children.push(child);
540
+ },
541
+ /** Told by the teardown, so latecomers know there is nothing left to join. */
542
+ markReleased() { releasedAlready = true; },
509
543
  notes: [
510
544
  `This run has its own settings folder, its own cache, its own two debugging ports and the name "${identity}".`,
511
545
  'Nothing here is shared with the real app, so the two cannot displace each other.',
@@ -633,7 +667,12 @@ export function startIsolated(isolation, opts) {
633
667
  * window, one for the network, and whatever the app itself started. They all carry this
634
668
  * run's own folder on their command line, which is how they are told from everybody
635
669
  * else's, and nothing without that marker is ever touched.
636
- * 5. Check the ports are free again, because the next run needs them and a port that is
670
+ * 5. Let go of the pipes. Not tidiness the run's own survival. A survivor holding the
671
+ * writing end of a pipe this process is reading keeps Node's event loop awake for ever,
672
+ * and on 2026-08-31 that was measured doing exactly that: every step above succeeded, the
673
+ * report said the app was proved gone, and the tool then never returned. A check that
674
+ * prints a perfect verdict and hangs cannot be told apart from a check that is broken.
675
+ * 6. Check the ports are free again, because the next run needs them and a port that is
637
676
  * still held is the clearest possible proof that something survived.
638
677
  *
639
678
  * @param {Isolation} isolation
@@ -647,6 +686,9 @@ export async function releaseIsolation(isolation, opts = {}) {
647
686
  const closers = held?.closers ?? [];
648
687
  const children = held?.children ?? [];
649
688
  alive.delete(isolation.id);
689
+ // Said before anything is torn down, so a connection that finishes opening half way through
690
+ // this is closed on arrival rather than added to a list that has already been walked.
691
+ isolation.markReleased?.();
650
692
  handedOut.delete(isolation.debugPort);
651
693
  handedOut.delete(isolation.inspectPort);
652
694
 
@@ -654,8 +696,17 @@ export async function releaseIsolation(isolation, opts = {}) {
654
696
  const leftBehind = [];
655
697
 
656
698
  // 1 — hang up, before asking anything to quit.
699
+ //
700
+ // On a clock, because a closer is a callback somebody else registered and a debugging socket
701
+ // that refuses to say goodbye must not be able to hold the teardown — and therefore the whole
702
+ // check — open. If one will not close in five seconds it is left, which is exactly what the
703
+ // sweep below is for.
657
704
  for (const close of closers) {
658
- try { await close(); } catch { /* a connection that will not close is one we are leaving anyway */ }
705
+ try {
706
+ await withLimit(Promise.resolve(close()), { limitMs: 5000, what: `one of ${isolation.label}'s debugging connections to hang up` });
707
+ } catch (e) {
708
+ leftBehind.push(`a debugging connection would not hang up (${e instanceof Error ? e.message : String(e)})`);
709
+ }
659
710
  }
660
711
 
661
712
  // 2 — read the whole family while the trail still exists, then ask the app to quit.
@@ -711,7 +762,29 @@ export async function releaseIsolation(isolation, opts = {}) {
711
762
  leftBehind.push(`process ${holder.pid} is still running and still pointing at this run's folder`);
712
763
  }
713
764
 
714
- // 5 — the ports have to come back.
765
+ // 5 — let go of the pipes, whatever else happened.
766
+ //
767
+ // This is the step that was missing, and it is the one that cost a run. Measured on
768
+ // 2026-08-31: with everything above done and the report reading "the app was closed and is
769
+ // gone ... the next run starts alone", the tool then sat there for ever. An app that started
770
+ // a shell of its own — which is the entire job of the app this was measured against — leaves
771
+ // that shell holding the writing end of the pipes this process is reading, a pipe being read
772
+ // keeps Node's event loop awake, and so the check never ended. It printed a perfect verdict
773
+ // and never came back, which is indistinguishable from the tool being broken.
774
+ //
775
+ // A short drain first, because whatever the app said on its way out is worth keeping and the
776
+ // exit and the last of the output can land a tick apart. Then the pipes go, on a clock,
777
+ // rather than being trusted to close on their own. `child.js` reaches the same conclusion for
778
+ // a start command's server, for the same reason.
779
+ for (const child of children) {
780
+ await Promise.race([
781
+ new Promise((done) => { child.once('close', done); }),
782
+ wait(250),
783
+ ]);
784
+ letGoOf(child);
785
+ }
786
+
787
+ // 6 — the ports have to come back.
715
788
  /** @type {[number, string][]} */
716
789
  const portsToCheck = [[isolation.debugPort, 'the window'], [isolation.inspectPort, 'the main process']];
717
790
  for (const [port, what] of portsToCheck) {