fix(engine,producer): classify blank vs psnr from a structural field, not the error message

Deepwork's request-changes on #2411 (twice): deFallbackReason's blank/psnr
split still ran /blank/i.test(err.message) even after this PR's stated goal
of moving off message-text parsing — a reworded message, a translated
string, or a differently-shaped error crossing a module boundary could
silently relabel a blank failure as psnr (or vice versa), corrupting the
soak's telemetry taxonomy.

DrawElementVerificationDetails now carries a required `kind: "blank" | "psnr"`
field, set at all three real throw sites in captureStreamingStage.ts. The
orchestrator derives deFallbackReason from getDrawElementVerificationDetails's
kind instead of regexing the message. Making `kind` a required constructor
argument means any future throw site that omits it fails to compile, closing
the gap for good rather than just at today's three call sites.

New tests in frameCapture.test.ts prove message-independence directly: kind
survives a reworded message that says neither "blank" nor "psnr", and stays
correctly "psnr" even when the message adversarially contains the substring
"blank" — the exact scenario a regex-based classifier would get wrong.
This commit is contained in:
Vance Ingalls
2026-07-14 23:29:49 -07:00
parent af923d947c
commit 36075c6797
4 changed files with 77 additions and 19 deletions
@@ -289,7 +289,7 @@ function createDrainFrameGuard(args: {
} catch (err) {
throw new DrawElementVerificationError(
`blank drawElement frame ${idx}: ${buf.length}B < floor ${Math.round(floor)}B and recapture failed (${err instanceof Error ? err.message : String(err)})`,
{ frameIndex: idx },
{ kind: "blank", frameIndex: idx },
);
}
if (retryBuf.equals(buf)) {
@@ -307,7 +307,7 @@ function createDrainFrameGuard(args: {
} else if (retryBuf.length < floor) {
throw new DrawElementVerificationError(
`blank drawElement frame ${idx}: ${buf.length}B (retry ${retryBuf.length}B) < floor ${Math.round(floor)}B`,
{ frameIndex: idx },
{ kind: "blank", frameIndex: idx },
);
} else {
buf = retryBuf;
@@ -341,7 +341,7 @@ function createDrainFrameGuard(args: {
}
throw new DrawElementVerificationError(
`drawElement self-verify failed at frame ${idx}: ${db.toFixed(1)}dB < ${verifyMinDb}dB vs pre-injection screenshot${dumpDir ? ` (pair: ${dumpDir})` : ""}`,
{ frameIndex: idx, failedDb: db, verifyThresholdDb: verifyMinDb },
{ kind: "psnr", frameIndex: idx, failedDb: db, verifyThresholdDb: verifyMinDb },
);
}
stats.verifyChecked += 1;
@@ -2729,15 +2729,17 @@ export async function executeRenderJob(
throw err;
const isMemoryExhaustion = !isVerifyError && isMemoryExhaustionError(err);
deSelfVerifyFallback = isVerifyError;
// `kind` is a structural field on the error (DrawElementVerificationDetails),
// never derived from message text — a reworded message, a translated
// string, or a cross-module/serialized error must never be able to
// flip "blank" into "psnr" or vice versa (review finding).
const verifyDetails = isVerifyError ? getDrawElementVerificationDetails(err) : undefined;
deFallbackReason = isVerifyError
? /blank/i.test(err instanceof Error ? err.message : "")
? "blank"
: "psnr"
? (verifyDetails?.kind ?? "psnr")
: isMemoryExhaustion
? "oom"
: "capture_error";
if (isVerifyError) {
const verifyDetails = getDrawElementVerificationDetails(err);
deFallbackFailedDb = roundDb(verifyDetails?.failedDb);
deFallbackFrameIndex = verifyDetails?.frameIndex;
deFallbackThresholdDb = roundDb(verifyDetails?.verifyThresholdDb);