diff --git a/cancelok/gate.mjs b/cancelok/gate.mjs --- a/cancelok/gate.mjs +++ b/cancelok/gate.mjs @@ -19,36 +19,87 @@ const DOCTOR = resolve(REPO, "marketing/av-reels/bin/pad-doctor.mjs"); const BASE = process.env.CANCELOK_BASE || "http://localhost:8899"; -// The thresholds are pad-doctor's own, restated as a pass/fail because a gate -// has to actually decide. 40fps is the floor the shipped pads clear on native; -// a 40MB heap climb in 12 seconds is the signature of a runaway allocation. -const MIN_FPS = 40; -const MAX_HEAP_MB = 40; +// An ABSOLUTE fps floor is a trap. Headless Chrome pins the render cadence to +// whatever the host feels like — on this Mac every pad reads exactly 30fps, +// including all 47 shipped ones. A floor of 40 would reject `prism`. A floor of +// 25 would pass a pad that's genuinely half-speed on a machine that can do 60. +// Neither floor means anything, because the number isn't about the pad. +// +// So we measure a KNOWN-GOOD pad in the same session and grade against it. `lull` +// is the reference: shipped, certified, cheap. Whatever the host is doing to +// lull, it's doing to the candidate too, and the ratio survives it. +// And heap growth over a 12-second window is NOISE. Measured twice, the same pad +// swings from +11MB to +42MB — that's the garbage collector's mood, not a leak. +// `lull`, shipped and certified, reads +40MB on a bad run. So a single bad +// measurement means nothing, and the gate CONFIRMS before it convicts: anything +// that fails gets measured again, and only a failure that REPEATS is real. +// A leak repeats. A GC hiccup doesn't. +const REFERENCE = "lull"; +const FPS_RATIO = 0.85; // a candidate may be 15% slower than a shipped pad +const MAX_HEAP_MB = 60; // above the observed noise band of known-good pads +const MAX_WORK_MS = 14; // onPaint work — host-independent, unlike cadence -export function gate(pad, secs = 12) { +let baseline = null; // measured once per process + +function measure(pad, secs) { let out = ""; try { - out = execFileSync( - "node", - [DOCTOR, pad, "--base", BASE, "--secs", String(secs)], - { encoding: "utf8", cwd: REPO, timeout: (secs + 60) * 1000 }, - ); + out = execFileSync("node", [DOCTOR, pad, "--base", BASE, "--secs", String(secs)], { + encoding: "utf8", + cwd: REPO, + timeout: (secs + 60) * 1000, + }); } catch (e) { - return { pass: false, why: "the probe itself died — the pad likely hung the page", out: String(e.message).slice(0, 200) }; + return { dead: true, out: String(e.message).slice(0, 200) }; + } + return { + fps: parseFloat(/fps min=([\d.]+)/.exec(out)?.[1] ?? "0"), + heap: parseFloat(/\(([+-][\d.]+)MB\//.exec(out)?.[1] ?? "0"), + work: parseFloat(/work=([\d.]+)ms/.exec(out)?.[1] ?? "0"), + errs: parseInt(/JS errors[^:]*:\s*(\d+)/.exec(out)?.[1] ?? "0", 10), + out: out.trim(), + }; +} + +// Judge one measurement. Returns the complaints, or [] if it's clean. +function complaints(m, floor) { + const why = []; + if (!(m.fps > 0)) why.push("it never rendered a frame"); + else if (m.fps < floor) + why.push(`${m.fps}fps vs ${baseline.fps} for ${REFERENCE} — too slow for this host`); + if (m.work > MAX_WORK_MS) why.push(`onPaint takes ${m.work}ms — over the frame budget`); + if (m.heap > MAX_HEAP_MB) why.push(`heap grew ${m.heap}MB — a leak`); + if (m.errs > 0) why.push(`${m.errs} JS error(s)`); + return why; +} + +export function gate(pad, secs = 12) { + if (!baseline) { + baseline = measure(REFERENCE, secs); + console.log(`📏 baseline (${REFERENCE}): ${baseline.fps}fps on this host`); } + const floor = Math.max(1, baseline.fps * FPS_RATIO); - const fps = parseFloat(/fps min=([\d.]+)/.exec(out)?.[1] ?? "0"); - const heap = parseFloat(/\(([+-][\d.]+)MB\//.exec(out)?.[1] ?? "0"); - const errs = parseInt(/JS errors[^:]*:\s*(\d+)/.exec(out)?.[1] ?? "0", 10); - const ran = /\[pads:fps\]|fps min=/.test(out) && fps > 0; + let m = measure(pad, secs); + if (m.dead) return { pass: false, why: "the probe died — it likely hung the page", ...m }; - const why = []; - if (!ran) why.push("it never rendered a frame"); - if (ran && fps < MIN_FPS) why.push(`${fps}fps — under the ${MIN_FPS} floor`); - if (heap > MAX_HEAP_MB) why.push(`heap grew ${heap}MB — a leak`); - if (errs > 0) why.push(`${errs} JS error(s)`); + // A thrown error is deterministic — believe it the first time. Everything else + // is a measurement, and one measurement is a rumor. + let why = complaints(m, floor); + if (why.length && m.errs === 0) { + console.log(` …${pad} failed on "${why.join("; ")}" — measuring again to be sure`); + const second = measure(pad, secs); + if (second.dead) return { pass: false, why: "the probe died on retry", ...second }; + const again = complaints(second, floor); + if (!again.length) { + console.log(` …clean on the second look. It was noise.`); + return { pass: true, why: "", ...second }; + } + m = second; + why = again; + } - return { pass: why.length === 0, why: why.join("; "), fps, heap, errs, out: out.trim() }; + return { pass: why.length === 0, why: why.join("; "), ...m }; } if (import.meta.url === `file://${process.argv[1]}`) {