Commitdd5a178cRecorded4 Aug 2026Repositorycourier

Add COURIER_TELEGRAM_POLL_FORCE, an escape hatch with a stand-down handshake

Message

David's ruling: "Telegram polling is only for the leader and there should only ever be one running by default." That confirms the semantics already built -- leader-mode eligibility, oldest-live-wins, no new configuration required for the default -- so nothing about the default path changes here. An opt-in marker was explicitly NOT added: it would make the poller default to OFF, and a leader session that forgot to set it would silently stop receiving Telegram, which is the go-dark class this branch removed.

What is added is a way OUT of the one case the re-acquire cannot handle. The claim decides which session receives David's INBOUND messages, not merely which one avoids 409s. The 60s re-acquire recovers from a DEAD holder; a live-but-abandoned session holds a perfectly valid claim while his "task: ..." messages land where nobody is watching, and the only remedy was killing processes by hand. COURIER_TELEGRAM_POLL_FORCE=1 makes that one env var and an /mcp.

The hard part is not taking the claim, it is that the displaced holder must stop. Two pollers arriving through the repair path would be the original bug re-entering through its own fix, so:

  • the holder re-confirms ownership every 10s while polling, and on loss kills its child, abandons the claim WITHOUT unlinking it (the file names the new owner; deleting would leave nobody polling), logs at info, and falls back to being a normal non-polling instance that keeps re-checking;
  • a FORCED acquire returns a distinct verdict and waits 14s -- strictly more than the holder's check interval -- before it starts polling, so there is no window with two consumers rather than merely a short one;
  • force applies to the FIRST attempt only. Two forcing instances would otherwise steal from each other every interval forever.

break-stale-claim! generalised to take-claim-from!: taking a claim from a dead owner and from a live one are the same mechanics, and the difference is only whether we were entitled to, which the caller has already decided. The conditional-unlink TOCTOU discipline applies to both.

Harness case 9 proves it end to end: A polls, B forces, B polls, A stops, A stays healthy and serving, A leaves B's claim intact, and ZERO 409s across the whole handover. Sabotage-tested by setting the stand-down wait to 0 -- conflicts 1, max_inflight 2, i.e. the assertion detects the overlap it exists to forbid.

The unit-level force test is deliberately NOT the obvious one. The force branch only runs when the owner reads 'alive, which needs a live pid with our comm -- a second courier, which does not exist in a unit test. A claim owned by pid 1 takes the ordinary stale path whether or not force is set, so the first draft asserted "forcing works" while never executing the force branch. It is replaced by tests that force does NOT change any other outcome, with the scope limit stated in the file.

Default path demonstrated unchanged, not asserted: the full harness run against the 95515da binary and against this one give identical verdicts for cases 1-8, and differ only in case 9, which the older binary cannot pass.

Suite 116/0. Harness 54/0 with the pre-fix control still red. Storm repro 1 send -> 1 delivery with its positive control at 4. Sentinel-zero green.

Changed
 repro/single_poller_harness.py |  91 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
 src/courier/claim.sgl          |  73 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
 src/courier/config.sgl         |  16 ++++++++++++++++
 src/courier/poller.sgl         | 101 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
 test/test-claim.sgl            |  83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
 5 files changed, 326 insertions(+), 38 deletions(-)
