fix(anpr): sliding poll window so a car arriving mid-loop isn't lost

A loop started by a far/early car would (a) give up before the REAL car settled at
the barrier, and (b) swallow the real car's pushes (the #polling guard dropped them).
So a confident-but-wrong far-car plate could win, or the intended car get debounced
out after the loop ended — wrong car acted on, right car blocked.

Fix: a push that JOINS a running loop now EXTENDS the deadline (lastPush +
ANPR_POLL_WINDOW_MS) instead of being dropped, capped at start + ANPR_POLL_MAX_MS
(30s) so a continuously-busy lane can't slide forever. Each tick still pulls a FRESH
frame, so the loop tracks whoever is at the barrier NOW, not the car that started it.
Per-camera sliding deadline in #pollDeadline (cleared with #polling in finally).

+1 test (push mid-poll keeps the loop alive past the initial deadline); 171 server
tests green. New knob ANPR_POLL_MAX_MS documented in the komodo env reference + the
two concurrency guards written up in lane-presence-and-anpr-entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-27 23:30:41 +02:00
parent a888125eca
commit c2a861208f
4 changed files with 92 additions and 11 deletions
+37
View File
@@ -43,6 +43,7 @@ afterEach(() => {
vi.restoreAllMocks();
delete process.env.ANPR_POLL_MS;
delete process.env.ANPR_POLL_WINDOW_MS;
delete process.env.ANPR_POLL_MAX_MS;
});
/** A camera bound to an entry relay; `anpr` toggles the opt-in flag. */
@@ -173,6 +174,42 @@ describe("AnprBridge", () => {
expect(captureSnapshot.mock.calls.length).toBeGreaterThanOrEqual(3); // re-pulled fresh frames
});
it("SLIDES the window: a push mid-poll keeps the loop alive past the initial deadline", async () => {
// A loop started by an early/far car would expire — but a NEW push (another car arriving)
// extends the deadline, so the loop keeps polling and reads the car that settles at the
// barrier. Here: a SHORT base window, vision stays low until attempt 5; a second push at
// the start bumps the deadline so attempt 5's confident read still lands.
const cam = seedCamera({ anpr: true });
const confs = [0.2, 0.2, 0.2, 0.2, 0.97];
let i = 0;
const vision = {
enabled: true,
analyze: vi.fn(async () => ({
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
plates: [],
lowConfidence: false,
modelVersion: "test",
tookMs: 1,
})),
} as unknown as VisionClient;
process.env.ANPR_POLL_MS = "5";
process.env.ANPR_POLL_WINDOW_MS = "12"; // tiny — would expire ~attempt 2 WITHOUT a slide
process.env.ANPR_POLL_MAX_MS = "5000"; // ceiling far above, so the slide is what matters
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(async () => {
const loop = bridge.onVehicleDetected(cam); // starts the loop
// Joining pushes keep sliding the deadline forward so the slow-to-confident read lands.
for (let k = 0; k < 5; k++) {
await new Promise((r) => setTimeout(r, 5));
void bridge.onVehicleDetected(cam); // each bumps the deadline (loop already running)
}
await loop;
});
expect(reads).toHaveLength(1);
expect(reads[0]).toMatchObject({ value: "AA111BB" });
});
it("ABORTS if the subscriber transacts by another credential mid-poll (no double-act)", async () => {
// The car's plate is read (identity known) but stays below the floor; meanwhile the
// subscriber scans their card → openOccurrenceCount drops. The bridge must abort and NOT