mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-17 15:17:40 +02:00
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C9zwUaHucUgrdxvK8FvjYT
205 lines
8.0 KiB
Python
205 lines
8.0 KiB
Python
"""Kill app-runtime processes left behind by a previous OpenSwarm that died badly.
|
|
|
|
`stop_all()` reaps runtimes on a CLEAN shutdown. A crash, a SIGKILL, or a force-quit skips it, and
|
|
every `bash run.sh` plus its vite/uvicorn descendants reparents to PID 1 and keeps running: measured
|
|
on a dev machine, ghosts had been alive for **2 days 19 hours**, still holding their ports. The only
|
|
existing handling reallocates around a ghost that squats a port, so the ghost never dies at all and
|
|
they accumulate across sessions.
|
|
|
|
This runs at startup, before any runtime is spawned, which is the one moment when a workspace process
|
|
cannot legitimately belong to us: we have not started any yet.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import time
|
|
from typing import List
|
|
|
|
from typeguard import typechecked
|
|
|
|
from backend.apps.outputs.runtime_proc import kill_descendant_tree
|
|
from backend.config.paths import OUTPUTS_WORKSPACE_DIR as WORKSPACE_DIR
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Grace between TERM and KILL. Long enough for a run.sh EXIT trap to clean up its ports, short enough
|
|
# that boot does not visibly stall on it.
|
|
REAP_GRACE_SECONDS = float(os.environ.get("OSW_REAP_GRACE_SECONDS", "1.5"))
|
|
|
|
|
|
# The two ways a backend is spawned: dev `uvicorn backend.main:app` and the packaged
|
|
# `python -m backend.serve` (argv deliberately free of "uvicorn" and "backend.main", see serve.py).
|
|
# The packaged shape was missing here for a day, so every packaged reap found no owner and did nothing.
|
|
BACKEND_ARGV_SHAPES = (("uvicorn", "backend.main"), ("-m", "backend.serve"))
|
|
|
|
|
|
@typechecked
|
|
def is_backend_argv(args: str) -> bool:
|
|
return any(all(token in args for token in shape) for shape in BACKEND_ARGV_SHAPES)
|
|
|
|
|
|
@typechecked
|
|
def p_live_backend_pids() -> set:
|
|
"""PIDs of every running backend. A workspace process descended from one of these is ALIVE and
|
|
owned, not a ghost; a first draft of this reaper matched on the workspace path alone and would
|
|
have killed 14 working app runtimes on a machine where the owning backend was up."""
|
|
try:
|
|
out = subprocess.run(["ps", "-eo", "pid=,args="], capture_output=True, text=True, timeout=8)
|
|
except Exception:
|
|
return set()
|
|
pids = set()
|
|
for line in (out.stdout or "").splitlines():
|
|
if not is_backend_argv(line):
|
|
continue
|
|
head = line.strip().split(None, 1)
|
|
if head and head[0].isdigit():
|
|
pids.add(int(head[0]))
|
|
return pids
|
|
|
|
|
|
@typechecked
|
|
def p_ppid_map() -> dict:
|
|
try:
|
|
out = subprocess.run(["ps", "-eo", "pid=,ppid="], capture_output=True, text=True, timeout=8)
|
|
except Exception:
|
|
return {}
|
|
m = {}
|
|
for line in (out.stdout or "").splitlines():
|
|
parts = line.split()
|
|
if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
|
|
m[int(parts[0])] = int(parts[1])
|
|
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=<workspace>`, 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 (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
|
|
# case-sensitive `in` check misses it and the ghost survives (found live on a packaged smoke).
|
|
needle = os.path.abspath(WORKSPACE_DIR).casefold()
|
|
try:
|
|
out = subprocess.run(["ps", "-eo", "pid=,args="], capture_output=True, text=True, timeout=8)
|
|
except Exception:
|
|
return []
|
|
mine = os.getpid()
|
|
owners = p_live_backend_pids()
|
|
parents = p_ppid_map()
|
|
# WE are a backend, so a scan that finds no live backend has failed, not found ghosts: an empty
|
|
# owner set turns every working app into a "ghost" and the sweep would kill them all mid-use.
|
|
# Boot relied on running before anything spawned; the 10-minute sweep gets no such alibi.
|
|
if not owners or not parents:
|
|
return []
|
|
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 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
|
|
for _ in range(24):
|
|
if cur in owners or cur == mine:
|
|
owned = True
|
|
break
|
|
if cur <= 1:
|
|
break
|
|
nxt = parents.get(cur)
|
|
if nxt is None:
|
|
# The pid list and the ppid map are two separate ps snapshots; a process spawned
|
|
# between them has no entry here. Indeterminate is NOT ghost: skip, never kill.
|
|
broken = True
|
|
break
|
|
cur = nxt
|
|
if not owned and not broken:
|
|
ghosts.append(pid)
|
|
return ghosts
|
|
|
|
|
|
@typechecked
|
|
def reap_ghost_runtimes() -> int:
|
|
"""Reap them, leaves-first. Returns how many top-level processes were signalled.
|
|
|
|
Fire-and-forget by design: a machine where `ps` is restricted or a PID that vanishes between the
|
|
scan and the kill must never stop the backend from booting.
|
|
"""
|
|
pids = find_ghost_runtime_pids()
|
|
if not pids:
|
|
return 0
|
|
logger.warning(
|
|
"reaping %d ghost app-runtime process(es) left by a previous session: %s",
|
|
len(pids), pids[:12],
|
|
)
|
|
killed = 0
|
|
for pid in pids:
|
|
try:
|
|
# THAW FIRST. Idle app runtimes are frozen with SIGSTOP, and a stopped process never
|
|
# handles SIGTERM: it just queues it and stays alive forever. Measured live, a frozen
|
|
# ghost that had survived every reap for 2 days 21 hours.
|
|
kill_descendant_tree(pid, "CONT")
|
|
os.kill(pid, signal.SIGCONT)
|
|
except (ProcessLookupError, PermissionError, OSError):
|
|
pass
|
|
try:
|
|
kill_descendant_tree(pid, "TERM")
|
|
os.kill(pid, signal.SIGTERM)
|
|
killed += 1
|
|
except (ProcessLookupError, PermissionError, OSError):
|
|
continue
|
|
# A ghost's run.sh traps EXIT but not TERM, so give the tree a moment, then take out whatever
|
|
# ignored us. A ghost has no work worth protecting, so escalation is always the right call.
|
|
time.sleep(REAP_GRACE_SECONDS)
|
|
for pid in pids:
|
|
try:
|
|
os.kill(pid, 0)
|
|
except OSError:
|
|
continue
|
|
try:
|
|
kill_descendant_tree(pid, "KILL")
|
|
os.kill(pid, signal.SIGKILL)
|
|
logger.warning("ghost %d ignored TERM; escalated to KILL", pid)
|
|
except (ProcessLookupError, PermissionError, OSError):
|
|
continue
|
|
return killed
|