Diff
repro/single_poller_harness.pymodified
@@ -6,7 +6,7 @@ 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
The cases are NUMBERED BY TOPIC, and run in the order 1, 3, 4, 7, 8, 5, 6, 2 --
+9
The cases are NUMBERED BY TOPIC, and run in the order 1, 3, 4, 7, 9, 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:
@@ -42,6 +42,10 @@ same pair of live instances, and the pre-fix control in case 2 runs last so its
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
9 FORCED TAKEOVER COURIER_TELEGRAM_POLL_FORCE takes the claim from a
+46
LIVE holder; the displaced instance stops polling,
+47
stays healthy, and does not delete the new owner's
+48
claim -- with zero 409s across the handover.
49
50
Usage:
51
single_poller_harness.py --bin <courier> [--control-bin <pre-fix courier>]
@@ -506,7 +510,8 @@ def case_sigkill(binp, run_dir, a, b, state_dir):
510
log = b.log_text()
511
check("the takeover is logged, once, at INFO",
512
len([l for l in log.splitlines()
−509
if "previous holder is gone" in l]) == 1,
+513
if "claim acquired -- the previous holder is gone" in l
+514
and "[INFO]" in l]) == 1,
515
repr([l for l in log.splitlines() if "claim" in l]))
516
# And the skip line was NOT repeated every recheck interval.
517
check("the periodic re-check does not spam the log",
@@ -527,11 +532,88 @@ def case_sigkill(binp, run_dir, a, b, state_dir):
532
claim_owner(state_dir) == str(c.pid),
533
f"owner={claim_owner(state_dir)} C={c.pid}")
534
log = c.log_text()
−530
check("the break is logged", "Breaking stale" in log,
−531
repr([l for l in log.splitlines() if "stale" in l][:2]))
+535
check("the break is logged",
+536
any("Taking the Telegram poller claim" in l
+537
and "previous holder is gone" in l
+538
for l in log.splitlines()),
+539
repr([l for l in log.splitlines() if "Taking" in l][:2]))
540
return c
541
542
+543
def case_forced_takeover(binp, run_dir):
+544
"""COURIER_TELEGRAM_POLL_FORCE: a specific session takes the claim from a
+545
LIVE holder, and the displaced one stands down.
+546
+547
This is the case the unit tests cannot reach: the force branch only runs
+548
when the owner reads as 'alive, which needs a second real courier.
+549
+550
The property that matters is not just "B ends up polling" -- it is that
+551
there is never a moment with TWO pollers on the token. A takeover that
+552
produced even ten seconds of overlap would be the original 409 bug
+553
arriving through the escape hatch, so conflicts are asserted at zero
+554
across the whole handover.
+555
"""
+556
print("=== 9 FORCED TAKEOVER (COURIER_TELEGRAM_POLL_FORCE) ===")
+557
d = os.path.join(run_dir, "force")
+558
os.makedirs(d, exist_ok=True)
+559
post("/_seed", {"updates": []})
+560
post("/_dwell", {"seconds": DWELL})
+561
+562
a = Courier(binp, d, "force-a")
+563
a.initialize()
+564
got_a = wait_for(lambda: len(poller_children(a.pid)) == 1, timeout=40)
+565
check("holder A is polling before the takeover", got_a,
+566
f"A children={poller_children(a.pid)}")
+567
+568
b = Courier(binp, d, "force-b",
+569
extra_env={"COURIER_TELEGRAM_POLL_FORCE": "1"})
+570
b.initialize()
+571
+572
# B waits out the stand-down window before it starts, so allow for it.
+573
took = wait_for(lambda: len(poller_children(b.pid)) == 1, timeout=90)
+574
check("the forcing instance takes over and polls", took,
+575
f"B children={poller_children(b.pid)} owner={claim_owner(d)}")
+576
check("the claim records the forcing instance",
+577
claim_owner(d) == str(b.pid),
+578
f"owner={claim_owner(d)} B={b.pid}")
+579
+580
stood_down = wait_for(lambda: len(poller_children(a.pid)) == 0, timeout=45)
+581
check("the displaced holder STOPS polling", stood_down,
+582
f"A children={poller_children(a.pid)}")
+583
+584
alog = a.log_text()
+585
check("the displaced holder logs that it lost the claim",
+586
"claim taken by another instance" in alog,
+587
repr([l for l in alog.splitlines() if "claim" in l][-2:]))
+588
check("the displaced holder is still alive and serving",
+589
a.alive() and a.call("list-relays", {}) is not None)
+590
+591
blog = b.log_text()
+592
check("the forced takeover is logged at INFO",
+593
len([l for l in blog.splitlines()
+594
if "COURIER_TELEGRAM_POLL_FORCE" in l and "[INFO]" in l]) == 1,
+595
repr([l for l in blog.splitlines() if "claim" in l][:3]))
+596
+597
# Let both run a while so any overlap would show up at the mock.
+598
time.sleep(OBSERVE_WINDOW)
+599
s = stats()
+600
check("the handover produced NO 409s at any point", s["conflicts"] == 0,
+601
f"conflicts={s['conflicts']} getupdates={s['getupdates']} "
+602
f"max_inflight={s['max_inflight']}")
+603
check("the mock never saw two concurrent pollers during the handover",
+604
s["max_inflight"] <= 1, f"max_inflight={s['max_inflight']}")
+605
+606
# And the displaced instance must NOT have deleted the new owner's claim.
+607
check("the displaced holder left the new owner's claim intact",
+608
claim_owner(d) == str(b.pid), f"owner={claim_owner(d)}")
+609
+610
for inst in (a, b):
+611
inst.close_stdin()
+612
time.sleep(1)
+613
for inst in (a, b):
+614
inst.kill()
+615
+616
617
def case_log_pruning(binp, run_dir):
618
"""Per-instance logs must not accumulate forever, and must not take
619
anything else with them.
@@ -736,6 +818,7 @@ def main():
818
inst.kill()
819
instances = []
820
+821
case_forced_takeover(args.bin, run_dir)
822
case_log_pruning(args.bin, run_dir)
823
824
dry_dir, text = case_dry_run(args.bin, run_dir)
src/courier/claim.sglmodified
@@ -67,6 +67,8 @@
67
(courier relay))
68
(export acquire-poller-claim!
69
release-poller-claim!
+70
poller-claim-held?
+71
abandon-poller-claim!
72
poller-claim-path
73
claim-token-hash
74
read-claim-owner
@@ -204,16 +206,34 @@
206
(make-symlink me path)
207
(if (equal? (read-claim-owner path) me) 'acquired 'lost)))
208
+209
;;; Does this process still hold the claim for `token`?
+210
;;;
+211
;;; The poller calls this while running, because holding the claim once
+212
;;; is not the same as holding it now: COURIER_TELEGRAM_POLL_FORCE lets
+213
;;; another session take it. A displaced holder that kept polling would
+214
;;; put two consumers on the token again, which is the whole bug.
+215
(define (poller-claim-held? token)
+216
(: string? -> boolean?)
+217
(equal? (read-claim-owner (poller-claim-path token))
+218
(number->string (process-id))))
+219
220
;;; Try to become the single poller for `token`.
221
;;;
+222
;;; `force?` (COURIER_TELEGRAM_POLL_FORCE) takes the claim from a LIVE
+223
;;; holder too. Default off. It is applied only on the FIRST attempt, not
+224
;;; on the periodic re-checks -- two forcing instances would otherwise
+225
;;; steal from each other every interval forever.
+226
;;;
227
;;; Returns:
228
;;; 'acquired -- this process polls, and must call
229
;;; release-poller-claim! on clean shutdown
+230
;;; 'acquired-by-force -- as 'acquired, but taken from a LIVE holder, so
+231
;;; the caller must let that holder stand down first
232
;;; 'held-by-other -- another live courier polls. NORMAL. Log at info.
233
;;; 'undetermined -- liveness could not be established; do not poll,
234
;;; and do not break the existing claim.
−215
(define (acquire-poller-claim! token)
−216
(: string? -> symbol?)
+235
(define (acquire-poller-claim! token force?)
+236
(: string? any? -> symbol?)
237
(let* ((path (poller-claim-path token))
238
(me (number->string (process-id))))
239
(guard (e (else
@@ -239,19 +259,39 @@
259
'acquired)
260
(else
261
(case (pid-liveness owner)
−242
((alive) 'held-by-other)
+262
((alive)
+263
(if force?
+264
(forced (take-claim-from! path owner me
+265
"forced by COURIER_TELEGRAM_POLL_FORCE (previous holder is alive)"))
+266
'held-by-other))
267
((undetermined)
−244
(log-warn "Poller claim owner liveness undetermined"
−245
owner-pid: owner)
−246
'undetermined)
−247
(else (break-stale-claim! path owner me))))))))))
+268
(if force?
+269
(forced (take-claim-from! path owner me
+270
"forced by COURIER_TELEGRAM_POLL_FORCE (owner liveness undetermined)"))
+271
(begin
+272
(log-warn "Poller claim owner liveness undetermined"
+273
owner-pid: owner)
+274
'undetermined)))
+275
(else
+276
(take-claim-from! path owner me
+277
"previous holder is gone"))))))))))
+278
+279
;; Distinguish a FORCED win from an ordinary one. The caller has to wait
+280
;; for the displaced holder to notice before it starts polling, or there
+281
;; would be a window with two consumers on the token -- the original bug,
+282
;; arriving through the escape hatch. A stale-claim break needs no such
+283
;; wait, because its previous owner is already gone.
+284
(define (forced result)
+285
(if (eq? result 'acquired) 'acquired-by-force result))
286
−249
;; Owner is gone: remove the claim and try once to take it. Deliberately
−250
;; ONE retry -- a loop here would be the retry-against-a-dead-peer shape
−251
;; that caused the send storm.
−252
(define (break-stale-claim! path owner me)
−253
(log-info "Breaking stale Telegram poller claim (owner gone)"
−254
owner-pid: owner)
+287
;; Take the claim from `owner`. Used both for a stale claim (owner dead)
+288
;; and for a forced takeover (owner alive) -- the mechanics are identical
+289
;; and the difference is only whether we were entitled to, which the
+290
;; caller has already decided. Deliberately ONE attempt: a loop here
+291
;; would be the retry-against-a-peer shape that caused the send storm.
+292
(define (take-claim-from! path owner me reason)
+293
(log-info "Taking the Telegram poller claim" owner-pid: owner
+294
reason: reason)
295
;; Unlink ONLY while the claim still names the dead pid we just judged.
296
;; An unconditional delete here is a TOCTOU big enough to drive two
297
;; pollers through: deciding `owner` is dead involves a proc-comm port
@@ -275,6 +315,13 @@
315
(begin (set-car! *held-claim* path) 'acquired)
316
'held-by-other))))
317
+318
;;; Forget a claim without deleting it: another instance forced it away
+319
;;; from us and now owns the file. Deleting here would remove THEIR live
+320
;;; claim and leave nobody polling.
+321
(define (abandon-poller-claim!)
+322
(: -> void?)
+323
(set-car! *held-claim* #f))
+324
325
;;; Release the claim held by this process, if any. Safe to call when no
326
;;; claim is held. Only removes a claim still recording OUR pid, so a
327
;;; late shutdown cannot delete a claim another instance has since taken.
src/courier/config.sglmodified
@@ -18,6 +18,7 @@
18
courier-config-telegram-api-url
19
courier-config-telegram-send-disabled
20
courier-config-telegram-send-delay
+21
courier-config-telegram-poll-force
22
courier-config-allowed-senders
23
24
load-courier-config)
@@ -40,6 +41,18 @@
41
;; Test hook (COURIER_TELEGRAM_SEND_DELAY=<sec>): sleep before the
42
;; send (latency simulation). 0 = inert (default).
43
(telegram-send-delay default: 0)
+44
;; COURIER_TELEGRAM_POLL_FORCE=1: this session takes the single-poller
+45
;; claim even from a LIVE holder, instead of deferring to it. Default
+46
;; off, so it changes nothing unless deliberately reached for.
+47
;;
+48
;; It exists because the claim decides which session receives David's
+49
;; INBOUND Telegram messages, not merely which one avoids 409s. The
+50
;; periodic re-acquire recovers from a DEAD holder; it can do nothing
+51
;; about a live-but-abandoned session, whose claim is perfectly valid
+52
;; while his "task: ..." messages land where nobody is watching. This
+53
;; makes the answer to that one env var and an /mcp, rather than
+54
;; hunting processes by hand.
+55
(telegram-poll-force default: #f)
56
(allowed-senders default: '()))
57
58
;; ============================================================
@@ -65,6 +78,8 @@
78
(and v (not (string=? v "")) (not (string=? v "0")))))
79
(send-delay (let ((v (getenv "COURIER_TELEGRAM_SEND_DELAY")))
80
(or (and v (string->number v)) 0)))
+81
(poll-force (let ((v (getenv "COURIER_TELEGRAM_POLL_FORCE")))
+82
(and v (not (string=? v "")) (not (string=? v "0")))))
83
(yaml (load-config-file)))
84
(courier-config
85
telegram-token: token
@@ -72,6 +87,7 @@
87
telegram-api-url: api-url
88
telegram-send-disabled: send-disabled
89
telegram-send-delay: send-delay
+90
telegram-poll-force: poll-force
91
allowed-senders: (parse-allowed-senders yaml))))
92
93
;; ============================================================
src/courier/poller.sglmodified
@@ -95,6 +95,20 @@
95
;; case of another instance holding it.
96
(define *claim-recheck-interval* 60)
97
+98
;; How often the INSTANCE THAT IS POLLING re-confirms it still owns the
+99
;; claim. Holding it once is not the same as holding it now:
+100
;; COURIER_TELEGRAM_POLL_FORCE lets another session take it, and a
+101
;; displaced holder that kept polling would put two consumers back on the
+102
;; token -- the original bug, arriving through the escape hatch. Faster
+103
;; than the re-check above because the cost of being wrong is a live 409
+104
;; storm, not a delayed takeover.
+105
(define *claim-ownership-check-interval* 10)
+106
+107
;; How long a FORCED takeover waits before it starts polling, so the
+108
;; instance it displaced has seen the change and killed its child first.
+109
;; Strictly greater than the check interval above.
+110
(define *claim-standdown-wait* 14)
+111
112
;; Supervisor: wedge detection and restart backoff
113
(define *poller-stale-threshold* 30) ;; seconds of child silence
114
(define *watchdog-interval* 10) ;; seconds between checks
@@ -412,10 +426,19 @@
426
;; so relying on the child alone can leak a blocked orphan.
427
(define *active-poller* (list #f))
428
+429
;; Set while this instance is polling; cleared when it stops.
+430
(define *polling* (list #f))
+431
+432
;; Set when an ownership check finds the claim is no longer ours, so the
+433
;; supervise loop stops respawning instead of treating the killed child
+434
;; as a wedge to recover from.
+435
(define *claim-lost* (list #f))
+436
437
;;; Kill the active poller child, if any. Called by the leader
438
;;; when the MCP session ends.
439
(define (telegram-poller-shutdown!)
440
(: -> void?)
+441
(set-car! *polling* #f)
442
(let ((st (car *active-poller*)))
443
(when st
444
(set-poller-state-running?! st #f)
@@ -423,6 +446,8 @@
446
;; Give up the single-poller claim so the next session can poll
447
;; without waiting for a staleness check. Called unconditionally: an
448
;; instance that claimed and then failed to spawn still owns the claim.
+449
;; release-poller-claim! re-checks ownership before unlinking, so this
+450
;; cannot delete a claim another instance has since forced away.
451
(release-poller-claim!))
452
453
;; ============================================================
@@ -567,14 +592,28 @@
592
(: mcp-server? courier-config? -> void?)
593
(channel-wait-initialized! server)
594
(let ((token (courier-config-telegram-token config)))
−570
(let loop ((announced #f))
−571
(let ((result (acquire-poller-claim! token)))
+595
;; force? applies to the FIRST attempt only. Two forcing instances
+596
;; would otherwise steal the claim from each other every re-check,
+597
;; forever. "This session takes the claim on startup" is the whole
+598
;; intent of the flag.
+599
(let loop ((announced #f)
+600
(force? (courier-config-telegram-poll-force config)))
+601
(let ((result (acquire-poller-claim! token force?)))
602
(case result
−573
((acquired)
+603
((acquired acquired-by-force)
+604
(when (eq? result 'acquired-by-force)
+605
;; The displaced holder checks its ownership every
+606
;; *claim-ownership-check-interval*; give it more than one
+607
;; full interval to see the change and kill its child. Two
+608
;; pollers for ten seconds is a small 409 storm, and the
+609
;; whole point of this module is that there is never one.
+610
(log-info "Waiting for the displaced poller to stand down"
+611
seconds: *claim-standdown-wait*)
+612
(sleep *claim-standdown-wait*))
613
(when announced
614
(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))
+615
;; Returns only if another instance forces the claim away.
+616
(poller-supervise-loop server token))
617
(else
618
(unless (eq? announced result)
619
(if (eq? result 'held-by-other)
@@ -583,10 +622,35 @@
622
(log-warn "Telegram polling skipped -- could not establish poller claim ownership"
623
recheck-seconds: *claim-recheck-interval*)))
624
(sleep *claim-recheck-interval*)
−586
(loop result)))))))
−587
−588
(define (poller-supervise-loop server)
+625
(loop result #f)))))))
+626
+627
;; Watch the claim while we poll. If it is no longer ours, stop: another
+628
;; session forced it and is polling now, so continuing would put two
+629
;; consumers on the token. Killing the child unblocks poller-event-loop,
+630
;; and *claim-lost* tells the supervise loop this was a handover rather
+631
;; than a wedge to recover from.
+632
(define (poller-claim-guard token)
+633
(let loop ()
+634
(sleep *claim-ownership-check-interval*)
+635
(when (car *polling*)
+636
(if (poller-claim-held? token)
+637
(loop)
+638
(begin
+639
(set-car! *claim-lost* #t)
+640
;; Do NOT release: the file now names the instance that took
+641
;; it, and unlinking would leave nobody polling at all.
+642
(abandon-poller-claim!)
+643
(log-info "Telegram poller claim taken by another instance -- stopping this poller")
+644
(let ((st (car *active-poller*)))
+645
(when st
+646
(set-poller-state-running?! st #f)
+647
(poller-kill! st))))))))
+648
+649
(define (poller-supervise-loop server token)
650
(log-info "Telegram polling started (isolated poller process)")
+651
(set-car! *claim-lost* #f)
+652
(set-car! *polling* #t)
+653
(go (poller-claim-guard token))
654
(let respawn ((delay *restart-delay-initial*))
655
(let ((started (current-second))
656
(proc (spawn-poller-child!)))
@@ -605,10 +669,17 @@
669
(poller-kill! st)
670
(guard (e (else #f))
671
(process-wait proc))
−608
(let* ((uptime (- (current-second) started))
−609
(next (poller-next-restart-delay delay uptime)))
−610
(log-warn "Telegram poller exited -- restarting"
−611
uptime: (format-elapsed uptime)
−612
delay: (format-elapsed next))
−613
(sleep next)
−614
(respawn next)))))))))
+672
(if (car *claim-lost*)
+673
;; Handover, not a failure: fall back to being a normal
+674
;; non-polling instance, which will re-check on the usual
+675
;; interval in case the new holder goes away.
+676
(begin
+677
(set-car! *polling* #f)
+678
(log-info "Telegram polling stopped -- another instance holds the claim now"))
+679
(let* ((uptime (- (current-second) started))
+680
(next (poller-next-restart-delay delay uptime)))
+681
(log-warn "Telegram poller exited -- restarting"
+682
uptime: (format-elapsed uptime)
+683
delay: (format-elapsed next))
+684
(sleep next)
+685
(respawn next))))))))))
test/test-claim.sglmodified
@@ -21,6 +21,12 @@
21
(courier dedup)
22
(courier claim))
23
+24
;; Every existing test predates the force? argument and means "the normal,
+25
;; unforced path", so bind that spelling once rather than threading #f through
+26
;; twenty call sites.
+27
(define (acquire-poller-claim/1 token)
+28
(acquire-poller-claim! token #f))
+29
30
(define (fresh-dir)
31
(let ((d (make-temp-directory)))
32
(setenv! "COURIER_RELAY_DIR" (path-join d "relays"))
@@ -120,19 +126,19 @@
126
(test-group "acquire-poller-claim!"
127
(test "a first caller acquires"
128
(fresh-dir)
−123
(assert-equal 'acquired (acquire-poller-claim! "tok-a"))
+129
(assert-equal 'acquired (acquire-poller-claim/1 "tok-a"))
130
(release-poller-claim!))
131
132
(test "records OUR pid in the claim"
133
(fresh-dir)
−128
(acquire-poller-claim! "tok-b")
+134
(acquire-poller-claim/1 "tok-b")
135
(assert-equal (number->string (process-id))
136
(read-claim-owner (poller-claim-path "tok-b")))
137
(release-poller-claim!))
138
139
(test "release removes the claim"
140
(fresh-dir)
−135
(acquire-poller-claim! "tok-c")
+141
(acquire-poller-claim/1 "tok-c")
142
(release-poller-claim!)
143
(assert-equal #f (read-claim-owner (poller-claim-path "tok-c"))))
144
@@ -150,7 +156,7 @@
156
(ensure-directory (path-dirname path))
157
(make-symlink "999999" path)
158
(assert-equal "999999" (read-claim-owner path))
−153
(assert-equal 'acquired (acquire-poller-claim! "tok-stale"))
+159
(assert-equal 'acquired (acquire-poller-claim/1 "tok-stale"))
160
(assert-equal (number->string (process-id)) (read-claim-owner path))
161
(release-poller-claim!))))
162
@@ -166,14 +172,14 @@
172
(let ((path (poller-claim-path "tok-recycled")))
173
(ensure-directory (path-dirname path))
174
(make-symlink "1" path) ;; pid 1 is alive and is definitely not us
−169
(assert-equal 'acquired (acquire-poller-claim! "tok-recycled"))
+175
(assert-equal 'acquired (acquire-poller-claim/1 "tok-recycled"))
176
(assert-equal (number->string (process-id)) (read-claim-owner path))
177
(release-poller-claim!))))
178
179
(test "does not release a claim that now belongs to someone else"
180
(let ((d (fresh-dir)))
181
(let ((path (poller-claim-path "tok-steal")))
−176
(acquire-poller-claim! "tok-steal")
+182
(acquire-poller-claim/1 "tok-steal")
183
;; Someone else takes over (as would happen if we were SIGKILLed and
184
;; a later instance broke our stale claim).
185
(delete-file path)
@@ -193,3 +199,68 @@
199
(test "is deterministic and differs per input"
200
(assert-equal (fnv1a-hex "abc") (fnv1a-hex "abc"))
201
(assert-true (not (string=? (fnv1a-hex "abc") (fnv1a-hex "abd"))))))
+202
+203
;; ============================================================
+204
;; COURIER_TELEGRAM_POLL_FORCE -- the escape hatch
+205
;; ============================================================
+206
;;
+207
;; The claim decides which session receives inbound Telegram messages, not
+208
;; merely which one avoids 409s. The periodic re-acquire recovers from a DEAD
+209
;; holder; it can do nothing about a live-but-abandoned session whose claim is
+210
;; perfectly valid. Forcing is how a specific session says "me, now".
+211
+212
(test-group "forced takeover"
+213
;; SCOPE, stated plainly because the obvious test here is vacuous: the
+214
;; force branch is only reached when the owner reads as 'alive, and 'alive
+215
;; requires a live pid whose comm matches OURS -- i.e. a second courier,
+216
;; which does not exist inside a unit test. A claim owned by pid 1 looks
+217
;; like a recycled pid and takes the ORDINARY stale path whether or not
+218
;; force? is set, so asserting "forcing works" against it would pass
+219
;; without ever executing the force branch. (It did, on the first draft.)
+220
;;
+221
;; The live-owner takeover is therefore proved end-to-end in
+222
;; repro/repro-single-poller.sh case 9, with two real couriers. What is
+223
;; worth pinning HERE is that force? does not change any other outcome.
+224
(test "force? does not change the stale-claim path (still plain 'acquired)"
+225
(let ((d (fresh-dir)))
+226
(let ((path (poller-claim-path "tok-forced-stale")))
+227
(ensure-directory (path-dirname path))
+228
(make-symlink "999999" path)
+229
(assert-equal 'acquired (acquire-poller-claim! "tok-forced-stale" #t))
+230
(assert-equal (number->string (process-id)) (read-claim-owner path))
+231
(release-poller-claim!))))
+232
+233
(test "force? does not change an uncontended acquire"
+234
(let ((d (fresh-dir)))
+235
(assert-equal 'acquired (acquire-poller-claim! "tok-forced-free" #t))
+236
(release-poller-claim!)))
+237
+238
;; The displaced holder must be able to SEE that it lost the claim --
+239
;; without this it would keep polling and there would be two consumers on
+240
;; the token again, which is the original bug arriving through the hatch.
+241
(test "poller-claim-held? reports true only while we own the claim"
+242
(let ((d (fresh-dir)))
+243
(assert-equal 'acquired (acquire-poller-claim/1 "tok-held"))
+244
(assert-true (poller-claim-held? "tok-held"))
+245
;; Someone forces it away.
+246
(let ((path (poller-claim-path "tok-held")))
+247
(delete-file path)
+248
(make-symlink "1" path))
+249
(assert-true (not (poller-claim-held? "tok-held")))
+250
(abandon-poller-claim!)))
+251
+252
;; Abandoning must not delete the new owner's claim, or the forced takeover
+253
;; would leave NOBODY polling.
+254
(test "abandoning leaves the new owner's claim intact"
+255
(let ((d (fresh-dir)))
+256
(acquire-poller-claim/1 "tok-abandon")
+257
(let ((path (poller-claim-path "tok-abandon")))
+258
(delete-file path)
+259
(make-symlink "1" path)
+260
(abandon-poller-claim!)
+261
(release-poller-claim!) ;; must be a no-op now
+262
(assert-equal "1" (read-claim-owner path)))))
+263
+264
(test "poller-claim-held? is false when there is no claim at all"
+265
(let ((d (fresh-dir)))
+266
(assert-true (not (poller-claim-held? "tok-absent"))))))