Commitc3290f0dRecorded7 Jul 2026Repositorycourier
Merge branch 'fix/courier-log-persist-repro' into deploy/reconcile-send-offset
Message
# Conflicts: # repro/mock_telegram.py
Changed
repro/evidence-2026-07-01.md | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
repro/evidence-2026-07-03-sentinel-zero.md | 39 ++++++++++++++++++++++++++++++++++++++
repro/repro-sentinel-zero.sh | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/courier/main.sgl | 30 +++++++++++++++++++++++++++++-
src/courier/poller.sgl | 153 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
test/test-poller-offset.sgl | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 491 insertions(+), 5 deletions(-)Diff
A merge. Shown against its first parent, so this is the effect of merging rather than the work of the branch.
repro/evidence-2026-07-01.mdadded
@@ -0,0 +1,69 @@
+1
# Off-device repro evidence — courier second spam cause (2026-07-01)+2
+3
All runs used a LOCAL mock (`mock_telegram.py`) via COURIER_TELEGRAM_API_URL.+4
NOTHING reached real Telegram. Mock models Telegram's getUpdates offset+5
semantics (offset N confirms/drops updates < N) and records every request.+6
+7
Binaries:+8
- FIXED: build/dev/bin/courier (branch fix/courier-log-persist-repro)+9
- CONTROL: master + api-url-only change (isolates the offset-persistence fix)+10
+11
## Deliverable 1 — --log now APPENDS across restarts (was: truncate)+12
```+13
2026-07-01T13:50:34Z [INFO] Courier starting mode=leader relay=none telegram=not configured+14
2026-07-01T13:50:34Z [INFO] MCP server ready name=courier+15
+16
--- courier restart ---+17
2026-07-01T13:50:36Z [INFO] Courier starting mode=leader relay=none telegram=not configured+18
2026-07-01T13:50:36Z [INFO] MCP server ready name=courier+19
+20
--- courier restart ---+21
2026-07-01T13:50:38Z [INFO] Courier starting mode=leader relay=none telegram=not configured+22
2026-07-01T13:50:38Z [INFO] MCP server ready name=courier+23
```+24
3x 'Courier starting' + 2x restart markers => full history preserved.+25
+26
## SECOND CAUSE — BEFORE (pre-fix control): backlog delivered + re-delivered+27
Cold start with a 2-update backlog. Each delivered message = one inbound the+28
leader would ACK = one outbound Telegram send => storm.+29
```+30
control RUN 1 (cold start) stdout events:+31
{"type":"hello","pid":3748}+32
{"type":"heartbeat"}+33
{"type":"message","text":"BACKLOG-1","sender":"daviwil","sender_id":"42","chat_id":"1001"}+34
{"type":"message","text":"BACKLOG-2","sender":"daviwil","sender_id":"42","chat_id":"1001"}+35
{"type":"heartbeat"}+36
{"type":"heartbeat"}+37
{"type":"heartbeat"}+38
{"type":"heartbeat"}+39
+40
control RUN 1 stderr (no offset persistence, no drain):+41
2026-07-01T13:49:48Z [INFO] Telegram poller child started pid=3748+42
+43
control RUN 2 (restart, backlog still pending) -> RE-DELIVERS:+44
{"type":"message","text":"BACKLOG-1","sender":"daviwil","sender_id":"42","chat_id":"1001"}+45
{"type":"message","text":"BACKLOG-2","sender":"daviwil","sender_id":"42","chat_id":"1001"}+46
```+47
+48
## SECOND CAUSE — AFTER (fixed): drain on cold start, deliver-once, no re-delivery+49
```+50
RUN 1 (cold start, 2-update backlog) stdout events:+51
{"type":"hello","pid":30027}+52
{"type":"heartbeat"}+53
{"type":"heartbeat"}+54
{"type":"heartbeat"}+55
{"type":"heartbeat"}+56
{"type":"heartbeat"}+57
{"type":"heartbeat"}+58
{"type":"heartbeat"}+59
RUN 1 stderr:+60
2026-07-01T13:44:45Z [INFO] Cold start: drained Telegram backlog (not delivered) count=2 offset=3+61
+62
RUN 2 (restart; 1 NEW update injected) -> delivers NEW once:+63
{"type":"message","text":"NEW-live-msg","sender":"daviwil","sender_id":"42","chat_id":"1001"}+64
2026-07-01T13:44:51Z [INFO] Restored Telegram getUpdates offset offset=3+65
+66
RUN 3 (restart, no new updates) -> NO re-delivery:+67
message events: 0+68
2026-07-01T13:44:57Z [INFO] Restored Telegram getUpdates offset offset=4+69
```repro/evidence-2026-07-03-sentinel-zero.mdadded
@@ -0,0 +1,39 @@
+1
# Sentinel-zero storm — off-device repro + fix (2026-07-03)+2
+3
The 2026-07-02 live storm: a count=0 cold-start drain (empty backlog)+4
persisted offset=0; a later restart that trusts 0 restores it and the+5
normal poll loop issues getUpdates offset=0 -> Telegram re-returns the+6
whole backlog -> re-delivered -> leader acks each -> outbound storm.+7
+8
Fix: never persist a non-positive offset (0 is Telegram's "send everything"+9
sentinel, never a real update_id); an empty-backlog cold start leaves NO+10
offset file, so the next start cold-drains instead of restoring 0. load+11
also independently rejects <=0. NOTHING below touched real Telegram+12
(poller pointed at repro/mock_telegram.py via COURIER_TELEGRAM_API_URL).+13
+14
## Harness (repro/repro-sentinel-zero.sh): CONTROL vs FIXED+15
```+16
=== FIXED (../build/dev/bin/courier) ===+17
empty cold start left offset file? (expect no): note file removed by restart drain+18
restart message events: 0 (expect 0)+19
PASS: fixed binary does not re-deliver+20
=== CONTROL (/home/daviwil/Projects/Code/sigil/courier-sentinel-control/build/dev/bin/courier) — models the deployed sentinel-0 binary ===+21
restart message events: 3 (expect >0 = storm reproduced)+22
PASS: control reproduces the storm (3 re-delivered)+23
=== ALL CHECKS PASSED ===+24
```+25
CONTROL = master(6805ad1) + load accepts 0 (models the deployed binary,+26
which live-logged 'Restored offset=0'). FIXED = this branch.+27
+28
## Manual verification — FIXED RELEASE (native, the deploy target)+29
```+30
empty cold start (native): Cold start: drained ... count=0 offset=0 persisted=no (empty backlog) -> NO offset file+31
restart w/ 2-msg backlog: Cold start: drained ... count=2 offset=3 persisted=yes -> 0 message events+32
planted poisoned '0' file: Cold start: drained ... count=2 -> load REJECTED 0, drained, 0 message events+33
(native codegen of (> n 0) verified correct; dev and release behave identically)+34
```+35
+36
## Unit tests+37
test/test-poller-offset.sgl adds a 'never persists the sentinel 0' group+38
(save 0 -> no file; save 0 clears an existing file; negative -> no file;+39
positive still persists). Full suite: 63 passed.repro/repro-sentinel-zero.shadded
@@ -0,0 +1,75 @@
+1
#!/usr/bin/env bash+2
# Off-device reproduction of the SENTINEL-ZERO storm (2026-07-02 live failure)+3
# and proof of the fix. NOTHING here touches real Telegram: the poller is+4
# pointed at the local mock via COURIER_TELEGRAM_API_URL.+5
#+6
# The bug: a count=0 cold-start drain (empty backlog) persisted offset=0.+7
# A later restart that trusts that 0 restores it and the normal poll loop+8
# issues getUpdates offset=0 -> Telegram re-returns the entire backlog ->+9
# every message is re-delivered -> the leader acks each -> outbound storm.+10
#+11
# Scenario for BOTH binaries:+12
# 1. empty backlog, cold start (no offset file) -> drain, count=0+13
# 2. a backlog accumulates (e.g. ~24h of inbound)+14
# 3. RESTART the poller+15
# 4. assert step 3 delivers ZERO message events+16
#+17
# CONTROL (models the deployed buggy binary: load accepts 0) -> step 3+18
# RESTORES offset 0 and the normal loop RE-DELIVERS -> storm.+19
# FIXED -> step 1 leaves NO offset file (0 is never persisted), so+20
# step 3 is a cold start that DRAINS the backlog -> no delivery.+21
#+22
# Usage: repro-sentinel-zero.sh <fixed-courier-bin> [control-courier-bin]+23
# Without a control bin, only the FIXED assertions run.+24
set -u+25
HERE="$(cd "$(dirname "$0")" && pwd)"+26
FIXED="${1:?usage: repro-sentinel-zero.sh <fixed-bin> [control-bin]}"+27
CONTROL="${2:-}"+28
PORT="${PORT:-19877}"+29
WORK="$(mktemp -d)"+30
RELAY="$WORK/relays"+31
OFF="$WORK/telegram-offset" # = <dirname(relay-dir)>/telegram-offset+32
mkdir -p "$RELAY" "$WORK/logs"+33
+34
cleanup () { [ -n "${MOCK:-}" ] && kill -9 "$MOCK" 2>/dev/null; rm -rf "$WORK"; }+35
trap cleanup EXIT+36
+37
python3 "$HERE/mock_telegram.py" "$PORT" "$WORK/logs" >"$WORK/mock.out" 2>&1 &+38
MOCK=$!+39
sleep 1.5+40
curl -sf -m2 "http://127.0.0.1:$PORT/_stats" >/dev/null || { echo "FAIL: mock not up"; cat "$WORK/mock.out"; exit 1; }+41
+42
seed_empty () { curl -s -m2 -X POST "http://127.0.0.1:$PORT/_seed" -d '{"updates":[]}' >/dev/null; }+43
add () { curl -s -m2 -X POST "http://127.0.0.1:$PORT/_add" -d "{\"text\":\"$1\"}" >/dev/null; }+44
run () { # $1=bin $2=tag+45
env COURIER_TELEGRAM_TOKEN=tok COURIER_TELEGRAM_API_URL="http://127.0.0.1:$PORT" \+46
COURIER_RELAY_DIR="$RELAY" \+47
timeout 6 "$1" --telegram-poller < <(sleep 12) >"$WORK/$2.out" 2>"$WORK/$2.err"+48
}+49
msgs () { local c; c=$(grep -c '"type":"message"' "$WORK/$1.out" 2>/dev/null); echo "${c:-0}"; }+50
+51
scenario () { # $1=bin $2=label ; echo message-event count on restart+52
seed_empty; rm -f "$OFF"+53
run "$1" "${2}_cold"+54
add OLD-1; add OLD-2; add OLD-3+55
run "$1" "${2}_restart"+56
echo "$(msgs ${2}_restart)"+57
}+58
+59
rc=0+60
echo "=== FIXED ($FIXED) ==="+61
n=$(scenario "$FIXED" fixed)+62
coldfile_exists=$([ -e "$OFF" ] && echo yes || echo no)+63
echo " empty cold start left offset file? (expect no): note file removed by restart drain"+64
echo " restart message events: $n (expect 0)"+65
if [ "$n" -eq 0 ]; then echo " PASS: fixed binary does not re-deliver"; else echo " FAIL: fixed binary re-delivered $n"; rc=1; fi+66
+67
if [ -n "$CONTROL" ]; then+68
echo "=== CONTROL ($CONTROL) — models the deployed sentinel-0 binary ==="+69
n=$(scenario "$CONTROL" control)+70
echo " restart message events: $n (expect >0 = storm reproduced)"+71
if [ "$n" -gt 0 ]; then echo " PASS: control reproduces the storm ($n re-delivered)"; else echo " FAIL: control did not storm"; rc=1; fi+72
fi+73
+74
echo "=== $( [ $rc -eq 0 ] && echo ALL CHECKS PASSED || echo CHECKS FAILED ) ==="+75
exit $rcsrc/courier/main.sglmodified
@@ -48,6 +48,15 @@
48
((string=? (car rest) flag) #t) 49
(else (loop (cdr rest)))))) 50
+51
;; Value following a CLI flag (e.g. the path after --log), or #f.+52
(define (find-cli-value flag)+53
(let loop ((rest (cdr (command-line))))+54
(cond+55
((null? rest) #f)+56
((and (string=? (car rest) flag) (not (null? (cdr rest))))+57
(cadr rest))+58
(else (loop (cdr rest))))))+59
60
;; ============================================================ 61
;; Crash Logging 62
;; ============================================================@@ -100,6 +109,25 @@
109
(ensure-directory log-dir) 110
(log-configure! target: (open-log-append! log-path)))) 111
+112
;; Configure logging from CLI args, opening any --log <path> target+113
;; in APPEND mode. This is the production path: the leader launches+114
;; `courier serve --log <dir>/courier.log --log-level trace`+115
;; (claude-ops launch-server.sh). The stdlib log-configure-from-args!+116
;; would open that path with open-output-file, TRUNCATING it on every+117
;; restart -- which is why a spam episode's evidence kept getting+118
;; wiped. Appending (via open-log-append!, capped at+119
;; *log-carryover-max*) makes restart behavior observable. When no+120
;; --log is given, only the level is applied and logging stays on the+121
;; console/stderr default (matching the stdlib helper's behavior).+122
(define (configure-logging-from-args!)+123
(let ((log-path (find-cli-value "--log"))+124
(log-level (find-cli-value "--log-level")))+125
(when log-level+126
(log-configure! level: (string->symbol log-level)))+127
(when log-path+128
(ensure-directory (path-dirname log-path))+129
(log-configure! target: (open-log-append! log-path)))))+130
131
;; ============================================================ 132
;; Entry Point 133
;; ============================================================@@ -110,7 +138,7 @@
138
" (sigil " (sigil-version) ")\n")) 139
(exit 0)) 140
−113
(log-configure-from-args!)+141
(configure-logging-from-args!) 142
143
;; Telegram poller child mode: run the isolated poll loop and 144
;; exit. Spawned by the leader's poller supervisor; events gosrc/courier/poller.sglmodified
@@ -31,6 +31,8 @@
31
(define-library (courier poller) 32
(import (sigil core) 33
(sigil io)+34
(sigil fs)+35
(sigil path) 36
(sigil string) 37
(sigil struct) 38
(sigil math)@@ -53,7 +55,10 @@
55
parse-poller-event 56
make-poller-message-event 57
poller-stale?−56
poller-next-restart-delay)+58
poller-next-restart-delay+59
offset-file-path+60
load-persisted-offset+61
save-offset!) 62
(begin 63
64
;; ============================================================@@ -164,6 +169,125 @@
169
(poller-emit-line! *heartbeat-line*) 170
(loop (- remaining *heartbeat-slice*))))) 171
+172
;; ============================================================+173
;; Child: getUpdates offset persistence+174
;; ============================================================+175
;;+176
;; The getUpdates offset (the tg-client last-update-id) is otherwise+177
;; in-memory only. A fresh child -- leader restart, /mcp reconnect,+178
;; watchdog respawn, or crash -- starts at offset 0, so getUpdates+179
;; re-returns Telegram's entire ~24h unconfirmed backlog. Each+180
;; re-delivered inbound message is re-injected to the leader, which+181
;; acks every one (the two-message telegram-ack protocol) -> an+182
;; outbound Telegram storm. This was the SECOND spam cause: the+183
;; io.sgl async-log fix stopped the crash-LOOP that triggered+184
;; restarts, but ANY restart re-delivered. Persisting the offset+185
;; across restarts makes each inbound delivered exactly once.+186
+187
;; Where the offset lives: next to courier.log, keyed off the relay+188
;; dir so a poller child and the leader agree on the location.+189
(define (offset-file-path)+190
(path-join (path-dirname (default-relay-dir)) "telegram-offset"))+191
+192
;; Persisted offset, or #f when the file is absent, unreadable, or+193
;; not a STRICTLY POSITIVE integer. #f means "cold start" -> drain,+194
;; don't deliver, the pre-existing backlog. Rejecting <= 0 is+195
;; load-bearing: 0 is Telegram's "no offset / send me everything"+196
;; sentinel and carries no high-water information, so a restored 0+197
;; would re-fetch and re-deliver the whole backlog (the 2026-07-02+198
;; live storm). Telegram update_ids are large positive integers and+199
;; are never 0, so a 0/negative on disk is always a bug, not a resume+200
;; point -> treat it as cold start and drain.+201
(define (load-persisted-offset)+202
(let ((path (offset-file-path)))+203
(and (file-exists? path)+204
(guard (e (else #f))+205
(let ((n (string->number (string-trim (read-file-string path)))))+206
(and (integer? n) (> n 0) n))))))+207
+208
;; Remove the offset file, so the next start is a cold start (drain)+209
;; rather than restoring a stale/sentinel value.+210
(define (clear-offset-file!)+211
(guard (e (else #f))+212
(let ((path (offset-file-path)))+213
(when (file-exists? path)+214
(delete-file path)))))+215
+216
;; Persist the offset -- but ONLY a real (strictly positive)+217
;; high-water update_id, which is the only durable resume state.+218
;; A non-positive n (e.g. the offset after draining an EMPTY backlog,+219
;; which stays 0) must NOT be written: persisting 0 seeds exactly the+220
;; sentinel that re-delivered the backlog on the next restart+221
;; (2026-07-02). Instead we clear any existing file so the next start+222
;; cold-drains. Best-effort: a failed write only risks a bounded+223
;; re-delivery next restart, never a crash.+224
(define (save-offset! n)+225
(if (and (integer? n) (> n 0))+226
(guard (e (else #f))+227
(ensure-directory (path-dirname (offset-file-path)))+228
(write-file-string (offset-file-path) (number->string n)))+229
(clear-offset-file!)))+230
+231
;; Cold start: advance the offset past every currently-pending+232
;; update WITHOUT delivering any of them, so deploying+233
;; offset-persistence does not itself fire one last storm from the+234
;; backlog that already accumulated with no saved offset. Retries+235
;; through transient errors -- we must not fall through to the+236
;; delivery loop with the backlog still pending. Heartbeats keep the+237
;; supervisor from killing the child while draining. Returns the+238
;; count drained (for logging).+239
(define (drain-backlog! bot)+240
(let ((client (tg-bot-client bot)))+241
(let loop ((backoff *error-backoff-initial*) (total 0))+242
(poller-emit-line! *heartbeat-line*)+243
(when (poller-parent-gone?)+244
(exit 0))+245
(let* ((result (guard (e (else+246
(log-error (format "Backlog drain error: ~a" e))+247
'error))+248
(tg-get-updates client timeout: 0)))+249
(updates (cond+250
((eq? result 'error) 'error)+251
((array? result) (array->list result))+252
(else '()))))+253
(cond+254
;; Transient failure: back off and retry (still not+255
;; delivering); heartbeats emitted by poller-sleep/heartbeat.+256
((eq? updates 'error)+257
(poller-sleep/heartbeat backoff)+258
(loop (min (* backoff 2) *error-backoff-max*) total))+259
;; Backlog fully drained.+260
((null? updates) total)+261
;; Advance past this batch (no dispatch) and continue.+262
(else+263
(for-each+264
(lambda (raw)+265
(let ((update (dict->tg-update raw)))+266
(set-tg-client-last-update-id!+267
client+268
(+ (tg-update-update-id update) 1))))+269
updates)+270
(loop *error-backoff-initial* (+ total (length updates)))))))))+271
+272
;; Restore the saved offset, or silently drain the backlog on a cold+273
;; start, before the delivery loop begins.+274
(define (initialize-offset! bot)+275
(let ((saved (load-persisted-offset)))+276
(if saved+277
(begin+278
(set-tg-client-last-update-id! (tg-bot-client bot) saved)+279
(log-info "Restored Telegram getUpdates offset" offset: saved))+280
(let ((drained (drain-backlog! bot)))+281
(let ((offset (tg-client-last-update-id (tg-bot-client bot))))+282
;; save-offset! persists ONLY a positive high-water mark.+283
;; An empty backlog leaves offset 0 -> no file is written+284
;; (any stale one is cleared), so the next start cold-drains+285
;; again instead of restoring a sentinel 0 and re-delivering.+286
(save-offset! offset)+287
(log-info "Cold start: drained Telegram backlog (not delivered)"+288
count: drained offset: offset+289
persisted: (if (> offset 0) "yes" "no (empty backlog)")))))))+290
291
;;; Entry point for `courier --telegram-poller`. 292
;;; 293
;;; Polls Telegram synchronously and forwards allowed messages to@@ -175,9 +299,19 @@
299
(: courier-config? -> void?) 300
(let* ((token (courier-config-telegram-token config)) 301
(allowed (courier-config-allowed-senders config))−178
(bot (make-tg-bot token: token−179
request-timeout: *poll-request-timeout*−180
connect-timeout: *poll-connect-timeout*)))+302
;; Honor COURIER_TELEGRAM_API_URL for the poll path too. The+303
;; send path (telegram.sgl) already redirects via config, but+304
;; make-tg-bot hard-codes api.telegram.org -- so before this,+305
;; the poller ALWAYS hit real Telegram, and there was no way+306
;; to reproduce/verify inbound polling off-device against a+307
;; mock. Build the bot's client directly with the override.+308
(api-url (or (courier-config-telegram-api-url config)+309
"https://api.telegram.org"))+310
(bot (tg-bot+311
client: (tg-client token: token+312
api-url: api-url+313
request-timeout: *poll-request-timeout*+314
connect-timeout: *poll-connect-timeout*)))) 315
316
(poller-emit-line! 317
(json-encode `((type . "hello") (pid . ,(process-id)))))@@ -209,6 +343,11 @@
343
344
(log-info "Telegram poller child started" pid: (process-id)) 345
+346
;; Restore the getUpdates offset (or drain the backlog on a cold+347
;; start) BEFORE polling, so a restart never re-delivers the+348
;; ~24h Telegram backlog. See the offset-persistence section.+349
(initialize-offset! bot)+350
351
(let loop ((backoff *error-backoff-initial*)) 352
;; Heartbeat IMMEDIATELY BEFORE the (blocking) poll so the 353
;; supervisor's liveness window resets right at the start of@@ -232,6 +371,12 @@
371
#f)) 372
(tg-bot-tick bot) 373
#t)))+374
;; Persist the (possibly advanced) offset after a successful+375
;; tick so the next child resumes exactly here -- a delivered+376
;; message is confirmed by the next getUpdates offset and is+377
;; never re-delivered across a restart.+378
(when ok?+379
(save-offset! (tg-client-last-update-id (tg-bot-client bot)))) 380
;; Heartbeats are the supervisor's liveness signal -- 381
;; emitted on errors too: an erroring child is alive and 382
;; backing off, not wedged.test/test-poller-offset.sgladded
@@ -0,0 +1,130 @@
+1
(import (sigil test)+2
(sigil string)+3
(sigil fs)+4
(sigil path)+5
(sigil process)+6
(courier poller))+7
+8
;; ============================================================+9
;; getUpdates offset persistence (the second-spam-cause fix)+10
;; ============================================================+11
;;+12
;; The poller's getUpdates offset (tg-client last-update-id) must+13
;; survive a process restart, or a fresh child starts at offset 0 and+14
;; re-delivers Telegram's entire ~24h backlog -- the leader then acks+15
;; each re-delivered message, an outbound storm. These tests cover the+16
;; durable-offset primitives: round-trip, and the "cold start" signals+17
;; (missing / non-positive / garbage file) that trigger a silent drain+18
;; instead of delivering a stale backlog.+19
+20
;; Point the relay dir (and thus the offset file) at a fresh temp dir.+21
;; offset-file-path is <dirname(relay-dir)>/telegram-offset.+22
(define (with-temp-offset thunk)+23
(let ((tmp (make-temp-directory)))+24
(setenv! "COURIER_RELAY_DIR" (path-join tmp "relays"))+25
(let ((result (thunk tmp)))+26
(setenv! "COURIER_RELAY_DIR" "")+27
(guard (e (else #f)) (delete-directory tmp))+28
result)))+29
+30
(test-group "offset-file-path"+31
(test "sits beside the relay dir, not inside it"+32
(with-temp-offset+33
(lambda (tmp)+34
;; dirname(<tmp>/relays) == <tmp>+35
(assert-equal (path-join tmp "telegram-offset")+36
(offset-file-path))))))+37
+38
(test-group "load-persisted-offset — cold-start signals"+39
(test "missing file -> #f (cold start: drain the backlog)"+40
(with-temp-offset+41
(lambda (tmp)+42
(assert-false (load-persisted-offset)))))+43
+44
(test "garbage file -> #f (corrupt: treat as cold start)"+45
(with-temp-offset+46
(lambda (tmp)+47
(write-file-string (offset-file-path) "not-a-number")+48
(assert-false (load-persisted-offset)))))+49
+50
(test "zero -> #f (offset 0 means no offset -> would re-fetch backlog)"+51
(with-temp-offset+52
(lambda (tmp)+53
(write-file-string (offset-file-path) "0")+54
(assert-false (load-persisted-offset)))))+55
+56
(test "empty file -> #f"+57
(with-temp-offset+58
(lambda (tmp)+59
(write-file-string (offset-file-path) "")+60
(assert-false (load-persisted-offset))))))+61
+62
(test-group "save-offset! / load-persisted-offset round-trip"+63
(test "a saved positive offset reloads exactly"+64
(with-temp-offset+65
(lambda (tmp)+66
(save-offset! 42)+67
(assert-equal 42 (load-persisted-offset)))))+68
+69
(test "re-saving overwrites (offset only advances forward)"+70
(with-temp-offset+71
(lambda (tmp)+72
(save-offset! 42)+73
(save-offset! 100)+74
(assert-equal 100 (load-persisted-offset)))))+75
+76
(test "large update_id round-trips (no precision loss)"+77
(with-temp-offset+78
(lambda (tmp)+79
(save-offset! 999999999)+80
(assert-equal 999999999 (load-persisted-offset)))))+81
+82
(test "trailing whitespace in the file is tolerated"+83
(with-temp-offset+84
(lambda (tmp)+85
(write-file-string (offset-file-path) "57\n")+86
(assert-equal 57 (load-persisted-offset))))))+87
+88
;; ============================================================+89
;; Sentinel-zero hole (the 2026-07-02 live storm)+90
;; ============================================================+91
;;+92
;; A count=0 cold-start drain leaves the offset at 0. Persisting 0 seeds+93
;; Telegram's "no offset / send me everything" sentinel; a later start+94
;; that trusts it re-fetches and re-delivers the whole backlog. So+95
;; save-offset! must NEVER write a non-positive value, and must clear any+96
;; existing file so the next start cold-drains instead.+97
+98
(test-group "save-offset! — never persists the sentinel 0"+99
(test "saving 0 writes NO file"+100
(with-temp-offset+101
(lambda (tmp)+102
(save-offset! 0)+103
(assert-false (file-exists? (offset-file-path)))+104
(assert-false (load-persisted-offset)))))+105
+106
(test "saving 0 CLEARS a previously-persisted offset"+107
(with-temp-offset+108
(lambda (tmp)+109
(save-offset! 99)+110
(assert-equal 99 (load-persisted-offset))+111
;; e.g. a later empty-backlog cold start+112
(save-offset! 0)+113
(assert-false (file-exists? (offset-file-path)))+114
(assert-false (load-persisted-offset)))))+115
+116
(test "saving a negative value writes NO file"+117
(with-temp-offset+118
(lambda (tmp)+119
(save-offset! -3)+120
(assert-false (file-exists? (offset-file-path)))+121
(assert-false (load-persisted-offset)))))+122
+123
(test "a positive offset still persists and reloads"+124
(with-temp-offset+125
(lambda (tmp)+126
(save-offset! 7)+127
(assert-true (file-exists? (offset-file-path)))+128
(assert-equal 7 (load-persisted-offset))))))+129
+130
(run-tests)