AtlatestRenderedmarkdown

courier / tree / reproevidence-2026-07-03-sentinel-zero.md

Read the source

Readme

Sentinel-zero storm — off-device repro + fix (2026-07-03)

The 2026-07-02 live storm: a count=0 cold-start drain (empty backlog) persisted offset=0; a later restart that trusts 0 restores it and the normal poll loop issues getUpdates offset=0 -> Telegram re-returns the whole backlog -> re-delivered -> leader acks each -> outbound storm.

Fix: never persist a non-positive offset (0 is Telegram's "send everything" sentinel, never a real updateid); an empty-backlog cold start leaves NO offset file, so the next start cold-drains instead of restoring 0. load also independently rejects <=0. NOTHING below touched real Telegram (poller pointed at repro/mocktelegram.py via COURIERTELEGRAMAPI_URL).

Harness (repro/repro-sentinel-zero.sh): CONTROL vs FIXED

=== FIXED (../build/dev/bin/courier) ===
  empty cold start left offset file? (expect no): note file removed by restart drain
  restart message events: 0  (expect 0)
  PASS: fixed binary does not re-deliver
=== CONTROL (/home/daviwil/Projects/Code/sigil/courier-sentinel-control/build/dev/bin/courier) — models the deployed sentinel-0 binary ===
  restart message events: 3  (expect >0 = storm reproduced)
  PASS: control reproduces the storm (3 re-delivered)
=== ALL CHECKS PASSED ===

CONTROL = master(6805ad1) + load accepts 0 (models the deployed binary, which live-logged 'Restored offset=0'). FIXED = this branch.

Manual verification — FIXED RELEASE (native, the deploy target)

empty cold start (native):   Cold start: drained ... count=0 offset=0 persisted=no (empty backlog)  -> NO offset file
restart w/ 2-msg backlog:    Cold start: drained ... count=2 offset=3 persisted=yes  -> 0 message events
planted poisoned '0' file:   Cold start: drained ... count=2  -> load REJECTED 0, drained, 0 message events
(native codegen of (> n 0) verified correct; dev and release behave identically)

Unit tests

test/test-poller-offset.sgl adds a 'never persists the sentinel 0' group (save 0 -> no file; save 0 clears an existing file; negative -> no file; positive still persists). Full suite: 63 passed.