Commit95515da7Recorded4 Aug 2026Repositorycourier

Re-acquire the poller claim periodically, and fix two review findings

Message

Three changes, two of them defects found by adversarial review of the previous commits.

  1. TOCTOU in the stale-claim break (the serious one). break-stale-claim! unlinked the claim unconditionally. Deciding the owner is dead involves a proc-comm port read, which yields the scheduler inside the supervisor goroutine, so a second instance reaching the same verdict can have broken AND retaken the claim in between -- and the blind delete would then remove a LIVE owner's claim, leaving both instances polling. That is the original 409 bug re-entering through the repair path, with nothing left to notice it. The unlink is now conditional on the claim still naming the pid we judged; a racer that still slips through loses at the settle check.
  1. Log pruning could delete a LIVE instance's log. It selected purely by mtime, and mtime is whole seconds and goes stale on an idle process. A leader alive since Jul 23 with ten newer generations behind it would have been pruned while running -- destroying exactly the long-lived evidence per-instance logs exist to preserve, from the same long-lived session that caused the original problem. Pruning now consults pid-liveness (reused from the claim module) and only ever removes logs whose owning process is gone. A name carrying no pid, such as a worker's courier-<task>.log, is not ours to judge and is left alone.
  1. Periodic re-acquire of the claim, approved by the leader after I raised that the brief's "do not retry in a loop" produced a worse failure than the one it fixed. Acquiring once and giving up means that when the HOLDER exits, every other healthy instance has already decided, and nothing polls again until a brand-new process starts: a silent go-dark in place of a visible 409 storm. Non-holders now re-check every 60s. Logging is on state change only, so a long-lived non-polling session writes one line, not one a minute, and the normal case is never a warn.

Harness case 4 now proves the takeover with the ORIGINAL instance rather than a fresh one -- measured: skip logged at 06:11:55, takeover at 06:12:55, exactly one skip line and exactly one takeover line.

Also from review, no behaviour change: courier-state-dir replaces four open-coded copies of (path-dirname (default-relay-dir)); claim-token-hash uses string-pad; prune uses glob; two comments that this branch had made untrue were corrected; the appended test group moved above (run-tests) so it runs under direct invocation too.

Suite 111/0. Harness 44/0 with the pre-fix control still red (4 conflicts, 2 poller children). Storm repro 1 send -> 1 delivery with its window-neutered positive control at 4. Sentinel-zero repro green.

Changed
 repro/single_poller_harness.py | 101 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
 src/courier/claim.sgl          |  30 +++++++++++++++++++++---------
 src/courier/dedup.sgl          |   2 +-
 src/courier/main.sgl           |  64 +++++++++++++++++++++++++++++++++++++++++++++-------------------
 src/courier/poller.sgl         |  52 +++++++++++++++++++++++++++++++++++++++++-----------
 src/courier/relay.sgl          |  10 ++++++++++
 src/courier/telegram.sgl       |  16 +++++++++++-----
 test/test-telegram.sgl         |   4 ++--
 8 files changed, 218 insertions(+), 61 deletions(-)
