[eric] apps: parking an idle app freezes its whole process tree, not just the root run.sh (ENG-311)

This commit is contained in:
ciregenz
2026-08-15 23:00:36 -07:00
parent 84977535dd
commit d37b1f62de
2 changed files with 154 additions and 24 deletions
+59 -24
View File
@@ -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:
+95
View File
@@ -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)