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 $rc
src/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 go
src/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)