fix(scripts): don't arm a survival timer after the verdict is settled (#2147)

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>
This commit is contained in:
cliffhall 2026-08-26 22:01:08 -04:00
commit 4af38c719c

View file

@ -246,6 +246,15 @@ export function runRenderSmoke({
// <marker>" — quoting an output tail that visibly contains the marker.
// First paint has happened; its deadline no longer has a question to ask.
clearTimeout(renderTimer);
// `exit` can beat the last of the child's buffered output, and `done`
// then keeps reading until `close` — so this can run *after* the verdict
// is settled. Recording `markerAt` is still right (the deferred message
// below reads it, and a marker that only surfaced in the drain was still
// painted). Arming a timer is not: `done` is a no-op once settled, so it
// could not change the result, but it holds the event loop open for the
// whole survival window — which under `node --test` is a suite that
// hangs on for two seconds per case for no reason.
if (settled) return;
// First paint is not the verdict — it only starts the clock on the
// question this smoke actually asks.
surviveTimer = setTimeout(() => {
@ -267,14 +276,25 @@ export function runRenderSmoke({
child.on("exit", (code, signal) => {
childExited = true;
const exitAt = since();
if (settled) return;
const how = signal ? `signal ${signal}` : `code ${code}`;
// Thunked: rendered at `finish()`, i.e. after `close`, so `markerAt` is
// whatever the FULL output showed — a marker still sitting in the pipe
// when `exit` fired is accounted for by the time this is read.
//
// Deliberately makes no claim about the order in which this process
// *observed* the two. `exit` and the last `data` event race, so a
// "N ms later" phrased off observation times would flip wording (and
// sign) run to run on the same child. The times are reported as the
// observations they are and the verdict does not rest on them.
done(1, () =>
markerAt === null
? `child exited (${how}) before rendering "${marker}"\n${outputTail(output)}`
: `child rendered "${marker}" at ${markerAt}ms and then exited (${how}) ` +
`${since() - markerAt}ms later, inside the ${surviveMs}ms survival window — ` +
`it painted one frame, it did not run\n${outputTail(output)}`,
: `child rendered "${marker}" and then exited (${how}) inside the ` +
`${surviveMs}ms survival window — it painted one frame, it did not run ` +
`(first paint seen at ${markerAt}ms, exit at ${exitAt}ms)` +
`\n${outputTail(output)}`,
);
});