From bafa3282c72aa7e7826930616b2b2f97b09f8975 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 4 Jul 2026 20:03:01 +0200 Subject: [PATCH 1/2] deploy(park-buzi): pin TAG=stage-93f9ebe (press-gate + reader hardening + logging) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries: camera press-gate + cooldown backstop + duplicate-plate anomaly (b4f1418), reader channel tagging + structural phantom filter (43c1f45), log rotation/format (c21babf). Code-only — no migration; boot log should pass straight through [migrate] done. The compose logging-option change forces container recreation, which the Komodo deploy does anyway. Deploy is the manual Komodo step: refresh ResourceSync → Execute → Deploy. Reminder: deploy server BEFORE the vendor-tool reader changes (prefixes Q:/K:, Card Input format 8H, symbology cut). Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- komodo/resources.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/komodo/resources.toml b/komodo/resources.toml index 69dc6c1..b54ca46 100644 --- a/komodo/resources.toml +++ b/komodo/resources.toml @@ -49,7 +49,7 @@ REGISTRY=git.infra.msai.al/mca/parking_solution # Staging booth: pinned immutable stage-. After each promotion (merge dev → stage, CI builds # :stage-), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag # exists as the pointer; we deploy the sha, not the mover. -TAG=stage-6505a4a +TAG=stage-93f9ebe COOKIE_SECURE=0 VISION_ENABLED=1 WS_ALLOWED_ORIGINS= From c03ef2a34bd1c32fdb17ff07d08d37adfa0a3405 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 4 Jul 2026 20:16:52 +0200 Subject: [PATCH 2/2] fix(anpr): guarantee at least one analyze attempt per vehicle detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/server/src/anpr-entry.test.ts | 16 ++++++++++++++++ apps/server/src/anpr-entry.ts | 9 +++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/server/src/anpr-entry.test.ts b/apps/server/src/anpr-entry.test.ts index 43ad7f9..7e1eaf1 100644 --- a/apps/server/src/anpr-entry.test.ts +++ b/apps/server/src/anpr-entry.test.ts @@ -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 }); diff --git a/apps/server/src/anpr-entry.ts b/apps/server/src/anpr-entry.ts index 8e5b1fc..58255ff 100644 --- a/apps/server/src/anpr-entry.ts +++ b/apps/server/src/anpr-entry.ts @@ -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);