staysfixed 0.10.0 → 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 +125 -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 +319 -9
- package/src/v2/cli.js +345 -3
- package/src/v2/cluster.js +112 -4
- package/src/v2/coverage.js +208 -8
- package/src/v2/detect.js +182 -9
- package/src/v2/doctor.js +168 -30
- package/src/v2/init.js +88 -10
- package/src/v2/mcp/server.js +4 -1
- package/src/v2/mcp/tools.js +291 -24
- 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
|
@@ -502,6 +502,232 @@ export function compareTrees(before, after) {
|
|
|
502
502
|
return changes;
|
|
503
503
|
}
|
|
504
504
|
|
|
505
|
+
// ---------------------------------------------------------------------------
|
|
506
|
+
// Never waiting for ever
|
|
507
|
+
// ---------------------------------------------------------------------------
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* The three pieces below exist because of one measured symptom, and they are used by every
|
|
511
|
+
* adapter in this folder that starts a program.
|
|
512
|
+
*
|
|
513
|
+
* On 2026-08-30 an Electron check produced no output at all and simply never came back. On
|
|
514
|
+
* 2026-08-31 it was reproduced twice, deliberately, against a fake app that starts, prints one
|
|
515
|
+
* line and leaves a `sleep` behind that inherited its standard output:
|
|
516
|
+
*
|
|
517
|
+
* 1. `runCommand` was asked for a two second limit and had still not returned twenty
|
|
518
|
+
* seconds later. Its limit fired exactly on time and killed the shell — but the promise
|
|
519
|
+
* is settled by the child's `close` event, and `close` does not mean "the program ended",
|
|
520
|
+
* it means "nobody is holding its pipes any more". The orphan was holding them, so
|
|
521
|
+
* `close` never arrived and the limit may as well not have existed.
|
|
522
|
+
*
|
|
523
|
+
* 2. A run that had finished all of its work — the app opened, read, closed and PROVED gone,
|
|
524
|
+
* the teardown printing "The next run starts alone" — then sat there for ever, because
|
|
525
|
+
* the same orphan was still holding the writing end of a pipe this process was reading.
|
|
526
|
+
* A pipe being read keeps Node's event loop awake, so the tool never exited.
|
|
527
|
+
*
|
|
528
|
+
* `child.js` says the same thing about servers started from a start command, and it is right:
|
|
529
|
+
* the whole group has to be signalled and the pipes have to be TORN DOWN rather than trusted
|
|
530
|
+
* to close. These are the same two rules for the children that adapters start directly.
|
|
531
|
+
*
|
|
532
|
+
* The reason this matters more than an ordinary bug: a tool somebody runs before a release
|
|
533
|
+
* that never comes back cannot be told apart from a tool that is broken, or from a product
|
|
534
|
+
* that is broken. A run that gives up after a bounded wait and says exactly what it was
|
|
535
|
+
* waiting for is worse news and better information.
|
|
536
|
+
*/
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* A number of milliseconds that is definitely a number of milliseconds.
|
|
540
|
+
*
|
|
541
|
+
* Every limit in this tool can be set from a project's own settings file, and a settings file
|
|
542
|
+
* is written by a person. `Number("30s")` is NaN — and NaN is the most dangerous value a limit
|
|
543
|
+
* can take, because `Date.now() + NaN` is NaN, `Date.now() > NaN` is false for ever, and a
|
|
544
|
+
* loop written around that runs until somebody kills it and never says why. A limit that is
|
|
545
|
+
* not a real, positive, finite number becomes the default instead, and one that is absurd is
|
|
546
|
+
* capped, because "wait thirty days" is a typo every time.
|
|
547
|
+
*
|
|
548
|
+
* @param {unknown} value
|
|
549
|
+
* @param {number} fallback
|
|
550
|
+
* @param {number} [ceiling]
|
|
551
|
+
* @returns {number}
|
|
552
|
+
*/
|
|
553
|
+
export function boundedMs(value, fallback, ceiling = 30 * 60_000) {
|
|
554
|
+
const asked = typeof value === 'number' ? value : Number(value);
|
|
555
|
+
if (!Number.isFinite(asked) || asked <= 0) return fallback;
|
|
556
|
+
return Math.min(asked, ceiling);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* A count that is definitely a count. The same guard as `boundedMs`, for the numbers that are
|
|
561
|
+
* a number of times round a loop rather than a number of milliseconds.
|
|
562
|
+
*
|
|
563
|
+
* @param {unknown} value
|
|
564
|
+
* @param {number} fallback
|
|
565
|
+
* @param {number} ceiling
|
|
566
|
+
* @returns {number}
|
|
567
|
+
*/
|
|
568
|
+
export function boundedCount(value, fallback, ceiling) {
|
|
569
|
+
const asked = typeof value === 'number' ? value : Number(value);
|
|
570
|
+
if (!Number.isFinite(asked) || asked < 1) return fallback;
|
|
571
|
+
return Math.min(Math.round(asked), ceiling);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Let go of a child completely, so nothing it left behind can hold this tool open.
|
|
576
|
+
*
|
|
577
|
+
* The pipes are destroyed rather than left to close on their own, because whatever the child
|
|
578
|
+
* started inherited the writing end of them and a survivor this file did not start must not
|
|
579
|
+
* be able to keep a finished check awake. This is the same tear-down `child.js` does for a
|
|
580
|
+
* start command's server, for the same measured reason.
|
|
581
|
+
*
|
|
582
|
+
* @param {import('node:child_process').ChildProcess|null|undefined} child
|
|
583
|
+
* @returns {void}
|
|
584
|
+
*/
|
|
585
|
+
export function letGoOf(child) {
|
|
586
|
+
if (!child) return;
|
|
587
|
+
for (const stream of [child.stdout, child.stderr, child.stdin]) {
|
|
588
|
+
try { stream?.destroy(); } catch { /* already gone, which is the outcome wanted */ }
|
|
589
|
+
}
|
|
590
|
+
try { child.unref(); } catch { /* not every child can be unreferenced; it has been let go either way */ }
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Stop a child, and everything it started, as firmly as the platform allows.
|
|
595
|
+
*
|
|
596
|
+
* Signalling a negative pid signals the whole process GROUP, which is the only way to reach
|
|
597
|
+
* the grandchildren — a start command's shell runs npm which runs node, and killing the shell
|
|
598
|
+
* leaves the other two running. That only works for a child that was started in a group of
|
|
599
|
+
* its own, so `group` is the caller's promise that it spawned with `detached`. Passing it for
|
|
600
|
+
* a child that is in OUR group would ask this process to kill itself.
|
|
601
|
+
*
|
|
602
|
+
* @param {import('node:child_process').ChildProcess} child
|
|
603
|
+
* @param {NodeJS.Signals} signal
|
|
604
|
+
* @param {boolean} group
|
|
605
|
+
* @returns {void}
|
|
606
|
+
*/
|
|
607
|
+
export function tellItToStop(child, signal, group) {
|
|
608
|
+
const pid = child.pid;
|
|
609
|
+
if (group && pid && process.platform !== 'win32') {
|
|
610
|
+
try {
|
|
611
|
+
process.kill(-pid, signal);
|
|
612
|
+
return;
|
|
613
|
+
} catch { /* no group of that name, or already gone; fall through and ask the one process */ }
|
|
614
|
+
}
|
|
615
|
+
try { child.kill(signal); } catch { /* already gone, which is the outcome wanted */ }
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Wait for a child to be GONE, with a limit that always fires and always says something.
|
|
620
|
+
*
|
|
621
|
+
* The important line in here is that it settles on `exit` and not on `close`. `exit` means the
|
|
622
|
+
* program ended. `close` means the program ended AND nobody anywhere is holding its pipes any
|
|
623
|
+
* more, which is a completely different claim and one an orphaned grandchild can refuse for
|
|
624
|
+
* ever. Every `close`-shaped wait in this folder was a hang waiting to happen, and one of them
|
|
625
|
+
* was measured hanging on 2026-08-31.
|
|
626
|
+
*
|
|
627
|
+
* After `exit` the last of the output is still worth having, so there is a short drain — but
|
|
628
|
+
* it is a drain with a clock on it, not a wait.
|
|
629
|
+
*
|
|
630
|
+
* @param {import('node:child_process').ChildProcess} child
|
|
631
|
+
* @param {object} [opts]
|
|
632
|
+
* @param {number} [opts.limitMs] How long the program itself may take. Default two minutes.
|
|
633
|
+
* @param {number} [opts.drainMs] How long to wait for the last of its output after it ends.
|
|
634
|
+
* @param {number} [opts.graceMs] Between asking it to stop and insisting.
|
|
635
|
+
* @param {boolean} [opts.group] True only when it was spawned with `detached`.
|
|
636
|
+
* @param {string} [opts.what] Named in the sentence, so a give-up can be acted on.
|
|
637
|
+
* @returns {Promise<{code: number|null, signal: string|null, gaveUp: boolean, why: string}>}
|
|
638
|
+
*/
|
|
639
|
+
export function endOfChild(child, opts = {}) {
|
|
640
|
+
const limitMs = boundedMs(opts.limitMs, 120_000);
|
|
641
|
+
const drainMs = boundedMs(opts.drainMs, 250, 10_000);
|
|
642
|
+
const graceMs = boundedMs(opts.graceMs, 2000, 60_000);
|
|
643
|
+
const group = opts.group === true;
|
|
644
|
+
const what = opts.what ?? 'the program it started';
|
|
645
|
+
|
|
646
|
+
return new Promise((resolve) => {
|
|
647
|
+
let settled = false;
|
|
648
|
+
/** @type {ReturnType<typeof setTimeout>[]} */
|
|
649
|
+
const timers = [];
|
|
650
|
+
/** @param {number} ms @param {() => void} fn */
|
|
651
|
+
const later = (ms, fn) => {
|
|
652
|
+
const timer = setTimeout(fn, ms);
|
|
653
|
+
// The limit itself must never be the thing holding the loop open.
|
|
654
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
655
|
+
timers.push(timer);
|
|
656
|
+
return timer;
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
// Set the moment the limit fires, and read by every path out of here. A program that
|
|
660
|
+
// stops promptly when it is asked to still ran out of time, and reporting that as a clean
|
|
661
|
+
// exit is how a run that checked nothing comes back looking green.
|
|
662
|
+
let ranOutOfTime = false;
|
|
663
|
+
const gaveUpBecause = () => `Stays Fixed gave up waiting for ${what} after ${Math.round(limitMs / 1000)} seconds and stopped it. Nothing it would have done after that point was checked.`;
|
|
664
|
+
|
|
665
|
+
/** @param {string} why */
|
|
666
|
+
const finish = (why) => {
|
|
667
|
+
if (settled) return;
|
|
668
|
+
settled = true;
|
|
669
|
+
for (const timer of timers) clearTimeout(timer);
|
|
670
|
+
letGoOf(child);
|
|
671
|
+
resolve({ code: child.exitCode, signal: child.signalCode, gaveUp: ranOutOfTime, why: ranOutOfTime ? gaveUpBecause() : why });
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
child.once('close', () => finish(`${what} finished.`));
|
|
675
|
+
child.once('exit', () => {
|
|
676
|
+
later(drainMs, () => finish(`${what} finished, and the last of its output was cut off after ${Math.round(drainMs)}ms because something it started was still holding its pipes open.`));
|
|
677
|
+
});
|
|
678
|
+
child.once('error', (error) => finish(`${what} could not be run: ${error.message}`));
|
|
679
|
+
|
|
680
|
+
later(limitMs, () => {
|
|
681
|
+
ranOutOfTime = true;
|
|
682
|
+
tellItToStop(child, 'SIGTERM', group);
|
|
683
|
+
later(graceMs, () => {
|
|
684
|
+
tellItToStop(child, 'SIGKILL', group);
|
|
685
|
+
// Even SIGKILL cannot make a pipe close while an orphan holds it, so this is where
|
|
686
|
+
// the waiting stops whatever anything else does.
|
|
687
|
+
finish(gaveUpBecause());
|
|
688
|
+
});
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
// It may already be over before anybody looked.
|
|
692
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
693
|
+
later(drainMs, () => finish(`${what} had already finished.`));
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Put a limit on any wait at all, and make the limit say what it was waiting for.
|
|
700
|
+
*
|
|
701
|
+
* For the waits that are not a child process: a socket that accepts a connection and then goes
|
|
702
|
+
* quiet, a window that never opens, a handler that never answers. The sentence is the whole
|
|
703
|
+
* point of it — "it timed out" and "it never came back" are the same non-answer, while "it
|
|
704
|
+
* gave up after sixty seconds waiting for the app to open its main-process debugging
|
|
705
|
+
* connection" is something a person can act on.
|
|
706
|
+
*
|
|
707
|
+
* `what` may be a function, so that a wait made of several stages can name the stage it was
|
|
708
|
+
* actually stuck in rather than the whole job. "It gave up opening the app" sends somebody
|
|
709
|
+
* looking everywhere; "it gave up while waiting for the app to open a window" sends them to one
|
|
710
|
+
* place.
|
|
711
|
+
*
|
|
712
|
+
* @template T
|
|
713
|
+
* @param {Promise<T>} promise
|
|
714
|
+
* @param {{limitMs: number, what: string|(() => string), fallbackMs?: number}} opts
|
|
715
|
+
* @returns {Promise<T>}
|
|
716
|
+
*/
|
|
717
|
+
export function withLimit(promise, opts) {
|
|
718
|
+
const limitMs = boundedMs(opts.limitMs, opts.fallbackMs ?? 60_000);
|
|
719
|
+
/** @type {ReturnType<typeof setTimeout>} */
|
|
720
|
+
let timer;
|
|
721
|
+
const giveUp = new Promise((_resolve, reject) => {
|
|
722
|
+
timer = setTimeout(
|
|
723
|
+
() => reject(new Error(`Stays Fixed gave up after ${Math.round(limitMs / 1000)} seconds waiting for ${typeof opts.what === 'function' ? opts.what() : opts.what}.`)),
|
|
724
|
+
limitMs,
|
|
725
|
+
);
|
|
726
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
727
|
+
});
|
|
728
|
+
return Promise.race([promise, giveUp]).finally(() => clearTimeout(timer));
|
|
729
|
+
}
|
|
730
|
+
|
|
505
731
|
// ---------------------------------------------------------------------------
|
|
506
732
|
// Running one command
|
|
507
733
|
// ---------------------------------------------------------------------------
|
|
@@ -521,12 +747,27 @@ export function compareTrees(before, after) {
|
|
|
521
747
|
*/
|
|
522
748
|
|
|
523
749
|
/**
|
|
524
|
-
* Run a command and wait for it, with a hard limit.
|
|
750
|
+
* Run a command and wait for it, with a hard limit that is actually hard.
|
|
525
751
|
*
|
|
526
752
|
* Killed with SIGTERM first and SIGKILL after a grace period, because a program that traps
|
|
527
753
|
* SIGTERM and hangs would otherwise hold the whole run open — and killed is reported as
|
|
528
754
|
* killed, never quietly as an exit code.
|
|
529
755
|
*
|
|
756
|
+
* Two things in here are not decoration, and both were measured on 2026-08-31 against a start
|
|
757
|
+
* command that leaves an orphan behind — the shape `npm run dev` has, and the shape that had
|
|
758
|
+
* already been recorded once as an Electron check that gave no output at all and never came
|
|
759
|
+
* back.
|
|
760
|
+
*
|
|
761
|
+
* The command runs in a process GROUP of its own, because the thing spawned is a shell and the
|
|
762
|
+
* program is its child or grandchild. Killing the shell leaves the program running, and the
|
|
763
|
+
* limit then achieves nothing at all. This is the same finding `child.js` made about servers.
|
|
764
|
+
*
|
|
765
|
+
* And the answer is settled by `endOfChild`, which waits for the command to END rather than
|
|
766
|
+
* for its pipes to close. Waiting for `close` was the actual bug: asked for a two second
|
|
767
|
+
* limit, this function had not returned twenty seconds later, because the orphan was still
|
|
768
|
+
* holding the writing end of the pipes and `close` will not fire until every last holder lets
|
|
769
|
+
* go. The limit fired perfectly and nobody was listening.
|
|
770
|
+
*
|
|
530
771
|
* @param {string} command Run through the shell, so a project can write what it means.
|
|
531
772
|
* @param {object} opts
|
|
532
773
|
* @param {string} opts.cwd
|
|
@@ -536,69 +777,68 @@ export function compareTrees(before, after) {
|
|
|
536
777
|
* @param {AbortSignal} [opts.signal]
|
|
537
778
|
* @returns {Promise<CommandResult>}
|
|
538
779
|
*/
|
|
539
|
-
export function runCommand(command, opts) {
|
|
540
|
-
const timeoutMs = opts.timeoutMs
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
let timedOut = false;
|
|
554
|
-
let settled = false;
|
|
780
|
+
export async function runCommand(command, opts) {
|
|
781
|
+
const timeoutMs = boundedMs(opts.timeoutMs, 120000);
|
|
782
|
+
const started = Date.now();
|
|
783
|
+
// Its own process group, so the limit can reach the program and not only the shell in front
|
|
784
|
+
// of it. There are no groups of this kind on Windows, where signalling the child is the best
|
|
785
|
+
// that can be done.
|
|
786
|
+
const inItsOwnGroup = process.platform !== 'win32';
|
|
787
|
+
const child = spawn(command, {
|
|
788
|
+
shell: true,
|
|
789
|
+
cwd: opts.cwd,
|
|
790
|
+
env: opts.env,
|
|
791
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
792
|
+
detached: inItsOwnGroup,
|
|
793
|
+
});
|
|
555
794
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
795
|
+
/** @type {Buffer[]} */
|
|
796
|
+
const out = [];
|
|
797
|
+
/** @type {Buffer[]} */
|
|
798
|
+
const err = [];
|
|
799
|
+
|
|
800
|
+
child.stdout?.on('data', (chunk) => out.push(chunk));
|
|
801
|
+
child.stderr?.on('data', (chunk) => err.push(chunk));
|
|
802
|
+
if (opts.stdin !== undefined) child.stdin?.end(opts.stdin);
|
|
803
|
+
else child.stdin?.end();
|
|
804
|
+
|
|
805
|
+
/** @type {string|undefined} */
|
|
806
|
+
let couldNotStart;
|
|
807
|
+
child.on('error', (error) => {
|
|
808
|
+
// Nothing ran. Said out loud rather than folded into "exit code null", which is what a
|
|
809
|
+
// killed run also looks like — and which is identical on both builds, so the comparison
|
|
810
|
+
// saw no difference and the run passed for the worst possible reason.
|
|
811
|
+
couldNotStart = error.message;
|
|
812
|
+
err.push(Buffer.from(`${error.message}\n`));
|
|
813
|
+
});
|
|
560
814
|
|
|
561
|
-
|
|
562
|
-
|
|
815
|
+
// The whole group, not the shell. Stopping the shell and leaving npm and node running is
|
|
816
|
+
// how a cancelled run leaves a dev server on somebody's machine.
|
|
817
|
+
const onAbort = () => { tellItToStop(child, 'SIGTERM', inItsOwnGroup); };
|
|
818
|
+
opts.signal?.addEventListener('abort', onAbort, { once: true });
|
|
819
|
+
|
|
820
|
+
const ended = await endOfChild(child, {
|
|
821
|
+
limitMs: timeoutMs,
|
|
822
|
+
graceMs: 5000,
|
|
823
|
+
group: inItsOwnGroup,
|
|
824
|
+
// Both ends of the command, never just the first eighty characters. A command that lives
|
|
825
|
+
// under a long scratch path is all path for the first eighty characters, so cutting from
|
|
826
|
+
// the front produces a sentence naming a FOLDER and never the thing it gave up on.
|
|
827
|
+
what: `"${trimForStorage(String(command), 120).text}"`,
|
|
828
|
+
});
|
|
829
|
+
opts.signal?.removeEventListener('abort', onAbort);
|
|
563
830
|
|
|
564
|
-
|
|
565
|
-
if (settled) return;
|
|
566
|
-
settled = true;
|
|
567
|
-
clearTimeout(alarm);
|
|
568
|
-
clearTimeout(hardStop);
|
|
569
|
-
opts.signal?.removeEventListener('abort', onAbort);
|
|
570
|
-
resolve({
|
|
571
|
-
stdout: Buffer.concat(out).toString('utf8'),
|
|
572
|
-
stderr: Buffer.concat(err).toString('utf8'),
|
|
573
|
-
code,
|
|
574
|
-
signal,
|
|
575
|
-
timedOut,
|
|
576
|
-
ms: Date.now() - started,
|
|
577
|
-
...(couldNotStart ? { couldNotStart } : {}),
|
|
578
|
-
});
|
|
579
|
-
};
|
|
831
|
+
if (ended.gaveUp) err.push(Buffer.from(`${ended.why}\n`));
|
|
580
832
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
opts.signal?.addEventListener('abort', onAbort, { once: true });
|
|
591
|
-
|
|
592
|
-
child.on('error', (error) => {
|
|
593
|
-
// Nothing ran. Said out loud rather than folded into "exit code null", which is what a
|
|
594
|
-
// killed run also looks like — and which is identical on both builds, so the comparison
|
|
595
|
-
// saw no difference and the run passed for the worst possible reason.
|
|
596
|
-
couldNotStart = error.message;
|
|
597
|
-
err.push(Buffer.from(`${error.message}\n`));
|
|
598
|
-
finish(null, null);
|
|
599
|
-
});
|
|
600
|
-
child.on('close', (code, signal) => finish(code, signal));
|
|
601
|
-
});
|
|
833
|
+
return {
|
|
834
|
+
stdout: Buffer.concat(out).toString('utf8'),
|
|
835
|
+
stderr: Buffer.concat(err).toString('utf8'),
|
|
836
|
+
code: ended.code,
|
|
837
|
+
signal: ended.signal,
|
|
838
|
+
timedOut: ended.gaveUp,
|
|
839
|
+
ms: Date.now() - started,
|
|
840
|
+
...(couldNotStart ? { couldNotStart } : {}),
|
|
841
|
+
};
|
|
602
842
|
}
|
|
603
843
|
|
|
604
844
|
// ---------------------------------------------------------------------------
|
|
@@ -728,38 +968,27 @@ export async function copyForScratch(from, to, opts = {}) {
|
|
|
728
968
|
* @param {AbortSignal} [signal]
|
|
729
969
|
* @returns {Promise<boolean>}
|
|
730
970
|
*/
|
|
731
|
-
function cloneOne(source, target, signal) {
|
|
971
|
+
async function cloneOne(source, target, signal) {
|
|
732
972
|
// Windows has no reflink through `cp`, and there is no `cp`. Straight to the fallback.
|
|
733
|
-
if (process.platform === 'win32') return
|
|
973
|
+
if (process.platform === 'win32') return false;
|
|
734
974
|
const args = process.platform === 'darwin'
|
|
735
975
|
? ['-Rc', source, target]
|
|
736
976
|
: ['-a', '--reflink=auto', source, target];
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
child.on('error', () => done(false));
|
|
753
|
-
child.on('close', (code) => {
|
|
754
|
-
if (code === 0) {
|
|
755
|
-
done(true);
|
|
756
|
-
return;
|
|
757
|
-
}
|
|
758
|
-
// A half-written target from a failed clone would make the fallback copy merge into
|
|
759
|
-
// it. Clear it out so the fallback starts from nothing.
|
|
760
|
-
fsp.rm(target, { recursive: true, force: true }).then(() => done(false), () => done(false));
|
|
761
|
-
});
|
|
762
|
-
});
|
|
977
|
+
let child;
|
|
978
|
+
try {
|
|
979
|
+
child = spawn('cp', args, { stdio: 'ignore', signal });
|
|
980
|
+
} catch {
|
|
981
|
+
return false;
|
|
982
|
+
}
|
|
983
|
+
// Ten minutes is far longer than a clone of a project tree has ever taken and still a limit.
|
|
984
|
+
// `cp` on a network mount that stops answering hangs for ever otherwise, and a copy that
|
|
985
|
+
// never finishes looks to whoever ran the check exactly like the check being broken.
|
|
986
|
+
const ended = await endOfChild(child, { limitMs: 10 * 60_000, what: `copying ${path.basename(source)} into the scratch build` });
|
|
987
|
+
if (!ended.gaveUp && ended.code === 0) return true;
|
|
988
|
+
// A half-written target from a failed clone would make the fallback copy merge into it.
|
|
989
|
+
// Clear it out so the fallback starts from nothing.
|
|
990
|
+
await fsp.rm(target, { recursive: true, force: true }).catch(() => {});
|
|
991
|
+
return false;
|
|
763
992
|
}
|
|
764
993
|
|
|
765
994
|
// ---------------------------------------------------------------------------
|
|
@@ -1428,15 +1657,44 @@ export async function describeRun(input) {
|
|
|
1428
1657
|
}
|
|
1429
1658
|
|
|
1430
1659
|
// ---- how it finished
|
|
1431
|
-
|
|
1660
|
+
// DID THIS WALK EVER REACH THE PRODUCT? Said out loud, in one place, because it is the one
|
|
1661
|
+
// question nothing downstream can work out for itself.
|
|
1662
|
+
//
|
|
1663
|
+
// A command that fails to SPAWN has always been reported here. The other half was missing
|
|
1664
|
+
// until 2026-08-31: a command that spawns perfectly, throws on its first line, prints
|
|
1665
|
+
// nothing and exits non-zero. That walk fills the complaints channel with a real stack
|
|
1666
|
+
// trace and a real exit code — both genuine facts, and both facts about a crash rather than
|
|
1667
|
+
// about the product. Two builds that crash the same way agree at every one of those
|
|
1668
|
+
// addresses, so the run came back "Nothing that worked has changed. 7 addresses checked"
|
|
1669
|
+
// about a product whose entire output had been rewritten in between; `ship` blessed it; and
|
|
1670
|
+
// the day it was fixed, every real value differed from the stored crash and four findings
|
|
1671
|
+
// arrived that nobody had caused.
|
|
1672
|
+
//
|
|
1673
|
+
// The stdout test is what keeps this narrow, and it is the honest line. A command that
|
|
1674
|
+
// printed something got somewhere, and what it printed is a real observation of the product
|
|
1675
|
+
// however it ended — a linter that exits 1 with a list of problems is being observed
|
|
1676
|
+
// properly and must go on being compared. A command that printed nothing and then died
|
|
1677
|
+
// never reached the product at all.
|
|
1678
|
+
const neverReachedIt =
|
|
1679
|
+
result.couldNotStart
|
|
1680
|
+
|| (result.stdout.trim() === '' && (result.timedOut || result.signal !== null || (result.code !== null && result.code !== 0)));
|
|
1681
|
+
if (neverReachedIt) {
|
|
1682
|
+
const why = result.couldNotStart
|
|
1683
|
+
? `it never started: ${result.couldNotStart}`
|
|
1684
|
+
: result.timedOut
|
|
1685
|
+
? 'it was still running when its time ran out and had printed nothing'
|
|
1686
|
+
: result.signal
|
|
1687
|
+
? `it was killed by ${result.signal} having printed nothing`
|
|
1688
|
+
: `it exited ${result.code} without printing anything at all`;
|
|
1432
1689
|
out.push(notCovered({
|
|
1433
1690
|
channel: 'complaints',
|
|
1434
1691
|
path: joinPath('cli', id, 'ran at all'),
|
|
1435
1692
|
reason: 'crashed',
|
|
1436
1693
|
says:
|
|
1437
|
-
`"${journey.describe}"
|
|
1438
|
-
`
|
|
1439
|
-
`
|
|
1694
|
+
`"${journey.describe}" did not get far enough to observe the product: ${why}. What it complained about and how ` +
|
|
1695
|
+
`it finished are recorded below and are facts about the crash, not about the product — so nothing here is ` +
|
|
1696
|
+
`compared with the other build. A command that fails the same way on both builds otherwise agrees at every ` +
|
|
1697
|
+
`address, and that agreement reads exactly like a clean run.`,
|
|
1440
1698
|
}));
|
|
1441
1699
|
}
|
|
1442
1700
|
out.push(observation({
|
|
@@ -729,6 +729,9 @@ export const IRREVERSIBLE = Object.freeze([
|
|
|
729
729
|
* @property {JsonValue} [answered] The answer, for the app's own calls that speak JSON.
|
|
730
730
|
* @property {JsonValue} [shape] The fields the answer carries and what type each one is.
|
|
731
731
|
* @property {string} [failed] Why it never finished.
|
|
732
|
+
* @property {boolean} [unfinishedAtTeardown] It was still in flight when the walk closed the
|
|
733
|
+
* page, so how it ended was never seen. A hole, and never
|
|
734
|
+
* a complaint: the abort is our own doing.
|
|
732
735
|
* @property {number} times
|
|
733
736
|
*/
|
|
734
737
|
|
|
@@ -838,10 +841,28 @@ export async function watchTheWire(page, opts) {
|
|
|
838
841
|
);
|
|
839
842
|
});
|
|
840
843
|
|
|
844
|
+
// Whether the walk has started packing up. A request that was still in flight when WE
|
|
845
|
+
// closed the page is aborted by the closing, and that is the measurement's own footprint
|
|
846
|
+
// rather than anything the product did — the browser reports it exactly like a real
|
|
847
|
+
// failure. Next.js starts a prefetch behind every internal link, so on a two-page app the
|
|
848
|
+
// address `.../never finished` appeared in roughly four runs in five, at random, and two
|
|
849
|
+
// byte-identical runs disagreed about whether the product had a problem. Measured
|
|
850
|
+
// 2026-08-31.
|
|
851
|
+
let packingUp = false;
|
|
852
|
+
const OUR_OWN_DOING = /ERR_ABORTED|context or browser has been closed|Target closed/i;
|
|
853
|
+
|
|
841
854
|
page.on('requestfailed', (/** @type {any} */ request) => {
|
|
842
855
|
const entry = entryFor(String(request.method()).toUpperCase(), String(request.url()));
|
|
843
856
|
if (entry.refused) return;
|
|
844
|
-
|
|
857
|
+
const failure = String(request.failure()?.errorText ?? 'it did not finish');
|
|
858
|
+
if (packingUp && OUR_OWN_DOING.test(failure)) {
|
|
859
|
+
// Recorded as a HOLE, not dropped and not turned into a complaint. Something was still
|
|
860
|
+
// being asked for and we never saw how it ended, which is missing coverage — louder
|
|
861
|
+
// than a complaint, never quieter.
|
|
862
|
+
entry.unfinishedAtTeardown = true;
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
entry.failed = failure;
|
|
845
866
|
});
|
|
846
867
|
|
|
847
868
|
return {
|
|
@@ -850,6 +871,7 @@ export async function watchTheWire(page, opts) {
|
|
|
850
871
|
await withLimit(Promise.all(reading.splice(0)), 15000, []);
|
|
851
872
|
},
|
|
852
873
|
stop: async () => {
|
|
874
|
+
packingUp = true;
|
|
853
875
|
try {
|
|
854
876
|
await page.unroute('**/*');
|
|
855
877
|
} catch {
|
package/src/v2/adapters/web.js
CHANGED
|
@@ -524,6 +524,11 @@ export const webAdapter = defineAdapter({
|
|
|
524
524
|
tmp,
|
|
525
525
|
extra: {
|
|
526
526
|
PORT: String(port),
|
|
527
|
+
// Asked for, not relied on. Plenty of dev servers never look at HOST — Vite is one,
|
|
528
|
+
// measured on 2026-08-31: it ignores both HOST and PORT and binds the name
|
|
529
|
+
// `localhost`, which this Mac resolves to the IPv6 loopback. That is why the boot
|
|
530
|
+
// check knocks on both loopback addresses and uses whichever one answers, rather
|
|
531
|
+
// than trusting this line to have been obeyed.
|
|
527
532
|
HOST: '127.0.0.1',
|
|
528
533
|
NODE_ENV: config.nodeEnv ?? 'production',
|
|
529
534
|
...config.env,
|
|
@@ -556,7 +561,21 @@ export const webAdapter = defineAdapter({
|
|
|
556
561
|
exited = `The app stopped before it answered - exit code ${code}${signal ? `, killed by ${signal}` : ''}.`;
|
|
557
562
|
});
|
|
558
563
|
|
|
559
|
-
|
|
564
|
+
// WHY THIS HANDS OVER SO MUCH. Measured on 2026-08-31 on a freshly scaffolded Vite app:
|
|
565
|
+
// this wait took 90.6 seconds and then said "the server never answered on port 64912",
|
|
566
|
+
// which names neither the command that was run nor the fact that the server was up the
|
|
567
|
+
// whole time on the other loopback address. A whole `check --paired` on that app took
|
|
568
|
+
// 3 minutes 2 seconds and reported nothing a person could act on. So the wait is now
|
|
569
|
+
// given the command, everything the command has printed, and somewhere to say what it is
|
|
570
|
+
// waiting for while it waits — that is what turns ninety seconds of silence into a
|
|
571
|
+
// sentence, usually in the first second or two.
|
|
572
|
+
const up = await waitForServer(port, {
|
|
573
|
+
timeoutMs: config.startTimeoutMs ?? 90000,
|
|
574
|
+
crashed: () => exited,
|
|
575
|
+
command: String(config.start),
|
|
576
|
+
announced: () => Buffer.concat(said).toString('utf8'),
|
|
577
|
+
say: (message) => ctx.log?.(message),
|
|
578
|
+
});
|
|
560
579
|
if (!up.up) {
|
|
561
580
|
await stopServer(child);
|
|
562
581
|
return {
|
|
@@ -571,13 +590,17 @@ export const webAdapter = defineAdapter({
|
|
|
571
590
|
};
|
|
572
591
|
}
|
|
573
592
|
|
|
574
|
-
|
|
593
|
+
// The address that ANSWERED, not the address that was assumed. A server told to listen on
|
|
594
|
+
// `localhost` lands on whichever of the two loopback addresses this machine resolves that
|
|
595
|
+
// name to, and on this Mac that is the IPv6 one — so handing the browser a hard-coded
|
|
596
|
+
// `http://127.0.0.1:...` would open a page that cannot load even though the app is up.
|
|
597
|
+
const baseUrl = up.baseUrl ?? `http://127.0.0.1:${port}`;
|
|
575
598
|
running.set(build.id, { base, baseUrl, port, child, config, playwright, paired: true, work, home, tmp });
|
|
576
599
|
return {
|
|
577
600
|
build,
|
|
578
601
|
root: work,
|
|
579
602
|
ready: true,
|
|
580
|
-
why: `${copy.why} It came up
|
|
603
|
+
why: `${copy.why} It came up at ${baseUrl} in ${timeBucket(up.ms)}, in a browser profile nobody else is using.${notes.length > 0 ? ` ${notes.join(' ')}` : ''}`,
|
|
581
604
|
facts: { baseUrl, port, paired: true },
|
|
582
605
|
dispose: async () => {
|
|
583
606
|
const held = running.get(build.id);
|
|
@@ -1022,6 +1045,22 @@ export function describeTraffic(journey, calls, footprint) {
|
|
|
1022
1045
|
surface: 'web',
|
|
1023
1046
|
}),
|
|
1024
1047
|
);
|
|
1048
|
+
} else if (call.unfinishedAtTeardown) {
|
|
1049
|
+
// Missing coverage, not a complaint. The page was still asking for this when the walk
|
|
1050
|
+
// ended, and the abort that followed is this tool closing the page — reporting it as
|
|
1051
|
+
// the product failing made an unchanged tree look broken in four runs out of five.
|
|
1052
|
+
// Recorded rather than dropped: something was being asked for and how it ended was
|
|
1053
|
+
// never seen, which is a hole, and a hole is louder than a complaint, never quieter.
|
|
1054
|
+
out.push(
|
|
1055
|
+
notCovered({
|
|
1056
|
+
channel: 'effects',
|
|
1057
|
+
path: `${where}.how it finished`,
|
|
1058
|
+
reason: 'refused',
|
|
1059
|
+
says:
|
|
1060
|
+
`The page was still asking for ${asked} when the walk ended, so how that finished was never seen. ` +
|
|
1061
|
+
`The request was cancelled by this tool closing the page, which is not the product doing anything wrong.`,
|
|
1062
|
+
}),
|
|
1063
|
+
);
|
|
1025
1064
|
}
|
|
1026
1065
|
if (call.status !== undefined) {
|
|
1027
1066
|
out.push(
|