Diff
repro/single_poller_harness.pymodified
@@ -6,7 +6,10 @@ Telegram: every courier instance is started with COURIER_TELEGRAM_API_URL
6
pointed at the local mock, which is the only endpoint the send AND poll paths
7
use once that variable is set.
8
−9
What it proves, in the order the cases run:
+9
The cases are NUMBERED BY TOPIC, and run in the order 1, 3, 4, 7, 8, 5, 6, 2 --
+10
the ordering is dictated by shared state (cases 1, 3, 4 and 7 all operate on the
+11
same pair of live instances, and the pre-fix control in case 2 runs last so its
+12
409s can never be confused with the fixed binary's). What each proves:
13
14
1 ONE POLLER two leader-mode couriers on one token -> exactly one
15
poller child process, and zero 409s at the mock.
@@ -16,10 +19,15 @@ What it proves, in the order the cases run:
19
is a claim of absence made by an instrument never
20
shown capable of detecting presence.
21
3 NON-POLLER IS FINE the instance that lost the claim still serves its
−19
MCP tools -- create-relay / list-relays / close-relay.
−20
4 SIGKILL RECOVERY SIGKILL (not a graceful stop) the claim holder, start
−21
a fresh instance: it detects the dead owner, breaks
−22
the stale claim, and polls.
+22
MCP tools -- create-relay, a real worker connecting
+23
over that relay, send both ways, close-relay.
+24
4 SIGKILL RECOVERY SIGKILL (not a graceful stop) the claim holder, then
+25
(a) the OTHER already-running instance re-acquires
+26
and starts polling on its own, and (b) a fresh
+27
instance can also break a stale claim. (a) is the
+28
one that matters: without the periodic re-acquire,
+29
a healthy instance would sit there forever and the
+30
estate would silently stop receiving Telegram.
31
5 DRY-RUN IS HONEST with COURIER_DISABLE_TELEGRAM_SEND=1 the tool returns
32
a string that cannot be read as success, does not
33
raise, and delivers nothing at the mock.
@@ -31,6 +39,9 @@ What it proves, in the order the cases run:
39
log files, and grep works on both (the shared-log
40
interleaving produced NUL bytes, which made grep
41
silently report zero matches during an incident).
+42
8 LOG PRUNING instance logs are capped, a LIVE instance's log is
+43
never pruned however old it looks, and nothing that
+44
is not an instance log is touched.
45
46
Usage:
47
single_poller_harness.py --bin <courier> [--control-bin <pre-fix courier>]
@@ -61,6 +72,9 @@ DWELL = 2.5
72
# binary would only mean "we did not watch for long enough".
73
OBSERVE_WINDOW = 20
74
+75
# Must match *claim-recheck-interval* in src/courier/poller.sgl.
+76
CLAIM_RECHECK = 60
+77
78
FAILED = []
79
PASSED = []
80
@@ -467,7 +481,7 @@ def case_relay_tools(binp, b, run_dir):
481
r == "Relay 'harness-relay' closed.", repr(r))
482
483
−470
def case_sigkill(binp, run_dir, a, state_dir):
+484
def case_sigkill(binp, run_dir, a, b, state_dir):
485
print("=== 4 SIGKILL RECOVERY ===")
486
dead_pid = a.pid
487
a.sigkill()
@@ -477,6 +491,33 @@ def case_sigkill(binp, run_dir, a, state_dir):
491
owner == str(dead_pid),
492
f"owner={owner} killed={dead_pid}")
493
+494
# (a) The instance that was ALREADY RUNNING and had been told to skip must
+495
# take over by itself. This is the case that would silently not happen if
+496
# the supervisor acquired once and gave up: B is healthy, holds the token,
+497
# and would never poll again.
+498
took_over = wait_for(lambda: len(poller_children(b.pid)) == 1,
+499
timeout=CLAIM_RECHECK + 45)
+500
check("the already-running non-poller re-acquires and starts polling",
+501
took_over, f"B children={poller_children(b.pid)} "
+502
f"claim owner={claim_owner(state_dir)}")
+503
check("the claim now records the instance that took over",
+504
claim_owner(state_dir) == str(b.pid),
+505
f"owner={claim_owner(state_dir)} B={b.pid}")
+506
log = b.log_text()
+507
check("the takeover is logged, once, at INFO",
+508
len([l for l in log.splitlines()
+509
if "previous holder is gone" in l]) == 1,
+510
repr([l for l in log.splitlines() if "claim" in l]))
+511
# And the skip line was NOT repeated every recheck interval.
+512
check("the periodic re-check does not spam the log",
+513
len([l for l in log.splitlines()
+514
if "another courier instance holds" in l]) == 1,
+515
f"{len([l for l in log.splitlines() if 'another courier instance holds' in l])} skip lines")
+516
+517
# (b) A fresh instance can break a stale claim too. Kill the new holder
+518
# hard so the claim is stale again.
+519
b.sigkill()
+520
time.sleep(1)
521
c = Courier(binp, run_dir, "inst-c")
522
c.initialize()
523
got = wait_for(lambda: len(poller_children(c.pid)) == 1, timeout=45)
@@ -515,6 +556,28 @@ def case_log_pruning(binp, run_dir):
556
# Staggered mtimes, oldest first, so "keep the newest N" is testable.
557
os.utime(p, (now - (100 - i) * 60, now - (100 - i) * 60))
558
fakes.append(p)
+559
# A log belonging to a LIVE COURIER, made to look ancient. This is the
+560
# Jul-23 case: a long-lived session whose log has an old mtime because it
+561
# has been quiet, with ten newer generations behind it. Pruning it by age
+562
# would destroy exactly the evidence per-instance logs exist to preserve
+563
# -- and it is the same long-lived process that caused the original bug.
+564
#
+565
# It has to be a real courier, not this harness: liveness is "the pid is
+566
# running AND its comm matches courier's", so a live PYTHON pid is
+567
# correctly judged a recycled pid and pruned. (Measured: the first
+568
# version of this fixture used os.getpid() and failed for exactly that
+569
# reason -- the code was right and the fixture was wrong.)
+570
live = Courier(binp, d, "prune-live")
+571
live.initialize()
+572
live_old = live.log_path()
+573
os.utime(live_old, (now - 90 * 86400, now - 90 * 86400))
+574
+575
# A worker-style name (no pid) must also be left alone.
+576
worker_log = os.path.join(d, "courier-some-task-name.log")
+577
with open(worker_log, "w") as f:
+578
f.write("worker log\n")
+579
os.utime(worker_log, (now - 91 * 86400, now - 91 * 86400))
+580
581
# Files that are NOT instance logs must survive untouched.
582
bystanders = {
583
os.path.join(d, "send-dedup.state"): "1 abc\n",
@@ -531,21 +594,32 @@ def case_log_pruning(binp, run_dir):
594
time.sleep(2)
595
remaining = sorted(n for n in os.listdir(d)
596
if n.startswith("courier-") and n.endswith(".log"))
−534
check("instance logs are capped at 10", len(remaining) == 10,
−535
f"{len(remaining)} remain: {remaining}")
+597
dead_remaining = [n for n in remaining
+598
if n not in (f"courier-{c.pid}.log",
+599
os.path.basename(live_old),
+600
os.path.basename(worker_log))]
+601
check("dead instance logs are capped at 10", len(dead_remaining) == 10,
+602
f"{len(dead_remaining)} dead remain: {dead_remaining}")
603
check("the instance's own log survived pruning",
604
f"courier-{c.pid}.log" in remaining, f"{remaining}")
−538
check("the OLDEST fakes are the ones removed",
+605
check("the OLDEST dead fakes are the ones removed",
606
os.path.basename(fakes[0]) not in remaining
607
and os.path.basename(fakes[14]) in remaining,
608
f"{remaining}")
+609
# The rule that matters: liveness beats age.
+610
check("a LIVE instance's log survives even when 90 days old",
+611
os.path.basename(live_old) in remaining, f"{remaining}")
+612
check("a log whose name carries no pid is left alone",
+613
os.path.basename(worker_log) in remaining, f"{remaining}")
614
survived = [os.path.basename(p) for p in bystanders if os.path.exists(p)]
615
check("non-log files (dedup state, offset) are untouched",
616
len(survived) == len(bystanders),
617
f"survived={survived} of {[os.path.basename(p) for p in bystanders]}")
−546
c.close_stdin()
+618
for inst in (c, live):
+619
inst.close_stdin()
620
time.sleep(1)
−548
c.kill()
+621
for inst in (c, live):
+622
inst.kill()
623
624
625
EXPECTED_DRY_RUN = ("Message NOT sent: COURIER_DISABLE_TELEGRAM_SEND is set "
@@ -652,11 +726,10 @@ def main():
726
a, b, state_dir = case_one_poller(args.bin, run_dir)
727
instances += [a, b]
728
case_relay_tools(args.bin, b, run_dir)
−655
c = case_sigkill(args.bin, run_dir, a, state_dir)
+729
c = case_sigkill(args.bin, run_dir, a, b, state_dir)
730
instances.append(c)
731
pids = [a.pid, b.pid, c.pid]
−658
for inst in (b, c):
−659
inst.close_stdin()
+732
c.close_stdin()
733
time.sleep(1.5)
734
case_log_hygiene(run_dir, pids)
735
for inst in instances:
src/courier/claim.sglmodified
@@ -93,19 +93,14 @@
93
;;; little to reconstruct a token from.
94
(define (claim-token-hash token)
95
(: string? -> string?)
−96
(let* ((h (fnv1a-hex token))
−97
(n (string-length h))
−98
(padded (if (< n 16)
−99
(string-append (make-string (- 16 n) #\0) h)
−100
h)))
−101
(substring padded 0 12)))
+96
(substring (string-pad (fnv1a-hex token) 16 #\0) 0 12))
97
98
;;; Where the claim for `token` lives. Scoped to the token (two bots on
99
;;; one machine do not collide) AND to the relay dir (so a test courier
100
;;; with its own COURIER_RELAY_DIR never contends with the live one).
101
(define (poller-claim-path token)
102
(: string? -> string?)
−108
(path-join (path-dirname (default-relay-dir))
+103
(path-join (courier-state-dir)
104
(string-append "telegram-poller-"
105
(claim-token-hash token)
106
".claim")))
@@ -232,7 +227,13 @@
227
;; Present but unreadable: leave it alone rather than break
228
;; something we cannot attribute.
229
((not owner) 'held-by-other)
−235
;; Our own pid already in the link (a re-entry): keep it.
+230
;; Our own pid already in the link. Normally try-claim!
+231
;; has already returned 'acquired for this, since it reads
+232
;; the link back; this is the path where make-symlink
+233
;; RAISED (EACCES, ENOENT on a vanished dir) over a claim
+234
;; we still hold, so it is reachable only via try-claim!'s
+235
;; guard. Keeping it means a transient filesystem error
+236
;; cannot make us abandon a claim we own.
237
((string=? owner me)
238
(set-car! *held-claim* path)
239
'acquired)
@@ -251,7 +252,18 @@
252
(define (break-stale-claim! path owner me)
253
(log-info "Breaking stale Telegram poller claim (owner gone)"
254
owner-pid: owner)
−254
(guard (e (else #f)) (delete-file path))
+255
;; Unlink ONLY while the claim still names the dead pid we just judged.
+256
;; An unconditional delete here is a TOCTOU big enough to drive two
+257
;; pollers through: deciding `owner` is dead involves a proc-comm port
+258
;; read, which yields the scheduler inside the supervisor goroutine, so
+259
;; a second instance that reached the same verdict can have broken the
+260
;; claim AND taken it in the meantime. Deleting blind would then remove
+261
;; a LIVE owner's claim, and both instances would poll -- the exact 409
+262
;; storm this module exists to remove, with no path to notice it.
+263
;; Re-reading first narrows the window to the unlink itself, and a
+264
;; racer that slips through still loses at the settle check below.
+265
(when (equal? (read-claim-owner path) owner)
+266
(guard (e (else #f)) (delete-file path)))
267
(if (not (eq? (try-claim! path me) 'acquired))
268
'held-by-other
269
(begin
src/courier/dedup.sglmodified
@@ -66,7 +66,7 @@
66
override
67
;; ".state", not ".log": this is a rewritten-in-place state set,
68
;; not an append log like courier.log.
−69
(path-join (path-dirname (default-relay-dir)) "send-dedup.state"))))
+69
(path-join (courier-state-dir) "send-dedup.state"))))
70
71
;; ---- FNV-1a 64-bit key hashing (no plaintext on disk) ----
72
(define *fnv-offset* 14695981039346656037)
src/courier/main.sglmodified
@@ -20,6 +20,7 @@
20
(sigil mcp channel)
21
(sigil log)
22
(courier config)
+23
(courier claim)
24
(courier telegram)
25
(courier poller)
26
(courier notify)
@@ -91,36 +92,61 @@
92
;; How many courier-<pid>.log files to keep in the state dir.
93
(define *log-keep-generations* 10)
94
+95
;; The pid encoded in a courier-<pid>.log path, or #f if the name does
+96
;; not carry one (a worker's courier-<task>.log, for instance).
+97
(define (instance-log-pid path)
+98
(let* ((base (path-basename path))
+99
(n (string-length base))
+100
(stem (substring base 8 (- n 4)))) ;; "courier-" ... ".log"
+101
(and (string->number stem) stem)))
+102
+103
;; Is this log file being written by a process that is still running?
+104
;; Reuses the claim module's liveness check rather than inferring from
+105
;; mtime. mtime is whole seconds and an idle-but-alive instance can have
+106
;; a very old one -- a leader alive since Jul 23 with ten newer
+107
;; generations behind it would be pruned while running, silently
+108
;; destroying exactly the long-lived evidence per-instance logs exist to
+109
;; preserve. A name with no pid in it is not ours to judge, so it is
+110
;; treated as live and left alone.
+111
(define (instance-log-live? path)
+112
(let ((pid (instance-log-pid path)))
+113
(if (not pid)
+114
#t
+115
(not (eq? 'dead (pid-liveness pid))))))
+116
117
;; Remove all but the newest *log-keep-generations* courier-<pid>.log
−95
;; files. Best-effort and never fatal: a failure here costs disk, not
−96
;; correctness. A live instance's file has a recent mtime and is
−97
;; therefore kept; even if one were removed, Unix keeps the open fd
−98
;; valid, so the writer cannot be broken by this.
+118
;; files whose owning process is GONE. Best-effort and never fatal: a
+119
;; failure here costs disk, not correctness.
120
(define (prune-instance-logs! log-dir)
121
(guard (e (else #f))
−101
(let* ((entries (or (directory-list log-dir) '()))
−102
(logs (filter (lambda (n)
−103
(and (string-starts-with? n "courier-")
−104
(string-ends-with? n ".log")))
−105
entries))
−106
(paths (map (lambda (n) (path-join log-dir n)) logs))
+122
(let* ((dead (filter (lambda (p) (not (instance-log-live? p)))
+123
(glob (path-join log-dir "courier-*.log"))))
124
(sorted (list-sort (lambda (a b)
125
(> (or (file-mtime a) 0)
126
(or (file-mtime b) 0)))
−110
paths)))
+127
dead)))
128
(let loop ((rest sorted) (i 0))
129
(unless (null? rest)
130
(when (>= i *log-keep-generations*)
131
(guard (e (else #f)) (delete-file (car rest))))
132
(loop (cdr rest) (+ i 1)))))))
133
−117
;; Configure persistent logging for leader mode in APPEND mode, so
−118
;; a hung instance's evidence survives across leader restarts. A
−119
;; /mcp reconnect SIGKILLs the leader and spawns a fresh one; the
−120
;; old rotate-current-to-.prev scheme kept only ONE prior
−121
;; generation, so a hang -> /mcp -> still-hung -> /mcp loop lost
−122
;; the original (load-bearing) evidence. Appending into one file
−123
;; keeps the whole history (capped by *log-carryover-max*).
+134
;; Configure persistent logging for leader mode, so a hung instance's
+135
;; evidence survives across leader restarts. A /mcp reconnect SIGKILLs
+136
;; the leader and spawns a fresh one; the old rotate-current-to-.prev
+137
;; scheme kept only ONE prior generation, so a hang -> /mcp ->
+138
;; still-hung -> /mcp loop lost the original (load-bearing) evidence.
+139
;;
+140
;; That history is now kept by KEEPING THE FILES rather than by
+141
;; concatenating into one. Each generation is its own courier-<pid>.log
+142
;; and prune-instance-logs! retains the ten most recent DEAD ones, so a
+143
;; hang/reconnect loop leaves ten separate, individually readable
+144
;; generations. open-log-append!'s carryover no longer fires on this
+145
;; path (a fresh pid means a fresh filename, so there is nothing to
+146
;; carry); it is kept because pids DO get reused, and truncating a
+147
;; predecessor's log because the kernel handed us its number would lose
+148
;; exactly the evidence this function exists to protect. The --log
+149
;; path still uses the carryover for real.
150
;;
151
;; PER-INSTANCE FILE (courier-<pid>.log, was the shared courier.log):
152
;; append here is EMULATED -- read the old contents, reopen (which
@@ -146,7 +172,7 @@
172
;; configures this (main() calls it whenever no explicit --log target
173
;; was given), so production never logs to an async port.
174
(define (configure-persistent-log!)
−149
(let* ((log-dir (path-dirname (default-relay-dir)))
+175
(let* ((log-dir (courier-state-dir))
176
(log-path (path-join log-dir
177
(string-append "courier-"
178
(number->string (process-id))
src/courier/poller.sglmodified
@@ -86,6 +86,15 @@
86
;; connect lets a bad IP be abandoned fast (a working one is tried).
87
(define *poll-connect-timeout* 10) ;; seconds; must be < stale threshold
88
+89
;; How often an instance that did NOT get the single-poller claim looks
+90
;; again. Without this the fix trades a visible failure for a silent one:
+91
;; the claim HOLDER exits, every other live courier has already decided
+92
;; once and returned, and nothing polls until some brand-new process
+93
;; happens to start. Deliberately slow and quiet -- one filesystem check
+94
;; a minute, logged only on a state CHANGE, never a warn for the normal
+95
;; case of another instance holding it.
+96
(define *claim-recheck-interval* 60)
+97
98
;; Supervisor: wedge detection and restart backoff
99
(define *poller-stale-threshold* 30) ;; seconds of child silence
100
(define *watchdog-interval* 10) ;; seconds between checks
@@ -188,7 +197,7 @@
197
;; Where the offset lives: next to courier.log, keyed off the relay
198
;; dir so a poller child and the leader agree on the location.
199
(define (offset-file-path)
−191
(path-join (path-dirname (default-relay-dir)) "telegram-offset"))
+200
(path-join (courier-state-dir) "telegram-offset"))
201
202
;; Persisted offset, or #f when the file is absent, unreadable, or
203
;; not a STRICTLY POSITIVE integer. #f means "cold start" -> drain,
@@ -541,19 +550,40 @@
550
;;;
551
;;; Telegram allows ONE getUpdates consumer per bot token, so this first
552
;;; takes the single-poller claim (see (courier claim)). Not getting it is
−544
;;; the NORMAL outcome for the 2nd..Nth concurrent session: this returns
−545
;;; quietly and the instance runs on as a full MCP server -- tools,
−546
;;; relays, sends and notifications all unaffected. There is deliberately
−547
;;; no retry loop and no exit.
+553
;;; the NORMAL outcome for the 2nd..Nth concurrent session: the instance
+554
;;; runs on as a full MCP server -- tools, relays, sends and notifications
+555
;;; all unaffected -- and simply looks again every
+556
;;; *claim-recheck-interval* seconds.
+557
;;;
+558
;;; The re-check is what stops this fix from swapping a loud failure for
+559
;;; a quiet one. Acquiring once and giving up means that when the HOLDER
+560
;;; exits, every other healthy instance has already decided and nothing
+561
;;; polls again until a brand-new process starts. A 409 storm is at least
+562
;;; visible in a log; a poller that silently stopped is not.
+563
;;;
+564
;;; Logging is on STATE CHANGE only, so a long-lived non-polling session
+565
;;; does not write a line a minute forever.
566
(define (telegram-poller-supervisor server config)
567
(: mcp-server? courier-config? -> void?)
568
(channel-wait-initialized! server)
−551
(case (acquire-poller-claim! (courier-config-telegram-token config))
−552
((acquired) (poller-supervise-loop server))
−553
((held-by-other)
−554
(log-info "Telegram polling skipped -- another courier instance holds the poller claim (normal; this instance is fully functional)"))
−555
(else
−556
(log-warn "Telegram polling skipped -- could not establish poller claim ownership"))))
+569
(let ((token (courier-config-telegram-token config)))
+570
(let loop ((announced #f))
+571
(let ((result (acquire-poller-claim! token)))
+572
(case result
+573
((acquired)
+574
(when announced
+575
(log-info "Telegram poller claim acquired -- the previous holder is gone"))
+576
;; Never returns: supervises and respawns the child forever.
+577
(poller-supervise-loop server))
+578
(else
+579
(unless (eq? announced result)
+580
(if (eq? result 'held-by-other)
+581
(log-info "Telegram polling skipped -- another courier instance holds the poller claim (normal; this instance is fully functional)"
+582
recheck-seconds: *claim-recheck-interval*)
+583
(log-warn "Telegram polling skipped -- could not establish poller claim ownership"
+584
recheck-seconds: *claim-recheck-interval*)))
+585
(sleep *claim-recheck-interval*)
+586
(loop result)))))))
587
588
(define (poller-supervise-loop server)
589
(log-info "Telegram polling started (isolated poller process)")
src/courier/relay.sglmodified
@@ -41,6 +41,7 @@
41
make-media-upload-envelope
42
43
default-relay-dir
+44
courier-state-dir
45
create-relay!
46
close-relay!
47
register-relay-tools!
@@ -187,6 +188,15 @@
188
(or (getenv "COURIER_RELAY_DIR")
189
(path-join (or (getenv "HOME") "/tmp") ".courier" "relays")))
190
+191
;;; Where courier keeps its per-machine state: the instance logs, the
+192
;;; getUpdates offset, the send-dedup record, and the single-poller
+193
;;; claim. Derived from the relay dir so all of them move together when
+194
;;; COURIER_RELAY_DIR is overridden -- which is what keeps a test
+195
;;; courier from ever contending with the live one.
+196
(define (courier-state-dir)
+197
(: -> string?)
+198
(path-dirname (default-relay-dir)))
+199
200
(define (make-relay-state)
201
(relay-state base-dir: (default-relay-dir)))
202
src/courier/telegram.sglmodified
@@ -149,11 +149,17 @@
149
;; bug it was masking, making production
150
;; observation of that bug impossible.
151
;; A dry-run is a DEFINITE non-delivery, so
−152
;; it also releases the dedup key: without
−153
;; that, the very next identical send hits
−154
;; the 'suppress path and reports "Message
−155
;; sent." — reintroducing the same lie one
−156
;; call later. Returning (not raising) is
+152
;; it also releases the dedup key, for the
+153
;; same reason a rejected send does. The
+154
;; key was recorded BEFORE this branch;
+155
;; leaving it would make the next
+156
;; identical send take the 'suppress path
+157
;; and claim the message "was accepted for
+158
;; delivery" when nothing ever accepted
+159
;; it. Measured on the pre-fix binary:
+160
;; gag on, gag off, same text -> zero
+161
;; deliveries while reporting success.
+162
;; Returning (not raising) is
163
;; deliberate: an MCP error invites a client
164
;; retry, and retry-against-a-killed-courier
165
;; is the mechanism behind the send storm.
test/test-telegram.sglmodified
@@ -103,8 +103,6 @@
103
;; 2.5 MB exactly = 2621440 bytes; integer truncation gives "2.5 MB"
104
(assert-equal "2.5 MB" (format-size (quotient (* 5 1024 1024) 2)))))
105
−106
(run-tests)
−107
106
;; ============================================================
107
;; Send-result strings: a caller must be able to tell delivery
108
;; from non-delivery by reading the return value
@@ -142,3 +140,5 @@
140
(test "the two non-delivery strings are distinct from each other"
141
(assert-true (not (string=? *dry-run-not-sent-message*
142
*duplicate-suppressed-message*)))))
+143
+144
(run-tests)