Copilot review round 4. `exit` can be observed before the child's last output drains — `done()` already keeps reading until `close` for exactly that reason — so `onData` can run after the verdict is settled. Recording `markerAt` there is still right: the message is a thunk rendered at `finish()`, and a marker still sitting in the pipe when `exit` fired was painted all the same. Arming a survival timer is not. It could not change the result (`done` is a no-op once settled), but it holds the event loop open for the whole window, which under `node --test` is a suite lingering seconds for nothing. That implies something the comment did not say, and it had already bitten this PR: if `markerAt` can be set after the exit was observed, any wording derived from observation ORDER is unstable. The old message read "rendered at Nms and then exited Mms later", where M was `since() - markerAt` evaluated at finish — not the paint-to-exit gap at all, and incoherent whenever the marker surfaced during the drain. The test asserted on /survival window/, which the losing side of that race could drop. It could have flaked, and the flake would have looked like the bug this PR fixes. So the message now makes no ordering claim and reports both times as the observations they are, with `exitAt` captured in the exit handler rather than recomputed later. Assertions moved to the race-stable phrase, plus a test that runs the paint-then-die stub 12x asserting verdict AND wording are identical every time — one pass proves nothing about a race. The ordering could not be forced on darwin, so the guard is reasoned rather than reproduced, and the reply says so. Two attempts and what they actually showed: a 4MB payload with the marker last never gets there, because `process.exit()` discards pending stdout writes (the "before rendering" branch is correctly taken); marker on stderr with a graceful exitCode was observed first in all 30 runs, none lingering near the window. `done()` exists precisely because exit and close are separate events, so treating their order as guaranteed was worth removing anyway. Also (suppressed comment): the README's "Two more do the same" clause now lists four suites, not two, after this PR added render-smoke and pty. Signed-off-by: Cliff Hall <cliff@futurescale.com> Signed-off-by: cliffhall <cliff@futurescale.com>
192 lines
7.7 KiB
JavaScript
192 lines
7.7 KiB
JavaScript
import { strict as assert } from "node:assert";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
DEFAULTS,
|
|
normalizeMs,
|
|
outputTail,
|
|
runRenderSmoke,
|
|
} from "./render-smoke.mjs";
|
|
|
|
const MARKER = "MCP Servers";
|
|
|
|
/** A stub terminal app, driven as a real child process rather than a spy. */
|
|
function stub(body) {
|
|
return { command: process.execPath, args: ["-e", body] };
|
|
}
|
|
|
|
const paintThenExit = (code) =>
|
|
stub(`console.log(${JSON.stringify(MARKER)}); process.exit(${code});`);
|
|
const paintThenLive = stub(
|
|
`console.log(${JSON.stringify(MARKER)}); setInterval(() => {}, 1000);`,
|
|
);
|
|
|
|
const opts = {
|
|
marker: MARKER,
|
|
timeoutMs: 5000,
|
|
surviveMs: 400,
|
|
exitGraceMs: 500,
|
|
drainMs: 200,
|
|
warn: () => {},
|
|
};
|
|
|
|
// The regression this module exists for. The old harness resolved OK on the
|
|
// marker and never looked again, so this exact stub — paint, then die — passed.
|
|
test("fails when the child paints the marker and then exits non-zero", async () => {
|
|
const r = await runRenderSmoke({ ...opts, ...paintThenExit(1) });
|
|
assert.equal(r.code, 1, `expected failure, got: ${r.message}`);
|
|
assert.match(r.message, /code 1/);
|
|
assert.match(r.message, /it painted one frame, it did not run/);
|
|
// The diagnostic must not read as a crash *before* first paint — that was a
|
|
// different defect, and conflating them sends the reader to the wrong place.
|
|
assert.doesNotMatch(r.message, /before rendering/);
|
|
});
|
|
|
|
// `exit` races the child's last `data` event, so this stub can be observed
|
|
// either way round on the same machine. Both orders must produce the same
|
|
// verdict AND the same wording — a message phrased off observation order would
|
|
// flip run to run, which is how a stable failure gets written off as flake.
|
|
// Repeated because one pass proves nothing about a race.
|
|
test("the verdict does not depend on whether exit beats the final output", async () => {
|
|
for (let i = 0; i < 12; i++) {
|
|
const r = await runRenderSmoke({ ...opts, ...paintThenExit(1) });
|
|
assert.equal(r.code, 1, `run ${i}: expected failure, got: ${r.message}`);
|
|
assert.match(r.message, /it painted one frame, it did not run/, `run ${i}`);
|
|
assert.doesNotMatch(r.message, /before rendering/, `run ${i}`);
|
|
}
|
|
});
|
|
|
|
// Exiting 0 right after painting is the same failure: the app did not run. It
|
|
// is called out separately because `script(1)` under busybox reports 0 for a
|
|
// crashed child, so the verdict cannot rest on the exit code.
|
|
test("fails when the child paints the marker and then exits zero", async () => {
|
|
const r = await runRenderSmoke({ ...opts, ...paintThenExit(0) });
|
|
assert.equal(r.code, 1, `expected failure, got: ${r.message}`);
|
|
assert.match(r.message, /it painted one frame, it did not run/);
|
|
});
|
|
|
|
test("passes when the child paints the marker and keeps running", async () => {
|
|
const r = await runRenderSmoke({ ...opts, ...paintThenLive });
|
|
assert.equal(r.code, 0, `expected pass, got: ${r.message}`);
|
|
assert.match(r.message, /still running 400ms later/);
|
|
});
|
|
|
|
// The first-paint deadline and the survival window overlap: `done` is
|
|
// first-caller-wins, so an armed render timer firing during the survival window
|
|
// settles the run as "did not render" — while quoting an output tail that
|
|
// visibly contains the marker. A slow-but-fine TUI would fail, intermittently,
|
|
// with a diagnostic pointing at the wrong thing entirely.
|
|
test("a paint landing just under the deadline is not failed by the render timer", async () => {
|
|
const paintAt = 250;
|
|
const r = await runRenderSmoke({
|
|
...opts,
|
|
...stub(
|
|
`setTimeout(() => { console.log(${JSON.stringify(MARKER)}); ` +
|
|
`setInterval(() => {}, 1000); }, ${paintAt});`,
|
|
),
|
|
// Deliberately tight: the render deadline expires while the survival window
|
|
// is still open, which is the whole shape of the bug.
|
|
timeoutMs: paintAt + 150,
|
|
surviveMs: 400,
|
|
});
|
|
assert.equal(r.code, 0, `expected pass, got: ${r.message}`);
|
|
assert.doesNotMatch(r.message, /did not render/);
|
|
});
|
|
|
|
test("fails when the child exits before painting", async () => {
|
|
const r = await runRenderSmoke({
|
|
...opts,
|
|
...stub(`console.error("boom"); process.exit(3);`),
|
|
});
|
|
assert.equal(r.code, 1);
|
|
assert.match(r.message, /before rendering/);
|
|
assert.match(r.message, /code 3/);
|
|
// The tail is quoted after the drain, so the crash reason survives.
|
|
assert.match(r.message, /boom/);
|
|
});
|
|
|
|
test("fails when the child never paints, and kills it", async () => {
|
|
let spawned;
|
|
const r = await runRenderSmoke({
|
|
...opts,
|
|
...stub(`setInterval(() => {}, 1000);`),
|
|
timeoutMs: 300,
|
|
onSpawn: (c) => (spawned = c),
|
|
});
|
|
assert.equal(r.code, 1);
|
|
assert.match(r.message, /did not render/);
|
|
// The #2000 ownership rule: the child is published before the wait, so the
|
|
// timeout path leaves a stoppable handle rather than an orphan.
|
|
assert.ok(spawned, "onSpawn was never called");
|
|
assert.notEqual(
|
|
spawned.exitCode === null && spawned.signalCode === null,
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("reports a spawn failure rather than throwing it uncaught", async () => {
|
|
const r = await runRenderSmoke({
|
|
...opts,
|
|
command: "/definitely/not/a/binary-2147",
|
|
args: [],
|
|
});
|
|
assert.equal(r.code, 1);
|
|
assert.match(r.message, /failed to spawn/);
|
|
});
|
|
|
|
test("scans stderr as well as stdout for the marker", async () => {
|
|
// An Ink app writing to stderr is missed entirely by a stdout-only scan,
|
|
// which then times out with an empty diagnostic.
|
|
const r = await runRenderSmoke({
|
|
...opts,
|
|
...stub(
|
|
`console.error(${JSON.stringify(MARKER)}); setInterval(() => {}, 1000);`,
|
|
),
|
|
});
|
|
assert.equal(r.code, 0, `expected pass, got: ${r.message}`);
|
|
});
|
|
|
|
// `Number("")` is 0 and `Number("typo")` is NaN, and `??` catches neither
|
|
// because both are non-nullish. `setTimeout` fires immediately on either, so an
|
|
// un-normalized `surviveMs` resolves the survival check before the child could
|
|
// possibly have died — restoring the exact first-paint false green, on a run
|
|
// that still prints OK. This is the guard on the guard.
|
|
test("normalizeMs rejects the values a bad env var actually produces", () => {
|
|
assert.equal(normalizeMs(Number(""), 2000), 2000);
|
|
assert.equal(normalizeMs(Number("typo"), 2000), 2000);
|
|
assert.equal(normalizeMs(undefined, 2000), 2000);
|
|
assert.equal(normalizeMs(-1, 2000), 2000);
|
|
assert.equal(normalizeMs(Infinity, 2000), 2000);
|
|
assert.equal(normalizeMs("3000", 2000), 2000, "a string is not a duration");
|
|
// 0 is a typo, never "skip the check" — there is no caller who wants that.
|
|
assert.equal(normalizeMs(0, 2000), 2000);
|
|
// A real value passes through untouched.
|
|
assert.equal(normalizeMs(1234, 2000), 1234);
|
|
});
|
|
|
|
test("a zero survival window falls back rather than passing instantly", async () => {
|
|
// End to end through the real state machine: with surviveMs honored as 0 the
|
|
// paint-then-exit stub would settle OK before the child's exit was seen.
|
|
const r = await runRenderSmoke({
|
|
...opts,
|
|
...paintThenExit(1),
|
|
surviveMs: Number(""),
|
|
});
|
|
assert.equal(r.code, 1, `expected failure, got: ${r.message}`);
|
|
// The window named in the message is the normalized one, not the 0 passed in.
|
|
assert.match(
|
|
r.message,
|
|
new RegExp(`${DEFAULTS.surviveMs}ms survival window`),
|
|
);
|
|
assert.doesNotMatch(r.message, /\b0ms survival window/);
|
|
});
|
|
|
|
test("outputTail drops a leading partial line but keeps short output whole", () => {
|
|
assert.equal(outputTail("short", 800), "short");
|
|
// Over the limit: the slice can land mid-escape-sequence, so it starts after
|
|
// the first newline instead of leading with a fragment.
|
|
const long = "x".repeat(50) + "\nkeep me";
|
|
assert.equal(outputTail(long, 20), "keep me");
|
|
// No newline in the tail at all: nothing to trim to, so return it as sliced.
|
|
assert.equal(outputTail("y".repeat(50), 10), "y".repeat(10));
|
|
});
|