fix(anpr): guarantee at least one analyze attempt per vehicle detection
Build & push images / images (push) Successful in 2m49s
CI / check (push) Successful in 40s

CI flake root cause (Gitea runner, anpr-entry.test.ts "records an advisory
anpr-skip"): the poll-until-confident loop was a plain
`while (Date.now() < deadline)` — zero iterations were possible when the
window elapsed between deadline-set and loop-entry (the tests run a 5ms
window; a slow runner loses that race). Zero attempts → no frame analyzed →
"gave up" → no anpr-skip row → assertion fails. Not a regression: nothing in
the recent merges touched this path; the race existed since the poll loop
was built.

The invariant is real beyond tests: on a sufficiently loaded booth the old
loop could silently drop a real car's detection the same way. The loop is
now do-while (exit via the existing breaks: confident read, or next tick
past the slid deadline/hard cap), so a detection ALWAYS analyzes at least
one frame.

New regression test forces ANPR_POLL_WINDOW_MS=0 (the CI scenario, made
deterministic) and asserts exactly one capture attempt + the recorded skip.
Suite 283 green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-07-04 20:16:52 +02:00
parent bafa3282c7
commit c03ef2a34b
2 changed files with 23 additions and 2 deletions
+16
View File
@@ -256,6 +256,22 @@ describe("AnprBridge", () => {
expect((skips[0].detail as { plate?: string }).plate).toBe("ZZ999ZZ");
});
it("analyzes AT LEAST ONE frame even if the poll window already elapsed (loaded host)", async () => {
// Regression for a CI flake (2026-07-04): with a plain `while`, a window that lapsed
// between deadline-set and loop-entry (slow runner; here forced with a 0ms window)
// meant ZERO analyze attempts — the detection was silently dropped ("gave up") and no
// skip was recorded. The do-while guarantees one frame per detection regardless of load.
process.env.ANPR_POLL_WINDOW_MS = "0";
const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
const bridge = new AnprBridge(db, vision, fakeSubFlow(null), silentLogger());
await captureReads(() => bridge.onVehicleDetected(cam));
expect(captureSnapshot).toHaveBeenCalledTimes(1); // the guaranteed first attempt
const skips = db.select().from(deviceEventsTable).where(eq(deviceEventsTable.kind, "anpr-skip")).all();
expect(skips).toHaveLength(1);
});
it("debounces: two vehicle events within the window analyze/emit at most once", async () => {
const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
+7 -2
View File
@@ -192,7 +192,12 @@ export class AnprBridge {
const hardCap = Date.now() + this.#pollMaxMs;
let attempts = 0;
try {
while (Date.now() < Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap)) {
// DO-while: a detection always analyzes AT LEAST ONE frame, however loaded the
// host — a plain while could zero-iterate if the window elapsed between setting
// the deadline and reaching the loop (seen as a CI flake with the tests' 5ms
// window; on a busy booth it would silently drop a real car's detection). Exit
// is via the breaks below (confident read, or next tick would pass the deadline).
do {
attempts++;
const shot = await camera.captureSnapshot({ direction });
const r = await this.#vision.analyze(shot.bytes, shot.contentType);
@@ -229,7 +234,7 @@ export class AnprBridge {
const effDeadline = Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap);
if (Date.now() + this.#pollMs >= effDeadline) break;
await sleep(this.#pollMs);
}
} while (true);
} finally {
this.#polling.delete(deviceId);
this.#pollDeadline.delete(deviceId);