AtlatestRepositorycourier

courier / tree / reprotwo_paths_probe.py

1#!/usr/bin/env python3
2"""Does the single-poller claim survive the PRODUCTION launch shape?
3
4claude-ops copies each courier to /tmp/mcp-bins/courier-<launcher-pid> and runs
5it from there, so every instance has a DIFFERENT /proc/<pid>/comm --
6"courier-21570", not "courier". Any liveness check that compares the claim
7owner's comm against our own therefore sees every other instance as a recycled
8pid, judges its live claim stale, breaks it, and starts a second poller.
9
10repro/single_poller_harness.py cannot see this: it runs both instances from one
11binary path, so their comms match. That is the whole point of this probe --
12production is the one shape the harness does not reproduce.
14Distinct names come from SYMLINKS rather than copies: a copied dev bundle
15cannot find its own library directory ("library not found: (courier main)"),
16while a symlink still resolves /proc/self/exe back to the real bundle AND
17still yields the symlink's basename as comm. Verified both.
19Usage: two_paths_probe.py --bin <courier>
20Exit 0 = exactly one poller (claim holds). Exit 1 = defect present.
21"""
22import argparse
23import json
24import os
25import shutil
26import subprocess
27import sys
28import threading
29import time
30import urllib.request
32PORT = int(os.environ.get("PORT", "8643"))
33BASE = f"http://127.0.0.1:{PORT}"
34RUN = "/tmp/courier-two-paths"
37def stats():
38 with urllib.request.urlopen(BASE + "/_stats", timeout=10) as r:
39 return json.load(r)
42def post(path, obj):
43 data = json.dumps(obj).encode()
44 req = urllib.request.Request(BASE + path, data=data,
45 headers={"Content-Type": "application/json"})
46 with urllib.request.urlopen(req, timeout=10) as r:
47 return json.load(r)
50def start(binp, name):
51 env = dict(os.environ)
52 env.update({
53 "COURIER_TELEGRAM_TOKEN": "TESTTOKEN-two-paths",
54 "COURIER_TELEGRAM_CHAT_ID": "1",
55 "COURIER_TELEGRAM_API_URL": BASE,
56 "COURIER_RELAY_DIR": os.path.join(RUN, "relays"),
57 })
58 env.pop("COURIER_DISABLE_TELEGRAM_SEND", None)
59 env.pop("COURIER_TELEGRAM_POLL_FORCE", None)
60 p = subprocess.Popen([binp, "serve"], stdin=subprocess.PIPE,
61 stdout=subprocess.PIPE,
62 stderr=open(os.path.join(RUN, name + ".err"), "wb"),
63 env=env, bufsize=0)
64 p.stdin.write((json.dumps({
65 "jsonrpc": "2.0", "id": 1, "method": "initialize",
66 "params": {"protocolVersion": "2024-11-05", "capabilities": {},
67 "clientInfo": {"name": "two-paths", "version": "0"}}}) + "\n").encode())
68 p.stdin.flush()
69 p.stdout.readline()
70 p.stdin.write((json.dumps({"jsonrpc": "2.0",
71 "method": "notifications/initialized",
72 "params": {}}) + "\n").encode())
73 p.stdin.flush()
74 threading.Thread(target=lambda: [None for _ in p.stdout], daemon=True).start()
75 return p
78def poller_children(pid):
79 out = []
80 for e in os.listdir("/proc"):
81 if not e.isdigit():
82 continue
83 try:
84 ppid = None
85 for line in open(f"/proc/{e}/status"):
86 if line.startswith("PPid:"):
87 ppid = int(line.split()[1])
88 break
89 if ppid != pid:
90 continue
91 argv = open(f"/proc/{e}/cmdline", "rb").read().decode(
92 "utf8", "replace").rstrip("\0").split("\0")
93 exe = os.readlink(f"/proc/{e}/exe")
94 except (FileNotFoundError, ProcessLookupError, PermissionError, OSError):
95 continue
96 # Exact last-argv match AND a real courier executable: a substring
97 # search matches the searching process itself (the `pgrep -f`
98 # self-match hazard).
99 if argv and argv[-1] == "--telegram-poller" and \
100 os.path.basename(exe).startswith("courier"):
101 out.append(int(e))
102 return out
105def main():
106 ap = argparse.ArgumentParser()
107 ap.add_argument("--bin", required=True)
108 args = ap.parse_args()
109 binp = os.path.abspath(args.bin)
111 shutil.rmtree(RUN, ignore_errors=True)
112 os.makedirs(os.path.join(RUN, "relays"), exist_ok=True)
113 a_path = os.path.join(RUN, "courier-1111")
114 b_path = os.path.join(RUN, "courier-2222")
115 os.symlink(binp, a_path)
116 os.symlink(binp, b_path)
118 here = os.path.dirname(os.path.abspath(__file__))
119 mock = subprocess.Popen([sys.executable, os.path.join(here, "mock_telegram.py"),
120 str(PORT), os.path.join(RUN, "mocklog")],
121 stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
122 for _ in range(50):
123 try:
124 stats()
125 break
126 except Exception:
127 time.sleep(0.1)
128 post("/_seed", {"updates": []})
129 post("/_dwell", {"seconds": 2.5})
131 a = b = None
132 try:
133 a = start(a_path, "a")
134 time.sleep(10)
135 b = start(b_path, "b")
136 time.sleep(25)
138 ca = open(f"/proc/{a.pid}/comm").read().strip()
139 cb = open(f"/proc/{b.pid}/comm").read().strip()
140 pa, pb = poller_children(a.pid), poller_children(b.pid)
141 claims = [f for f in os.listdir(RUN) if f.endswith(".claim")]
142 owner = os.readlink(os.path.join(RUN, claims[0])) if claims else None
143 s = stats()
145 print(f"comm A = {ca!r} comm B = {cb!r} (distinct: {ca != cb})")
146 print(f"poller children: A={pa} B={pb}")
147 print(f"claim owner = {owner!r} (A={a.pid}, B={b.pid})")
148 print(f"mock: conflicts={s['conflicts']} max_inflight={s['max_inflight']} "
149 f"getupdates={s['getupdates']}")
150 print()
152 # The probe is only meaningful if the two names really did differ and
153 # polling really happened -- otherwise "one poller" passes for free.
154 ok_setup = (ca != cb) and s["getupdates"] >= 3
155 if not ok_setup:
156 print("PROBE INVALID: names not distinct, or no polling observed "
157 f"(comms differ={ca != cb}, getupdates={s['getupdates']})")
158 return 2
160 total = len(pa) + len(pb)
161 if total == 1 and s["conflicts"] == 0 and s["max_inflight"] <= 1:
162 print("PASS: exactly one poller under the production launch shape")
163 return 0
164 print(f"FAIL: {total} pollers, {s['conflicts']} conflicts "
165 f"-- the claim does not survive per-instance binary names")
166 return 1
167 finally:
168 for p in (a, b):
169 if p:
170 p.kill()
171 mock.terminate()
174if __name__ == "__main__":
175 sys.exit(main())