diff --git a/backend/apps/outputs/reap_ghost_runtimes.py b/backend/apps/outputs/reap_ghost_runtimes.py index 6391aa1f..b053875a 100644 --- a/backend/apps/outputs/reap_ghost_runtimes.py +++ b/backend/apps/outputs/reap_ghost_runtimes.py @@ -56,13 +56,41 @@ def p_ppid_map() -> dict: return m +@typechecked +def p_cwd_map(needle: str) -> dict: + """pid -> cwd, for processes whose working directory sits under the workspace. + + An app's backend is spawned as `python -u backend.py` with `cwd=`, so the workspace + path appears NOWHERE in its argv: an argv-only scan is structurally blind to exactly the ghost + we most want dead. lsof is the only way to read another process's cwd on macOS. Best-effort by + design, since a machine that restricts lsof must still boot. + """ + try: + out = subprocess.run( + ["lsof", "-a", "-d", "cwd", "-Fn"], capture_output=True, text=True, timeout=15 + ) + except Exception: + return {} + m = {} + pid = None + for raw in (out.stdout or "").splitlines(): + if not raw: + continue + tag, val = raw[0], raw[1:] + if tag == "p" and val.isdigit(): + pid = int(val) + elif tag == "n" and pid is not None and val.casefold().startswith(needle): + m[pid] = val + return m + + @typechecked def find_ghost_runtime_pids() -> List[int]: """PIDs of workspace processes that NO live backend owns. - Matched on the absolute workspace path, so an unrelated `npm run dev` elsewhere is never touched, - then filtered by walking each candidate's ancestry: if a live backend is anywhere above it, it is - someone's working app and is left alone. + Matched on the absolute workspace path (in argv OR as the process's working directory), so an + unrelated `npm run dev` elsewhere is never touched, then filtered by walking each candidate's + ancestry: if a live backend is anywhere above it, it is someone's working app and is left alone. """ # Case-FOLDED needle. macOS's default filesystem is case-insensitive, so a process may report # `.../openswarm/...` while our resolved path is `.../OpenSwarm/...`: the same folder, but a @@ -80,15 +108,17 @@ def find_ghost_runtime_pids() -> List[int]: # Boot relied on running before anything spawned; the 10-minute sweep gets no such alibi. if not owners or not parents: return [] - ghosts: List[int] = [] + by_cwd = p_cwd_map(needle) + candidates = dict.fromkeys(by_cwd) for line in (out.stdout or "").splitlines(): line = line.strip() if needle not in line.casefold(): continue head = line.split(None, 1) - if not head or not head[0].isdigit(): - continue - pid = int(head[0]) + if head and head[0].isdigit(): + candidates[int(head[0])] = None + ghosts: List[int] = [] + for pid in candidates: if pid == mine: continue cur, owned, broken = pid, False, False diff --git a/backend/tests/test_reap_ghost_runtimes.py b/backend/tests/test_reap_ghost_runtimes.py index 4def1293..f3f96f40 100644 --- a/backend/tests/test_reap_ghost_runtimes.py +++ b/backend/tests/test_reap_ghost_runtimes.py @@ -133,3 +133,31 @@ def test_ghost_matched_despite_path_case_difference(monkeypatch): stdout = f"50 python -m uvicorn backend.main:app\n700 bash {lower_ws}/ws-x/backend/run.sh\n" monkeypatch.setattr(rg.subprocess, "run", lambda *a, **k: P_Out()) assert rg.find_ghost_runtime_pids() == [700], "a case-different path must still match the ghost" + + +def test_an_orphan_is_found_by_its_CWD_when_argv_hides_the_path(monkeypatch): + """An app's backend runs as `python -u backend.py` with cwd=, so the workspace path is + nowhere in its argv. An argv-only scan was structurally blind to exactly the ghost we most want + dead; found live on a packaged build where orphaned app backends survived every reap.""" + from backend.apps.outputs import reap_ghost_runtimes as rg + ws = rg.os.path.abspath(rg.WORKSPACE_DIR) + monkeypatch.setattr(rg, "p_live_backend_pids", lambda: {50}) + monkeypatch.setattr(rg, "p_ppid_map", lambda: {900: 1}) + monkeypatch.setattr(rg, "p_cwd_map", lambda needle: {900: ws + "/app-7"}) + class P_Out: + stdout = "50 python -m uvicorn backend.main:app\n900 python3 -u backend.py\n" + monkeypatch.setattr(rg.subprocess, "run", lambda *a, **k: P_Out()) + assert rg.find_ghost_runtime_pids() == [900], "a cwd-only orphan must still be reaped" + + +def test_a_cwd_orphan_owned_by_a_live_backend_is_spared(monkeypatch): + """The cwd path must obey the same ancestry rule: a working app is not a ghost.""" + from backend.apps.outputs import reap_ghost_runtimes as rg + ws = rg.os.path.abspath(rg.WORKSPACE_DIR) + monkeypatch.setattr(rg, "p_live_backend_pids", lambda: {50}) + monkeypatch.setattr(rg, "p_ppid_map", lambda: {900: 50, 50: 1}) + monkeypatch.setattr(rg, "p_cwd_map", lambda needle: {900: ws + "/app-7"}) + class P_Out: + stdout = "50 python -m uvicorn backend.main:app\n900 python3 -u backend.py\n" + monkeypatch.setattr(rg.subprocess, "run", lambda *a, **k: P_Out()) + assert rg.find_ghost_runtime_pids() == [], "a live backend's own app runtime must never be killed"