|
9 | 9 |
|
10 | 10 | import subprocess |
11 | 11 | import sys |
| 12 | +import time |
12 | 13 |
|
13 | 14 | from conftest import FAKE_CLAUDE, reap_fixture_stub_strays |
14 | 15 |
|
15 | 16 | #: Generous: this only bounds a SIGKILL'd process's disappearance, never a happy path. |
16 | 17 | DEATH_TIMEOUT = 30 |
| 18 | +#: Generous too: bounds how long the stub takes to reach its own first line of Python. |
| 19 | +STARTUP_TIMEOUT = 30 |
17 | 20 |
|
18 | 21 |
|
19 | 22 | def _spawn_stub(tmp_path) -> subprocess.Popen: |
20 | | - """Start a `fake_claude` bridge stub that idles until signalled, as the runner does.""" |
21 | | - return subprocess.Popen( # noqa: S603 — fixture stub, absolute path, list argv |
| 23 | + """Start a `fake_claude` bridge stub that idles until signalled, as the runner does. |
| 24 | +
|
| 25 | + Returns only once the stub is RUNNING its script, not merely spawned. `Popen` returns |
| 26 | + after the first exec, which is `/usr/bin/env` from the shebang; env then execs |
| 27 | + `python3`, and while that second exec is in flight `/proc/<pid>/cmdline` reads empty. |
| 28 | + The reaper selects on argv, so a reap in that window misses the stub (seen on the |
| 29 | + musl CI leg, and about one read in five locally right after `Popen`). The stub writes |
| 30 | + `<debug-file>.argv.json` as its first act, which is proof the exec chain is done. |
| 31 | + """ |
| 32 | + debug_file = tmp_path / "bridge.log" |
| 33 | + proc = subprocess.Popen( # noqa: S603 — fixture stub, absolute path, list argv |
22 | 34 | [ |
23 | 35 | str(FAKE_CLAUDE), |
24 | 36 | "remote-control", |
25 | 37 | "--name", |
26 | 38 | "reaper-probe", |
27 | 39 | "--debug-file", |
28 | | - str(tmp_path / "bridge.log"), |
| 40 | + str(debug_file), |
29 | 41 | ] |
30 | 42 | ) |
| 43 | + ready = tmp_path / "bridge.log.argv.json" |
| 44 | + deadline = time.monotonic() + STARTUP_TIMEOUT |
| 45 | + while not ready.exists(): |
| 46 | + if proc.poll() is not None or time.monotonic() > deadline: # pragma: no cover |
| 47 | + proc.kill() |
| 48 | + proc.wait(timeout=DEATH_TIMEOUT) |
| 49 | + raise AssertionError(f"fake_claude stub never started (rc={proc.returncode})") |
| 50 | + time.sleep(0.01) |
| 51 | + return proc |
31 | 52 |
|
32 | 53 |
|
33 | 54 | def test_reaper_kills_an_abandoned_stub(tmp_path): |
|
0 commit comments