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.
- package/CHANGELOG.md +182 -0
- package/README.md +17 -5
- package/docs/getting-started.md +10 -0
- package/docs/how-v2-works.md +5 -2
- package/package.json +2 -2
- package/src/guard/api.js +107 -3
- package/src/guard/run.js +154 -20
- package/src/report/console.js +235 -17
- package/src/report/html.js +75 -19
- package/src/types.js +5 -0
- package/src/v2/adapters/android-driver.js +62 -12
- package/src/v2/adapters/contract.js +18 -4
- package/src/v2/adapters/electron.js +96 -14
- package/src/v2/adapters/http.js +264 -23
- package/src/v2/adapters/ios-driver.js +22 -4
- package/src/v2/adapters/ios.js +5 -2
- package/src/v2/adapters/isolate.js +78 -5
- package/src/v2/adapters/process.js +350 -92
- package/src/v2/adapters/web-driver.js +23 -1
- package/src/v2/adapters/web.js +42 -3
- package/src/v2/adapters/windows.js +32 -15
- package/src/v2/check.js +526 -19
- package/src/v2/cli.js +345 -3
- package/src/v2/cluster.js +112 -4
- package/src/v2/coverage.js +293 -8
- package/src/v2/detect.js +182 -9
- package/src/v2/doctor.js +253 -30
- package/src/v2/init.js +102 -10
- package/src/v2/mcp/server.js +4 -1
- package/src/v2/mcp/tools.js +291 -24
- package/src/v2/normalise.js +11 -0
- package/src/v2/observation.js +57 -5
- package/src/v2/reference.js +133 -14
- package/src/v2/refusal.js +389 -0
- package/src/v2/remote.js +24 -3
- package/src/v2/run.js +306 -16
- package/src/v2/sealed.js +14 -2
- package/src/v2/ship.js +286 -22
- package/src/v2/store.js +101 -2
- package/src/v2/types.js +5 -0
- package/src/v2/waiver.js +9 -2
- package/src/watch/panel.js +12 -1
package/src/v2/adapters/http.js
CHANGED
|
@@ -167,15 +167,85 @@ export function shapeOf(value, at = '') {
|
|
|
167
167
|
// ---------------------------------------------------------------------------
|
|
168
168
|
|
|
169
169
|
/**
|
|
170
|
-
*
|
|
170
|
+
* The two things "this machine" can mean, and why both have to be knocked on.
|
|
171
171
|
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
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)
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
|
518
|
-
facts: { port, work, base:
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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;
|
package/src/v2/adapters/ios.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
508
|
-
|
|
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.
|
|
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 {
|
|
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
|
|
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) {
|