Identify a claim owner by (pid, starttime), not by process name
MEASURED ON THE LIVE HOST, and it defeats the single-poller claim in production while passing every test I had.
claude-ops copies each courier to /tmp/mcp-bins/courier-<launcher-pid> and runs it from there, so /proc/<pid>/comm is "courier-21570" -- it differs PER INSTANCE by construction. The liveness check compared the claim owner's comm against our own and read a mismatch as a recycled pid, i.e. dead. So a second leader session judged the first one's LIVE claim stale, broke it, and started a second poller: the exact failure this module exists to prevent, arriving through the repair path.
The harness could not see it. It ran both instances from one binary path, so their comms matched. Production is the one shape it did not reproduce, and it is the only shape that matters here.
The fix removes the name comparison rather than teaching the harness to match production. (pid, starttime) is the kernel's own process identity: starttime is field 22 of /proc/<pid>/stat, fixed for the life of a process, and it does not care what the binary is called or where it lives. The claim target becomes "<pid>:<starttime>". Parsed after the last ")" because field 2 is the command name in parentheses and can itself contain spaces.
A claim written by an older build records only a pid and cannot be discriminated from a recycled one. Those read ALIVE, deliberately: reading them dead would mean the first instance running this build breaks the live holder's claim and starts a second poller -- the bug, triggered by the upgrade itself. The cost is that a legacy claim whose pid is recycled is not breakable; it self-heals on the holder's next clean shutdown.
Log pruning keeps using the weaker pid-only check, since a log filename carries no start time. A recycled pid there costs disk and nothing else, and it errs toward keeping a live instance's log, which is the point of it.
Also: a clean first acquisition used to log NOTHING, so "is the new build running?" was unanswerable from the log. During today's deploy both the leader and I read the absence of claim lines as evidence of an old binary and both were wrong -- an absence is not a signal. It now logs the claim path and owner at info, once per session.
Verification, all behavioural:
- repro/two_paths_probe.py reproduces the production shape with symlinks (a copied dev bundle cannot find its library dir; a symlink still resolves /proc/self/exe while yielding the symlink's basename as comm -- both verified). Against the merged build: 2 pollers, 3 conflicts. Against this build: 1 poller, 0 conflicts.
- Folded in as harness case 10, with a setup control asserting the two names really are distinct, or the case silently degenerates into a copy of case 1.
- Note the end-state poller count ALONE would have passed the broken build: the loser stands down within 10s, so only the mock's max_inflight and conflict counters show the overlap. Asserting on the mock is what caught it.
Suite 120/0. Harness 59/0 with the pre-fix control still red.
repro/single_poller_harness.py | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
repro/two_paths_probe.py | 169 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/courier/claim.sgl | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
src/courier/main.sgl | 6 ++++++
src/courier/poller.sgl | 9 +++++++++
test/test-claim.sgl | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
6 files changed, 401 insertions(+), 72 deletions(-)repro/single_poller_harness.pymodified
pointed at the local mock, which is the only endpoint the send AND poll pathsuse once that variable is set.The cases are NUMBERED BY TOPIC, and run in the order 1, 3, 4, 7, 9, 8, 5, 6, 2 --The cases are NUMBERED BY TOPIC, and run in the order 1, 3, 4, 7, 10, 9, 8, 5, 6, 2 --the ordering is dictated by shared state (cases 1, 3, 4 and 7 all operate on thesame pair of live instances, and the pre-fix control in case 2 runs last so its409s can never be confused with the fixed binary's). What each proves: 8 LOG PRUNING instance logs are capped, a LIVE instance's log is never pruned however old it looks, and nothing that is not an instance log is touched. 10 PRODUCTION SHAPE two instances launched from DIFFERENT binary paths, as claude-ops actually does, still yield one poller. Every other case runs both from one path and so cannot see a name-dependent identity bug. 9 FORCED TAKEOVER COURIER_TELEGRAM_POLL_FORCE takes the claim from a LIVE holder; the displaced instance stops polling, stays healthy, and does not delete the new owner'sdef claim_owner(state_dir): """Raw claim target: "<pid>:<starttime>" (or a bare pid on older builds).""" files = claim_files(state_dir) if not files: return None return os.readlink(os.path.join(state_dir, files[0]))def claim_owner_pid(state_dir): """Just the pid. The claim records pid:starttime -- start time is what makes a recycled pid detectable without depending on the process NAME, which differs per instance in production.""" o = claim_owner(state_dir) return o.split(":")[0] if o else Nonedef wait_for(predicate, timeout=30, interval=0.25): deadline = time.time() + timeout while time.time() < deadline: owner = claim_owner(state_dir) check("claim file records the polling instance's pid", owner == str(a.pid), f"claim owner={owner} A={a.pid} B={b.pid}") claim_owner_pid(state_dir) == str(a.pid), f"claim owner={owner} A={a.pid} B={b.pid}") log = b.log_text() skip_lines = [l for l in log.splitlines() if "poller claim" in l] time.sleep(1) owner = claim_owner(state_dir) check("claim SURVIVES the SIGKILL (nothing released it)", owner == str(dead_pid), claim_owner_pid(state_dir) == str(dead_pid), f"owner={owner} killed={dead_pid}") # (a) The instance that was ALREADY RUNNING and had been told to skip must took_over, f"B children={poller_children(b.pid)} " f"claim owner={claim_owner(state_dir)}") check("the claim now records the instance that took over", claim_owner(state_dir) == str(b.pid), claim_owner_pid(state_dir) == str(b.pid), f"owner={claim_owner(state_dir)} B={b.pid}") log = b.log_text() check("the takeover is logged, once, at INFO", check("a fresh instance breaks the stale claim and polls", got, f"children={poller_children(c.pid)}") check("claim now records the new instance", claim_owner(state_dir) == str(c.pid), claim_owner_pid(state_dir) == str(c.pid), f"owner={claim_owner(state_dir)} C={c.pid}") log = c.log_text() check("the break is logged", check("the forcing instance takes over and polls", took, f"B children={poller_children(b.pid)} owner={claim_owner(d)}") check("the claim records the forcing instance", claim_owner(d) == str(b.pid), claim_owner_pid(d) == str(b.pid), f"owner={claim_owner(d)} B={b.pid}") stood_down = wait_for(lambda: len(poller_children(a.pid)) == 0, timeout=45) # And the displaced instance must NOT have deleted the new owner's claim. check("the displaced holder left the new owner's claim intact", claim_owner(d) == str(b.pid), f"owner={claim_owner(d)}") claim_owner_pid(d) == str(b.pid), f"owner={claim_owner(d)}") for inst in (a, b): inst.close_stdin()# ------------------------------------------------------------------ main ----def case_production_launch_shape(binp, run_dir): """The claim must survive claude-ops' actual launch shape. claude-ops copies each courier to /tmp/mcp-bins/courier-<launcher-pid>, so every instance has a DIFFERENT /proc/<pid>/comm. Every other case in this file runs both instances from ONE binary path, so their names match -- which is exactly why none of them could see the defect measured on the live host on 2026-08-04, where a name-based identity check made each live instance look like a recycled pid to the other. Distinct names come from symlinks: a copied dev bundle cannot find its own library dir, while a symlink still resolves /proc/self/exe back to the real bundle and still yields the symlink's basename as comm. Both verified. """ print("=== 10 PRODUCTION LAUNCH SHAPE (per-instance binary names) ===") d = os.path.join(run_dir, "twopath") os.makedirs(os.path.join(d, "relays"), exist_ok=True) post("/_seed", {"updates": []}) post("/_dwell", {"seconds": DWELL}) a_path = os.path.join(d, "courier-1111") b_path = os.path.join(d, "courier-2222") for p in (a_path, b_path): if not os.path.exists(p): os.symlink(os.path.abspath(binp), p) a = Courier(a_path, d, "twopath-a") a.initialize() wait_for(lambda: len(poller_children(a.pid)) == 1, timeout=40) b = Courier(b_path, d, "twopath-b") b.initialize() time.sleep(OBSERVE_WINDOW) ca = open(f"/proc/{a.pid}/comm").read().strip() cb = open(f"/proc/{b.pid}/comm").read().strip() # Setup control: if the names are NOT distinct this case degenerates into # a duplicate of case 1 and proves nothing about the production shape. check("the two instances really do have different process names", ca != cb, f"A={ca!r} B={cb!r}") pa, pb = poller_children(a.pid), poller_children(b.pid) check("exactly one poller despite per-instance binary names", len(pa) + len(pb) == 1, f"A={pa} B={pb}") s = stats() check("no 409s under the production launch shape", s["conflicts"] == 0, f"conflicts={s['conflicts']} getupdates={s['getupdates']}") # The end-state poller count alone would have PASSED the broken build -- # the loser stands down within 10s, so only the mock shows the overlap. check("the mock never saw two concurrent pollers", s["max_inflight"] <= 1, f"max_inflight={s['max_inflight']}") check("the claim records a pid:starttime identity, not a bare pid", (claim_owner(d) or "").count(":") == 1, f"owner={claim_owner(d)!r}") for inst in (a, b): inst.close_stdin() time.sleep(1) for inst in (a, b): inst.kill()def main(): ap = argparse.ArgumentParser() ap.add_argument("--bin", required=True) inst.kill() instances = [] case_production_launch_shape(args.bin, run_dir) case_forced_takeover(args.bin, run_dir) case_log_pruning(args.bin, run_dir)repro/two_paths_probe.pyadded
#!/usr/bin/env python3"""Does the single-poller claim survive the PRODUCTION launch shape?claude-ops copies each courier to /tmp/mcp-bins/courier-<launcher-pid> and runsit from there, so every instance has a DIFFERENT /proc/<pid>/comm --"courier-21570", not "courier". Any liveness check that compares the claimowner's comm against our own therefore sees every other instance as a recycledpid, judges its live claim stale, breaks it, and starts a second poller.repro/single_poller_harness.py cannot see this: it runs both instances from onebinary path, so their comms match. That is the whole point of this probe --production is the one shape the harness does not reproduce.Distinct names come from SYMLINKS rather than copies: a copied dev bundlecannot find its own library directory ("library not found: (courier main)"),while a symlink still resolves /proc/self/exe back to the real bundle ANDstill yields the symlink's basename as comm. Verified both.Usage: two_paths_probe.py --bin <courier>Exit 0 = exactly one poller (claim holds). Exit 1 = defect present."""import argparseimport jsonimport osimport shutilimport subprocessimport sysimport threadingimport timeimport urllib.requestPORT = int(os.environ.get("PORT", "8643"))BASE = f"http://127.0.0.1:{PORT}"RUN = "/tmp/courier-two-paths"def stats(): with urllib.request.urlopen(BASE + "/_stats", timeout=10) as r: return json.load(r)def post(path, obj): data = json.dumps(obj).encode() req = urllib.request.Request(BASE + path, data=data, headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=10) as r: return json.load(r)def start(binp, name): env = dict(os.environ) env.update({ "COURIER_TELEGRAM_TOKEN": "TESTTOKEN-two-paths", "COURIER_TELEGRAM_CHAT_ID": "1", "COURIER_TELEGRAM_API_URL": BASE, "COURIER_RELAY_DIR": os.path.join(RUN, "relays"), }) env.pop("COURIER_DISABLE_TELEGRAM_SEND", None) env.pop("COURIER_TELEGRAM_POLL_FORCE", None) p = subprocess.Popen([binp, "serve"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=open(os.path.join(RUN, name + ".err"), "wb"), env=env, bufsize=0) p.stdin.write((json.dumps({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "two-paths", "version": "0"}}}) + "\n").encode()) p.stdin.flush() p.stdout.readline() p.stdin.write((json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}) + "\n").encode()) p.stdin.flush() threading.Thread(target=lambda: [None for _ in p.stdout], daemon=True).start() return pdef poller_children(pid): out = [] for e in os.listdir("/proc"): if not e.isdigit(): continue try: ppid = None for line in open(f"/proc/{e}/status"): if line.startswith("PPid:"): ppid = int(line.split()[1]) break if ppid != pid: continue cmd = open(f"/proc/{e}/cmdline", "rb").read().decode("utf8", "replace") except (FileNotFoundError, ProcessLookupError, PermissionError): continue if "--telegram-poller" in cmd: out.append(int(e)) return outdef main(): ap = argparse.ArgumentParser() ap.add_argument("--bin", required=True) args = ap.parse_args() binp = os.path.abspath(args.bin) shutil.rmtree(RUN, ignore_errors=True) os.makedirs(os.path.join(RUN, "relays"), exist_ok=True) a_path = os.path.join(RUN, "courier-1111") b_path = os.path.join(RUN, "courier-2222") os.symlink(binp, a_path) os.symlink(binp, b_path) here = os.path.dirname(os.path.abspath(__file__)) mock = subprocess.Popen([sys.executable, os.path.join(here, "mock_telegram.py"), str(PORT), os.path.join(RUN, "mocklog")], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) for _ in range(50): try: stats() break except Exception: time.sleep(0.1) post("/_seed", {"updates": []}) post("/_dwell", {"seconds": 2.5}) a = b = None try: a = start(a_path, "a") time.sleep(10) b = start(b_path, "b") time.sleep(25) ca = open(f"/proc/{a.pid}/comm").read().strip() cb = open(f"/proc/{b.pid}/comm").read().strip() pa, pb = poller_children(a.pid), poller_children(b.pid) claims = [f for f in os.listdir(RUN) if f.endswith(".claim")] owner = os.readlink(os.path.join(RUN, claims[0])) if claims else None s = stats() print(f"comm A = {ca!r} comm B = {cb!r} (distinct: {ca != cb})") print(f"poller children: A={pa} B={pb}") print(f"claim owner = {owner!r} (A={a.pid}, B={b.pid})") print(f"mock: conflicts={s['conflicts']} max_inflight={s['max_inflight']} " f"getupdates={s['getupdates']}") print() # The probe is only meaningful if the two names really did differ and # polling really happened -- otherwise "one poller" passes for free. ok_setup = (ca != cb) and s["getupdates"] >= 3 if not ok_setup: print("PROBE INVALID: names not distinct, or no polling observed " f"(comms differ={ca != cb}, getupdates={s['getupdates']})") return 2 total = len(pa) + len(pb) if total == 1 and s["conflicts"] == 0 and s["max_inflight"] <= 1: print("PASS: exactly one poller under the production launch shape") return 0 print(f"FAIL: {total} pollers, {s['conflicts']} conflicts " f"-- the claim does not survive per-instance binary names") return 1 finally: for p in (a, b): if p: p.kill() mock.terminate()if __name__ == "__main__": sys.exit(main())src/courier/claim.sglmodified
claim-token-hash read-claim-owner pid-liveness pid-liveness/comm proc-comm) claim-identity identity-pid identity-start proc-start-time) (begin ;; After breaking a stale claim, two instances can both have decided the ;; Liveness ;; ============================================================ ;;; First line of /proc/<pid>/comm, or #f if it cannot be read. ;;; Kernel start time of a process (field 22 of /proc/<pid>/stat) as a ;;; string, or #f if it cannot be read. ;;; ;;; Parsed from AFTER the last ")" because field 2 is the command name in ;;; parentheses and may itself contain spaces and brackets -- splitting the ;;; whole line on whitespace picks the wrong field for any such process. ;;; ;;; MEASURED: procfs files report st_size 0, so `read-file-string` (which ;;; reads stat's size) returns "" for every one of them -- a confident, ;;; wrong empty answer. A PORT read is size-independent and returns the ;;; real content. (define (proc-comm pid-str) (define (proc-start-time pid-str) (: string? -> any?) (guard (e (else #f)) (let ((line (call-with-input-file (string-append "/proc/" pid-str "/comm") (string-append "/proc/" pid-str "/stat") (lambda (port) (read-line port))))) (and (string? line) (string-trim line))))) (and (string? line) (let ((close (string-index-right line (lambda (c) (char=? c #\)))))) (and close (let ((fields (string-split (string-trim (substring line (+ close 1) (string-length line))) " "))) ;; These fields start at overall field 3, so field 22 ;; (starttime) is index 19. (and (> (length fields) 19) (list-ref fields 19))))))))) ;;; This process's claim identity, "<pid>:<starttime>". (define (claim-identity) (: -> string?) (let* ((pid (number->string (process-id))) (start (proc-start-time pid))) (if start (string-append pid ":" start) pid))) ;; pid part of a recorded identity: "1234:567" -> "1234", "1234" -> "1234". (define (identity-pid id) (let ((i (string-index id (lambda (c) (char=? c #\:))))) (if i (substring id 0 i) id))) ;; starttime part, or #f for a pid-only claim written by an older build. (define (identity-start id) (let ((i (string-index id (lambda (c) (char=? c #\:))))) (and i (substring id (+ i 1) (string-length id))))) ;;; Is the process holding a claim still running? ;;; ;;; 'undetermined means the instrument could not answer, which is a ;;; different fact from "the process is gone" and must not be treated as ;;; permission to break the claim. (define (pid-liveness pid-str) ;;; Liveness of a recorded claim identity ("<pid>:<starttime>", or a bare ;;; pid from an older build). (define (pid-liveness id) (: string? -> symbol?) (pid-liveness/comm pid-str (proc-comm (number->string (process-id))))) ;;; pid-liveness with the expected command name passed in explicitly. ;;; ;;; Split out so the recycled-pid discriminator can be tested from both ;;; sides: a live process whose comm MATCHES must read 'alive, and the ;;; same live process with a non-matching comm must read 'dead. Testing ;;; only the second would be satisfied by an implementation that always ;;; answers 'dead -- which is the pre-fix behaviour wearing a lock's ;;; clothes, and would break every claim it ever saw. (define (pid-liveness/comm pid-str expected-comm) (: string? any? -> symbol?) (let ((self (number->string (process-id)))) (let ((pid-str (identity-pid id)) (self (number->string (process-id)))) (cond ;; Tier 1: /proc. POSITIVE CONTROL -- our own pid must be visible ;; through the instrument before its answer about another pid means (if (not (file-exists? (string-append "/proc/" pid-str))) 'dead ;; The pid exists -- but pids are recycled, and a recycled pid ;; would make a dead courier's claim look alive forever. Compare ;; the command name against OUR OWN (passed in, read at runtime ;; rather than hardcoded, so it cannot drift if the binary is ;; renamed). If either name is unreadable we cannot rule out a ;; live courier, so we say 'alive and leave the claim alone -- ;; the conservative direction. (let ((theirs (proc-comm pid-str))) ;; would make a dead courier's claim look alive forever. The ;; kernel's start time settles it: same pid AND same starttime ;; is the same process, full stop. This deliberately does NOT ;; compare the command name -- see the header; comm differs per ;; instance in production and comparing it made every live peer ;; look dead. (let ((recorded (identity-start id)) (actual (proc-start-time pid-str))) (cond ((or (not expected-comm) (not theirs)) 'alive) ((string=? expected-comm theirs) 'alive) ;; Older claim with no starttime, or an unreadable one: we ;; cannot rule out a live courier, so leave the claim alone. ((or (not recorded) (not actual)) 'alive) ((string=? recorded actual) 'alive) (else 'dead))))) ;; Tier 2: kill -0, for a host with no /proc. Same shape: prove the ;; instrument works on a pid known to be alive (ours) before (define (poller-claim-held? token) (: string? -> boolean?) (equal? (read-claim-owner (poller-claim-path token)) (number->string (process-id)))) (claim-identity))) ;;; Try to become the single poller for `token`. ;;; (define (acquire-poller-claim! token force?) (: string? any? -> symbol?) (let* ((path (poller-claim-path token)) (me (number->string (process-id)))) (me (claim-identity))) (guard (e (else (log-warn "Poller claim failed" error: (format "~a" e)) 'undetermined)) reason: reason) ;; Unlink ONLY while the claim still names the dead pid we just judged. ;; An unconditional delete here is a TOCTOU big enough to drive two ;; pollers through: deciding `owner` is dead involves a proc-comm port ;; pollers through: deciding `owner` is dead involves a procfs port ;; read, which yields the scheduler inside the supervisor goroutine, so ;; a second instance that reached the same verdict can have broken the ;; claim AND taken it in the meantime. Deleting blind would then remove (when path (set-car! *held-claim* #f) (guard (e (else #f)) (when (equal? (read-claim-owner path) (number->string (process-id))) (when (equal? (read-claim-owner path) (claim-identity)) (delete-file path) (log-info "Released Telegram poller claim"))))))))src/courier/main.sglmodified
;; destroying exactly the long-lived evidence per-instance logs exist to ;; preserve. A name with no pid in it is not ours to judge, so it is ;; treated as live and left alone. ;; NOTE: a log filename carries only a pid, with no start time, so this ;; asks the weaker question "is that pid running at all". A recycled pid ;; therefore keeps a dead instance's log alive forever. That costs disk ;; and nothing else, and it errs in the safe direction -- the failure this ;; guards against is deleting a LIVE instance's log, which is the evidence ;; per-instance logs exist to preserve. (define (instance-log-live? path) (let ((pid (instance-log-pid path))) (if (not pid)src/courier/poller.sglmodified
(poller-kill! st)))))))) (define (poller-supervise-loop server token) ;; Say so explicitly, including WHICH claim and WHO holds it. A clean ;; first acquisition used to log nothing at all, which made "is the new ;; build actually running?" unanswerable from the log -- during the ;; 2026-08-04 deploy both the worker and the leader read the absence of ;; claim lines as evidence of an OLD binary, and both were wrong. An ;; absence is not a signal; this is. One line per session. (log-info "Telegram poller claim held -- this instance is the poller" claim: (poller-claim-path token) owner: (claim-identity)) (log-info "Telegram polling started (isolated poller process)") (set-car! *claim-lost* #f) (set-car! *polling* #t)test/test-claim.sglmodified
(sigil path) (sigil process) (courier dedup) (courier claim)) (only (courier claim) acquire-poller-claim! release-poller-claim! poller-claim-held? abandon-poller-claim! poller-claim-path claim-token-hash read-claim-owner pid-liveness claim-identity identity-pid identity-start proc-start-time));; Every existing test predates the force? argument and means "the normal,;; unforced path", so bind that spelling once rather than threading #f through (test "reports garbage dead rather than raising" (assert-equal 'dead (pid-liveness "not-a-pid"))) (test "can read our own comm (the recycled-pid discriminator works)" (assert-true (string? (proc-comm (number->string (process-id))))) (assert-true (> (string-length (proc-comm (number->string (process-id)))) 0))) (test "can read our own start time (the recycled-pid discriminator works)" (assert-true (string? (proc-start-time (number->string (process-id))))) (assert-true (> (string-length (proc-start-time (number->string (process-id)))) 0))) (test "reads nothing for a pid that does not exist" (assert-equal #f (proc-comm "999999"))) ;; The recycled-pid discriminator, tested from BOTH sides against the same ;; live process (pid 1 always exists). Only testing the 'dead side would be ;; satisfied by an implementation that always answers 'dead, which would ;; break every claim it ever saw. (test "a live pid with a MATCHING comm is alive" (assert-equal 'alive (pid-liveness/comm "1" (proc-comm "1")))) (test "a live pid with a DIFFERENT comm is treated as dead (recycled pid)" (assert-equal 'dead (pid-liveness/comm "1" "not-the-same-comm"))) (assert-equal #f (proc-start-time "999999"))) ;; The recycled-pid discriminator, tested from BOTH sides against the SAME ;; live pid. Only testing the 'dead side would be satisfied by an ;; implementation that always answers 'dead -- which is what would break ;; every claim it ever saw. (test "a live pid with its REAL start time is alive" (assert-equal 'alive (pid-liveness (string-append "1:" (proc-start-time "1"))))) (test "a live pid with a DIFFERENT start time is dead (recycled pid)" (assert-equal 'dead (pid-liveness "1:99999999"))) ;; This is the production case the comm-based version got wrong: two live ;; couriers run from /tmp/mcp-bins/courier-<launcher-pid>, so their command ;; NAMES differ. Identity must not care. (test "liveness does not depend on the process name matching ours" (assert-equal 'alive (pid-liveness (string-append "1:" (proc-start-time "1")))) (assert-true (not (string=? (proc-start-time "1") (proc-start-time (number->string (process-id))))))) (test "a legacy pid-only claim reads alive, never dead, while the pid exists" (assert-equal 'alive (pid-liveness "1")) (assert-equal 'dead (pid-liveness "999999"))) (test "an unreadable comm falls back to 'alive, never to 'dead" (assert-equal 'alive (pid-liveness/comm "1" #f)))) (test "our own identity round-trips" (assert-equal (number->string (process-id)) (identity-pid (claim-identity))) (assert-equal 'alive (pid-liveness (claim-identity)))));; ============================================================;; Acquire / release (test "records OUR pid in the claim" (fresh-dir) (acquire-poller-claim/1 "tok-b") (assert-equal (number->string (process-id)) (assert-equal (claim-identity) (read-claim-owner (poller-claim-path "tok-b"))) (release-poller-claim!)) (make-symlink "999999" path) (assert-equal "999999" (read-claim-owner path)) (assert-equal 'acquired (acquire-poller-claim/1 "tok-stale")) (assert-equal (number->string (process-id)) (read-claim-owner path)) (assert-equal (claim-identity) (read-claim-owner path)) (release-poller-claim!)))) ;; A claim whose owner pid is ALIVE but is not a courier can only be a ;; recycled pid, so it is stale and must be broken -- otherwise one ;; unlucky pid reuse would silence Telegram until that unrelated process ;; exited. (The complementary case, a claim held by a genuinely live ;; courier being left strictly alone, needs a second courier process and ;; is proved end-to-end by repro/repro-single-poller.sh case 1, where ;; instance B neither steals nor rewrites instance A's claim.) (test "treats a live-but-unrelated owner pid as a recycled, stale claim" ;; A recycled pid is now detected by START TIME, not by name, so a claim ;; recording (pid, starttime) whose pid was reused reads dead and is broken. (test "breaks a claim whose pid was recycled (start time differs)" (let ((d (fresh-dir))) (let ((path (poller-claim-path "tok-recycled"))) (ensure-directory (path-dirname path)) (make-symlink "1" path) ;; pid 1 is alive and is definitely not us (make-symlink "1:99999999" path) ;; pid 1 is alive, but not THAT one (assert-equal 'acquired (acquire-poller-claim/1 "tok-recycled")) (assert-equal (number->string (process-id)) (read-claim-owner path)) (assert-equal (claim-identity) (read-claim-owner path)) (release-poller-claim!)))) ;; DELIBERATE SEMANTIC CHOICE, and the direction matters. A claim written by ;; an older build records only a pid, so it cannot be discriminated from a ;; recycled one. We treat it as ALIVE and leave it alone. Treating it as ;; dead would mean that the first instance running the new build breaks the ;; live holder's claim and starts a second poller -- which is precisely the ;; bug, triggered by the upgrade itself. The cost of this choice is that a ;; legacy claim whose pid gets recycled is not breakable; it self-heals on ;; the holder's next clean shutdown, which deletes the claim. (test "leaves a legacy pid-only claim alone while that pid is alive" (let ((d (fresh-dir))) (let ((path (poller-claim-path "tok-legacy"))) (ensure-directory (path-dirname path)) (make-symlink "1" path) (assert-equal 'held-by-other (acquire-poller-claim/1 "tok-legacy")) (assert-equal "1" (read-claim-owner path))))) (test "still breaks a legacy pid-only claim once that pid is gone" (let ((d (fresh-dir))) (let ((path (poller-claim-path "tok-legacy-dead"))) (ensure-directory (path-dirname path)) (make-symlink "999999" path) (assert-equal 'acquired (acquire-poller-claim/1 "tok-legacy-dead")) (assert-equal (claim-identity) (read-claim-owner path)) (release-poller-claim!)))) (test "does not release a claim that now belongs to someone else" (ensure-directory (path-dirname path)) (make-symlink "999999" path) (assert-equal 'acquired (acquire-poller-claim! "tok-forced-stale" #t)) (assert-equal (number->string (process-id)) (read-claim-owner path)) (assert-equal (claim-identity) (read-claim-owner path)) (release-poller-claim!)))) (test "force? does not change an uncontended acquire"