diff --git a/scripts/lib/render-smoke.test.mjs b/scripts/lib/render-smoke.test.mjs index a77b5468..f6df7ef2 100644 --- a/scripts/lib/render-smoke.test.mjs +++ b/scripts/lib/render-smoke.test.mjs @@ -86,20 +86,71 @@ test("passes when the child paints the marker and keeps running", async () => { // 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; + // The two clocks start at different moments: the child's `setTimeout` runs + // from its own first JS tick, while `renderTimer` is armed in the parent at + // spawn. A delay written as a plain `setTimeout(paint, N)` therefore lands at + // `N + startup`, so the deadline has to carry a startup allowance — and the + // case fails, as "did not render", whenever the machine is loaded enough to + // exceed it. It failed three `local:gate` runs in a row that way while + // passing every time standalone (#2177), and widening the allowance only + // moved the threshold (#2180). + // + // So the paint is anchored to the PARENT's clock instead: the child is told + // the wall-clock instant to paint at, and sleeps for whatever is left of it + // when it gets there. Startup is then *absorbed* by the sleep rather than + // added to it, and the paint lands at ~`PAINT_AT` measured from spawn no + // matter how slow the boot was. + const spawnedAt = Date.now(); + const PAINT_AT = 300; + const timeoutMs = 600; + const surviveMs = 900; + const r = await runRenderSmoke({ ...opts, ...stub( - `setTimeout(() => { console.log(${JSON.stringify(MARKER)}); ` + - `setInterval(() => {}, 1000); }, ${paintAt});`, + `const at = ${spawnedAt + PAINT_AT};` + + `setTimeout(() => { console.log(${JSON.stringify(MARKER)}); ` + + `setInterval(() => {}, 1000); }, Math.max(0, at - Date.now()));`, ), - // 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, + // The shape being reproduced is an overlap, and only an overlap: + // + // actual paint < timeoutMs < actual paint + surviveMs + // + // i.e. the render deadline expires while the survival window is still + // open. `done` is first-caller-wins, so an armed render timer firing in + // that gap settles the run as "did not render " while quoting an + // output tail that visibly contains the marker. + // + // `timeoutMs < surviveMs` is what makes the right-hand side hold for ANY + // paint time, including 0 — so a boot slow enough to swallow the anchor + // still reproduces the overlap rather than degrading into a different + // case. The only startup assumption left is that boot finishes before the + // deadline at all, and 600ms is an order of magnitude over the 46-59ms + // measured here idle. + // + // Do not tighten these to make the test faster, and do not reorder them so + // that `surviveMs <= timeoutMs`: the overlap is the point. + timeoutMs, + surviveMs, }); assert.equal(r.code, 0, `expected pass, got: ${r.message}`); assert.doesNotMatch(r.message, /did not render/); + + // Passing is not on its own evidence that the overlap was reproduced — a + // paint late enough to land past `timeoutMs` fails, but one landing so early + // that the deadline falls outside the survival window would pass while + // testing nothing. Read the paint time back out of the verdict and assert + // the shape actually held, so the case cannot go vacuous. + const paintedAt = Number(/ at (\d+)ms/.exec(r.message)?.[1]); + assert.ok( + Number.isFinite(paintedAt), + `no first-paint time in verdict: ${r.message}`, + ); + assert.ok( + paintedAt < timeoutMs && timeoutMs < paintedAt + surviveMs, + `deadline did not fall inside the survival window: painted at ${paintedAt}ms, ` + + `timeoutMs ${timeoutMs}, surviveMs ${surviveMs}`, + ); }); test("fails when the child exits before painting", async () => {