From d37b1f62dec78905c4146cda50883cc9557a9332 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sat, 15 Aug 2026 23:00:36 -0700 Subject: [PATCH] [eric] apps: parking an idle app freezes its whole process tree, not just the root run.sh (ENG-311) --- backend/apps/outputs/runtime_proc.py | 83 +++++++++++++++------ backend/tests/test_suspend_whole_tree.py | 95 ++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 24 deletions(-) create mode 100644 backend/tests/test_suspend_whole_tree.py diff --git a/backend/apps/outputs/runtime_proc.py b/backend/apps/outputs/runtime_proc.py index 8498f1dc..ab3af49f 100644 --- a/backend/apps/outputs/runtime_proc.py +++ b/backend/apps/outputs/runtime_proc.py @@ -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: diff --git a/backend/tests/test_suspend_whole_tree.py b/backend/tests/test_suspend_whole_tree.py new file mode 100644 index 00000000..6f8bdce2 --- /dev/null +++ b/backend/tests/test_suspend_whole_tree.py @@ -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)