Commit6805ad17Recorded1 Jul 2026Repositorycourier

Persist Telegram getUpdates offset + append courier.log across restarts

Message

Second spam cause: the poller's getUpdates offset (tg-client last-update-id) was in-memory only, so every fresh poller child (leader restart, /mcp reconnect, watchdog respawn, crash) restarted at offset 0 and re-fetched Telegram's ~24h unconfirmed backlog. The leader acked each re-delivered inbound message -> an outbound Telegram storm. The io.sgl async-log fix stopped the crash-loop trigger but not this: any restart still re-delivered.

- poller: persist last-update-id to <relay-dir>/../telegram-offset, reload on child start, re-save after each tick. Cold start / missing / corrupt offset silently DRAINS the pre-existing backlog (advances past it without delivering) so deploying the fix does not itself fire one last storm. - poller: honor COURIERTELEGRAMAPIURL on the poll path. make-tg-bot hard-coded api.telegram.org, so the poller always hit real Telegram and could not be reproduced off-device; build the bot's tg-client directly with the override (send path already did this). - main: open the --log <path> target in APPEND mode. Production launches with --log, which the stdlib log-configure-from-args! truncated on every restart, wiping each spam episode's evidence. - test: offset persistence round-trip + cold-start signals (59 pass). - repro: off-device mock (mocktelegram.py) + captured evidence.

Changed
 repro/.gitignore             |   4 ++++
 repro/evidence-2026-07-01.md |  69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 repro/mock_telegram.py       | 162 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/courier/main.sgl         |  30 +++++++++++++++++++++++++++++-
 src/courier/poller.sgl       | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
 test/test-poller-offset.sgl  |  88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 6 files changed, 474 insertions(+), 5 deletions(-)
