mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-30 05:24:50 +02:00
[eric] apps: parking an idle app freezes its whole process tree, not just the root run.sh (ENG-311)
This commit is contained in:
@@ -56,40 +56,75 @@ ERROR_PATTERNS = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def suspend_process_tree(proc) -> None:
|
||||
"""Send SIGSTOP to a workspace's subprocess so it consumes 0% CPU
|
||||
while sitting in the LRU idle pool. The signal is delivered to the
|
||||
PROCESS GROUP (negative PID) when the child is a session leader,
|
||||
so vite + uvicorn + their npm/python subchildren all pause together.
|
||||
def descendant_pids(root_pid: int) -> list:
|
||||
"""Every live descendant of root_pid, children before parents' siblings, via one ps pass.
|
||||
|
||||
No-op on Windows (SIGSTOP has no equivalent; the `OpenProcessToken` +
|
||||
`NtSuspendProcess` route works but isn't worth the win32 surface
|
||||
here; idle Windows runtimes just stay running, which is the current
|
||||
behavior). Failures here are swallowed; if the process already died
|
||||
a stop signal is meaningless."""
|
||||
The runtime tracks ONE pid, `bash run.sh`, but the real work lives in grandchildren (run.sh
|
||||
backgrounds `frontend/run.sh | awk` pipelines which spawn vite/uvicorn). Signalling only the
|
||||
root froze run.sh while vite kept burning CPU in the idle pool, which is ENG-311: the park was
|
||||
a no-op for the processes that matter. Children are walkable here because the frozen root stays
|
||||
alive; a root that DIED reparents them to launchd, and that case belongs to the cwd-scanning
|
||||
reaper, not to this fast path."""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["ps", "-axo", "pid=,ppid="], capture_output=True, text=True, timeout=5
|
||||
).stdout
|
||||
except Exception:
|
||||
return []
|
||||
children: dict = {}
|
||||
for line in out.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
try:
|
||||
pid, ppid = int(parts[0]), int(parts[1])
|
||||
except ValueError:
|
||||
continue
|
||||
children.setdefault(ppid, []).append(pid)
|
||||
found: list = []
|
||||
frontier = [root_pid]
|
||||
while frontier:
|
||||
p = frontier.pop()
|
||||
for c in children.get(p, ()):
|
||||
found.append(c)
|
||||
frontier.append(c)
|
||||
return found
|
||||
|
||||
|
||||
def p_signal_tree(proc, sig) -> None:
|
||||
if proc is None or os.name == "nt":
|
||||
return
|
||||
try:
|
||||
if proc.returncode is not None:
|
||||
return
|
||||
os.kill(proc.pid, signal.SIGSTOP)
|
||||
# Root first so run.sh cannot react to its children changing state, then every descendant.
|
||||
pids = [proc.pid] + descendant_pids(proc.pid)
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
# Already-dead or out-of-permission; both safe to ignore.
|
||||
pass
|
||||
return
|
||||
for pid in pids:
|
||||
try:
|
||||
os.kill(pid, sig)
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
# Already-dead or out-of-permission; both safe to ignore.
|
||||
continue
|
||||
|
||||
|
||||
def suspend_process_tree(proc) -> None:
|
||||
"""SIGSTOP a workspace's WHOLE process tree so it consumes 0% CPU in the LRU idle pool.
|
||||
|
||||
Root + every descendant, individually: the child is deliberately not a session leader (see
|
||||
background_priority_kwargs on why start_new_session stays off), so a group signal is not
|
||||
available and per-pid delivery is the honest mechanism. Measured before this: only run.sh
|
||||
froze (TN) while vite stayed running (SN, up to 125% CPU). No-op on Windows (idle Windows
|
||||
runtimes just stay running, the pre-existing behavior). Failures are swallowed; a dead
|
||||
process needs no stop signal."""
|
||||
p_signal_tree(proc, signal.SIGSTOP)
|
||||
|
||||
|
||||
def resume_process_tree(proc) -> None:
|
||||
"""SIGCONT a previously-suspended workspace process. Pair with
|
||||
suspend_process_tree. Microsecond cost; idempotent if the process
|
||||
was never paused."""
|
||||
if proc is None or os.name == "nt":
|
||||
return
|
||||
try:
|
||||
if proc.returncode is not None:
|
||||
return
|
||||
os.kill(proc.pid, signal.SIGCONT)
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
pass
|
||||
"""SIGCONT the whole previously-suspended tree. Pair with suspend_process_tree.
|
||||
Idempotent if the tree was never paused."""
|
||||
p_signal_tree(proc, signal.SIGCONT)
|
||||
|
||||
|
||||
def background_priority_kwargs() -> dict:
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Parking an idle app freezes the WHOLE process tree, not just its root (ENG-311).
|
||||
|
||||
The runtime tracks one pid (`bash run.sh`) whose real work lives in grandchildren (backgrounded
|
||||
pipeline -> vite). The old suspend signalled only the root: measured on the packaged build, run.sh
|
||||
went TN while vite stayed SN at up to 125% CPU, so the idle pool cost a live dev server per parked
|
||||
app, which is the shape of the 150MB/min growth users report (ENG-320). These tests build a real
|
||||
three-level tree and assert OS-level state (ps STAT), both directions, because a "fix" that killed
|
||||
the tree instead of freezing it would pass a one-directional check and destroy the fast reopen.
|
||||
"""
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.apps.outputs.runtime_proc import descendant_pids, resume_process_tree, suspend_process_tree
|
||||
|
||||
pytestmark = pytest.mark.skipif(os.name == "nt", reason="SIGSTOP path is POSIX-only by design")
|
||||
|
||||
|
||||
class P_FakeProc:
|
||||
"""The slice of asyncio.subprocess.Process the signal path reads."""
|
||||
|
||||
def __init__(self, pid: int):
|
||||
self.pid = pid
|
||||
self.returncode = None
|
||||
|
||||
|
||||
def p_stat(pid: int) -> str:
|
||||
out = subprocess.run(["ps", "-o", "stat=", "-p", str(pid)], capture_output=True, text=True).stdout.strip()
|
||||
return out
|
||||
|
||||
|
||||
def p_spawn_tree():
|
||||
# bash -> (bash -> sleep) mirrors run.sh -> frontend/run.sh -> vite: the root backgrounds a
|
||||
# child script and waits, so the interesting pids are two levels down from the tracked one.
|
||||
# The trailing `; :` stops bash exec-optimizing the middle level away (a bare -c 'sleep' execs).
|
||||
root = subprocess.Popen(["bash", "-c", "bash -c 'sleep 300; :' & wait"])
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline:
|
||||
kids = descendant_pids(root.pid)
|
||||
if len(kids) >= 2:
|
||||
return root, kids
|
||||
time.sleep(0.1)
|
||||
raise AssertionError("tree never grew two levels")
|
||||
|
||||
|
||||
def test_descendants_are_found_two_levels_down():
|
||||
root, kids = p_spawn_tree()
|
||||
try:
|
||||
assert len(kids) >= 2, "must see the grandchild, not just the direct child"
|
||||
finally:
|
||||
root.kill()
|
||||
for k in kids:
|
||||
try:
|
||||
os.kill(k, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
def test_suspend_freezes_every_level_and_resume_thaws_them():
|
||||
root, kids = p_spawn_tree()
|
||||
proc = P_FakeProc(root.pid)
|
||||
try:
|
||||
suspend_process_tree(proc)
|
||||
time.sleep(0.3)
|
||||
for pid in [root.pid] + kids:
|
||||
assert "T" in p_stat(pid), f"pid {pid} not frozen; STAT={p_stat(pid)!r} (the ENG-311 no-op)"
|
||||
resume_process_tree(proc)
|
||||
time.sleep(0.3)
|
||||
for pid in [root.pid] + kids:
|
||||
assert "T" not in p_stat(pid), f"pid {pid} still frozen after resume; the fast reopen is dead"
|
||||
finally:
|
||||
resume_process_tree(proc)
|
||||
root.kill()
|
||||
for k in kids:
|
||||
try:
|
||||
os.kill(k, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
def test_dead_root_is_a_quiet_noop():
|
||||
root = subprocess.Popen(["bash", "-c", "true"])
|
||||
root.wait()
|
||||
proc = P_FakeProc(root.pid)
|
||||
proc.returncode = 0
|
||||
suspend_process_tree(proc) # must not raise
|
||||
resume_process_tree(proc)
|
||||
|
||||
|
||||
def test_none_proc_is_a_quiet_noop():
|
||||
suspend_process_tree(None)
|
||||
resume_process_tree(None)
|
||||
Reference in New Issue
Block a user