Diff
repro/.gitignoreadded
@@ -0,0 +1,4 @@
+1
run/
+2
*.out
+3
*.err
+4
__pycache__/
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/mock_telegram.pyadded
@@ -0,0 +1,162 @@
+1
#!/usr/bin/env python3
+2
"""Mock Telegram Bot API for OFF-DEVICE courier repro.
+3
+4
Models the two semantics that matter for the getUpdates-backlog spam bug:
+5
+6
* getUpdates(offset=N) CONFIRMS (drops) every pending update with
+7
update_id < N, then returns the remaining pending updates. A call
+8
with no offset (or offset 0) confirms nothing and returns the whole
+9
backlog -- exactly how a fresh poller (offset reset to 0) re-fetches
+10
Telegram's ~24h backlog.
+11
* sendMessage records the outbound and returns ok. Nothing here ever
+12
reaches real Telegram; the point is to COUNT would-be sends.
+13
+14
Every request is appended to <logdir>/requests.log so the driver can
+15
assert on re-delivery. Control endpoints (not part of the Telegram API)
+16
let the driver seed and inject updates:
+17
+18
POST /_seed body: {"updates":[{...},...]} replace the backlog
+19
POST /_add body: {"text":"...","sender_id":"..","chat_id":".."} append one update
+20
GET /_stats {"delivered":[ids], "sends":N}
+21
+22
Usage: mock_telegram.py <port> <logdir>
+23
"""
+24
import json
+25
import sys
+26
import os
+27
import threading
+28
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+29
+30
PORT = int(sys.argv[1])
+31
LOGDIR = sys.argv[2]
+32
os.makedirs(LOGDIR, exist_ok=True)
+33
REQ_LOG = os.path.join(LOGDIR, "requests.log")
+34
SEND_LOG = os.path.join(LOGDIR, "sends.log")
+35
+36
LOCK = threading.Lock()
+37
STATE = {
+38
"updates": [], # pending updates (list of dicts with update_id)
+39
"next_id": 1, # next update_id to assign via _add
+40
"getupdates": 0, # count of getUpdates calls
+41
"sends": 0, # count of sendMessage calls
+42
"delivered_ids": [], # update_ids the mock RETURNED to a poller (per call)
+43
}
+44
+45
+46
def logline(path, msg):
+47
with open(path, "a") as f:
+48
f.write(msg + "\n")
+49
f.flush()
+50
+51
+52
def make_update(update_id, text, sender_id="42", chat_id="1001"):
+53
return {
+54
"update_id": update_id,
+55
"message": {
+56
"message_id": update_id,
+57
"date": 1000000 + update_id,
+58
"text": text,
+59
"from": {"id": int(sender_id), "is_bot": False,
+60
"first_name": "David", "username": "daviwil"},
+61
"chat": {"id": int(chat_id), "type": "private"},
+62
},
+63
}
+64
+65
+66
class Handler(BaseHTTPRequestHandler):
+67
def log_message(self, *a):
+68
pass # silence default stderr logging
+69
+70
def _body(self):
+71
n = int(self.headers.get("Content-Length", 0))
+72
raw = self.rfile.read(n) if n else b""
+73
try:
+74
return json.loads(raw) if raw else {}
+75
except Exception:
+76
return {}
+77
+78
def _reply(self, obj, code=200):
+79
data = json.dumps(obj).encode()
+80
self.send_response(code)
+81
self.send_header("Content-Type", "application/json")
+82
self.send_header("Content-Length", str(len(data)))
+83
self.end_headers()
+84
self.wfile.write(data)
+85
+86
def do_GET(self):
+87
if self.path == "/_stats":
+88
with LOCK:
+89
self._reply({"delivered": STATE["delivered_ids"],
+90
"sends": STATE["sends"],
+91
"getupdates": STATE["getupdates"],
+92
"pending": [u["update_id"] for u in STATE["updates"]]})
+93
return
+94
self._reply({"ok": False, "description": "not found"}, 404)
+95
+96
def do_POST(self):
+97
body = self._body()
+98
path = self.path
+99
+100
# ---- control endpoints ----
+101
if path == "/_seed":
+102
with LOCK:
+103
STATE["updates"] = list(body.get("updates", []))
+104
STATE["next_id"] = (max([u["update_id"] for u in STATE["updates"]],
+105
default=0) + 1)
+106
STATE["getupdates"] = 0
+107
STATE["sends"] = 0
+108
STATE["delivered_ids"] = []
+109
self._reply({"ok": True})
+110
return
+111
if path == "/_add":
+112
with LOCK:
+113
uid = STATE["next_id"]
+114
STATE["next_id"] += 1
+115
STATE["updates"].append(make_update(
+116
uid, body.get("text", "msg"),
+117
body.get("sender_id", "42"), body.get("chat_id", "1001")))
+118
self._reply({"ok": True, "update_id": uid})
+119
return
+120
+121
# ---- Telegram Bot API ----
+122
# path looks like /bot<token>/<method>
+123
method = path.rsplit("/", 1)[-1]
+124
+125
if method == "getUpdates":
+126
offset = body.get("offset", 0) or 0
+127
with LOCK:
+128
STATE["getupdates"] += 1
+129
if offset > 0:
+130
# Confirm: drop everything below the offset.
+131
STATE["updates"] = [u for u in STATE["updates"]
+132
if u["update_id"] >= offset]
+133
result = list(STATE["updates"])
+134
ids = [u["update_id"] for u in result]
+135
STATE["delivered_ids"].append({"offset": offset, "returned": ids})
+136
logline(REQ_LOG,
+137
f"getUpdates offset={offset} -> returned={ids}")
+138
self._reply({"ok": True, "result": result})
+139
return
+140
+141
if method == "sendMessage":
+142
chat_id = body.get("chat_id")
+143
text = body.get("text", "")
+144
with LOCK:
+145
STATE["sends"] += 1
+146
n = STATE["sends"]
+147
logline(SEND_LOG, f"#{n} sendMessage chat_id={chat_id} text={text!r}")
+148
logline(REQ_LOG, f"sendMessage chat_id={chat_id} text={text!r}")
+149
self._reply({"ok": True, "result": {
+150
"message_id": 9000 + n, "date": 1,
+151
"chat": {"id": chat_id, "type": "private"}, "text": text}})
+152
return
+153
+154
# Any other Telegram method (getMe, etc.) -> generic ok.
+155
logline(REQ_LOG, f"{method} (generic-ok)")
+156
self._reply({"ok": True, "result": {}})
+157
+158
+159
if __name__ == "__main__":
+160
logline(REQ_LOG, f"--- mock start on :{PORT} ---")
+161
srv = ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
+162
srv.serve_forever()
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,98 @@
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 positive integer. #f means "cold start" -> drain, don't
+194
;; deliver, the pre-existing backlog.
+195
(define (load-persisted-offset)
+196
(let ((path (offset-file-path)))
+197
(and (file-exists? path)
+198
(guard (e (else #f))
+199
(let ((n (string->number (string-trim (read-file-string path)))))
+200
(and (integer? n) (> n 0) n))))))
+201
+202
;; Persist the offset. Best-effort: a failed write only risks a
+203
;; bounded re-delivery on the next restart, never a crash.
+204
(define (save-offset! n)
+205
(guard (e (else #f))
+206
(ensure-directory (path-dirname (offset-file-path)))
+207
(write-file-string (offset-file-path) (number->string n))))
+208
+209
;; Cold start: advance the offset past every currently-pending
+210
;; update WITHOUT delivering any of them, so deploying
+211
;; offset-persistence does not itself fire one last storm from the
+212
;; backlog that already accumulated with no saved offset. Retries
+213
;; through transient errors -- we must not fall through to the
+214
;; delivery loop with the backlog still pending. Heartbeats keep the
+215
;; supervisor from killing the child while draining. Returns the
+216
;; count drained (for logging).
+217
(define (drain-backlog! bot)
+218
(let ((client (tg-bot-client bot)))
+219
(let loop ((backoff *error-backoff-initial*) (total 0))
+220
(poller-emit-line! *heartbeat-line*)
+221
(when (poller-parent-gone?)
+222
(exit 0))
+223
(let* ((result (guard (e (else
+224
(log-error (format "Backlog drain error: ~a" e))
+225
'error))
+226
(tg-get-updates client timeout: 0)))
+227
(updates (cond
+228
((eq? result 'error) 'error)
+229
((array? result) (array->list result))
+230
(else '()))))
+231
(cond
+232
;; Transient failure: back off and retry (still not
+233
;; delivering); heartbeats emitted by poller-sleep/heartbeat.
+234
((eq? updates 'error)
+235
(poller-sleep/heartbeat backoff)
+236
(loop (min (* backoff 2) *error-backoff-max*) total))
+237
;; Backlog fully drained.
+238
((null? updates) total)
+239
;; Advance past this batch (no dispatch) and continue.
+240
(else
+241
(for-each
+242
(lambda (raw)
+243
(let ((update (dict->tg-update raw)))
+244
(set-tg-client-last-update-id!
+245
client
+246
(+ (tg-update-update-id update) 1))))
+247
updates)
+248
(loop *error-backoff-initial* (+ total (length updates)))))))))
+249
+250
;; Restore the saved offset, or silently drain the backlog on a cold
+251
;; start, before the delivery loop begins.
+252
(define (initialize-offset! bot)
+253
(let ((saved (load-persisted-offset)))
+254
(if saved
+255
(begin
+256
(set-tg-client-last-update-id! (tg-bot-client bot) saved)
+257
(log-info "Restored Telegram getUpdates offset" offset: saved))
+258
(let ((drained (drain-backlog! bot)))
+259
(let ((offset (tg-client-last-update-id (tg-bot-client bot))))
+260
(save-offset! offset)
+261
(log-info "Cold start: drained Telegram backlog (not delivered)"
+262
count: drained offset: offset))))))
+263
264
;;; Entry point for `courier --telegram-poller`.
265
;;;
266
;;; Polls Telegram synchronously and forwards allowed messages to
@@ -175,9 +272,19 @@
272
(: courier-config? -> void?)
273
(let* ((token (courier-config-telegram-token config))
274
(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*)))
+275
;; Honor COURIER_TELEGRAM_API_URL for the poll path too. The
+276
;; send path (telegram.sgl) already redirects via config, but
+277
;; make-tg-bot hard-codes api.telegram.org -- so before this,
+278
;; the poller ALWAYS hit real Telegram, and there was no way
+279
;; to reproduce/verify inbound polling off-device against a
+280
;; mock. Build the bot's client directly with the override.
+281
(api-url (or (courier-config-telegram-api-url config)
+282
"https://api.telegram.org"))
+283
(bot (tg-bot
+284
client: (tg-client token: token
+285
api-url: api-url
+286
request-timeout: *poll-request-timeout*
+287
connect-timeout: *poll-connect-timeout*))))
288
289
(poller-emit-line!
290
(json-encode `((type . "hello") (pid . ,(process-id)))))
@@ -209,6 +316,11 @@
316
317
(log-info "Telegram poller child started" pid: (process-id))
318
+319
;; Restore the getUpdates offset (or drain the backlog on a cold
+320
;; start) BEFORE polling, so a restart never re-delivers the
+321
;; ~24h Telegram backlog. See the offset-persistence section.
+322
(initialize-offset! bot)
+323
324
(let loop ((backoff *error-backoff-initial*))
325
;; Heartbeat IMMEDIATELY BEFORE the (blocking) poll so the
326
;; supervisor's liveness window resets right at the start of
@@ -232,6 +344,12 @@
344
#f))
345
(tg-bot-tick bot)
346
#t)))
+347
;; Persist the (possibly advanced) offset after a successful
+348
;; tick so the next child resumes exactly here -- a delivered
+349
;; message is confirmed by the next getUpdates offset and is
+350
;; never re-delivered across a restart.
+351
(when ok?
+352
(save-offset! (tg-client-last-update-id (tg-bot-client bot))))
353
;; Heartbeats are the supervisor's liveness signal --
354
;; emitted on errors too: an erroring child is alive and
355
;; backing off, not wedged.
test/test-poller-offset.sgladded
@@ -0,0 +1,88 @@
+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
(run-tests)