Files
openswarm/backend/apps/outputs/runtime_proc.py
T

252 lines
10 KiB
Python

"""OS/process/port primitives for the per-workspace runtime: signal-based
suspend/resume, descendant-tree kills, free-port allocation, and .env
read/write. No asyncio runtime state lives here; AppRuntime (runtime.py) owns
that and just calls into these."""
import logging
import os
import re
import signal
import socket
import subprocess
from typing import Optional
logger = logging.getLogger(__name__)
# SIGTERM grace; well-behaved servers shut down under a second so 3s is enough.
TERMINATE_GRACE_SECONDS = 3
# 180s covers npm install (60-90s on typical hardware) plus the Vite bind.
FRONTEND_BIND_TIMEOUT_SECONDS = 180
# 80ms probe: dropping from 500ms was pure user-visible preview latency win; cheap on localhost.
FRONTEND_BIND_POLL_INTERVAL = 0.08
# 2000 lines per runtime; lets a Terminal tab opened mid-session replay context. ~few hundred KB at worst.
LOG_BUFFER_LINES = 2000
# Idle runtimes kept in LRU; trades memory for instant switch-back, beyond 1 because typical users ping-pong 2-3 apps.
MAX_IDLE_RUNTIMES = 3
# Cap on recent error lines the agent gets; 50 is enough for babel error + stack + a few warnings.
RECENT_ERRORS_MAX = 50
# Narrow regex for build errors (vite, babel, tsc, uvicorn); keeps routine logs out of agent context.
ERROR_PATTERNS = re.compile(
r"(?:"
r"\[plugin:[^\]]+\]|" # vite plugin errors
r"SyntaxError|" # node / babel
r"Unexpected token|" # babel / tsc parser
r"\berror TS\d+|" # tsc diagnostics
r"ERROR\s+in\s|" # webpack-style
r"Traceback \(most recent call last\)|" # python
r"ModuleNotFoundError|"
r"ImportError|"
r"AttributeError:|"
r"Failed to compile|"
r"Cannot find module|"
r"Cannot resolve"
r")"
)
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.
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."""
if proc is None or os.name == "nt":
return
try:
if proc.returncode is not None:
return
os.kill(proc.pid, signal.SIGSTOP)
except (ProcessLookupError, PermissionError, OSError):
# Already-dead or out-of-permission; both safe to ignore.
pass
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
def background_priority_kwargs() -> dict:
"""Return the kwargs that lower the spawned subprocess's OS priority
to a "background" level. On POSIX this is `preexec_fn=os.nice(10)`,
which sets the child's nice to +10 BEFORE exec (so the renice covers
the entire bash → vite + uvicorn process tree). On Windows it's
`creationflags=BELOW_NORMAL_PRIORITY_CLASS`. The OS scheduler then
yields workspace cycles to whichever agent or browser tab is in the
user's foreground, so an in-background app build doesn't starve a
live chat session.
We intentionally do NOT pass `start_new_session=True` here even
though it would defend against an errant `kill 0` inside the
workspace propagating into the OpenSwarm group: doing so also
detaches the workspace from the terminal's foreground process
group, so a user Ctrl+C only reaches OpenSwarm itself and the
cleanup path has to chase every workspace by hand. If that path
is even slightly slow or gets interrupted by a second Ctrl+C, the
workspace's uvicorn / vite leaks past shutdown and the next
`bash run.sh` hits Errno 48 on port 8324. The `kill 0` propagation
is fixed at its source in the workspace template's run.sh
(uses `kill_tree` on tracked PIDs, never `kill 0`)."""
if os.name == "nt":
# subprocess.BELOW_NORMAL_PRIORITY_CLASS == 0x4000
return {"creationflags": subprocess.BELOW_NORMAL_PRIORITY_CLASS}
return {"preexec_fn": lambda: os.nice(10)}
def find_free_port() -> int:
"""Ask the kernel for an unused localhost port. There's a tiny race
between this socket closing and the backend re-binding, but we hand
each port to exactly one runtime so no caller competes for it, and
the kernel won't immediately recycle a freshly-closed port anyway."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def kill_descendant_tree(pid: int, sig_name: str = "TERM") -> None:
"""Recursively signal every descendant of `pid`, leaves-first. The
webapp template's run.sh installs `trap cleanup EXIT` (no TERM), so a
plain SIGTERM to the bash wrapper exits bash silently and leaves
vite/uvicorn grandchildren reparented to PID 1, squatting on the
workspace's ports. Walking the tree ourselves bypasses the template's
signal-handling habits entirely. POSIX uses `pgrep -P` to enumerate
direct children; Windows is covered by `taskkill /T /F` (job-object
walk). All failures are swallowed; missing PIDs mean the process
already exited, which is the desired state anyway."""
if os.name == "nt":
try:
subprocess.run(
["taskkill", "/PID", str(pid), "/T", "/F"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
)
except Exception:
pass
return
try:
out = subprocess.run(
["pgrep", "-P", str(pid)],
capture_output=True,
text=True,
timeout=2,
)
children = [int(p) for p in out.stdout.split() if p.strip().isdigit()]
except Exception:
children = []
for child in children:
kill_descendant_tree(child, sig_name)
sig = getattr(signal, f"SIG{sig_name}", signal.SIGTERM)
for child in children:
try:
os.kill(child, sig)
except (ProcessLookupError, PermissionError, OSError):
pass
def is_port_free(port: int) -> bool:
"""True if nothing currently holds a TCP listener on 127.0.0.1:port.
Cheap kernel-probe; resolves on bind success. Used as the cross-session
safety net: if a prior OpenSwarm run left a ghost subprocess holding
the .env-persisted FRONTEND_PORT, we detect it here and reallocate
rather than handing run.sh a port that will EADDRINUSE."""
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", port))
return True
except OSError:
return False
def write_env_value(env_path: str, key: str, value: str) -> None:
"""Update KEY=VALUE in an existing `.env`, preserving every other
line. Creates the file if missing. Used when a persisted port collides
with a ghost from a prior session and we have to reallocate before
spawning run.sh."""
lines: list[str] = []
found = False
if os.path.exists(env_path):
try:
with open(env_path, encoding="utf-8") as f:
lines = f.readlines()
except Exception:
lines = []
for i, raw in enumerate(lines):
stripped = raw.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
k = stripped.split("=", 1)[0].strip()
if k == key:
lines[i] = f"{key}={value}\n"
found = True
break
if not found:
if lines and not lines[-1].endswith("\n"):
lines[-1] = lines[-1] + "\n"
lines.append(f"{key}={value}\n")
try:
with open(env_path, "w", encoding="utf-8") as f:
f.writelines(lines)
except Exception:
logger.exception("failed writing %s=%s to %s", key, value, env_path)
def is_new_mode(workspace_path: str) -> bool:
"""A workspace is "new-mode" (webapp-template scaffold) if it has a
`run.sh` at its root. Old-mode workspaces are flat `index.html`-only
apps that pre-date the template swap; they're served by OpenSwarm's
own `/api/outputs/workspace/{ws}/serve/...` FastAPI route and have an
optional `backend.py` we spawn directly.
Single-file probe so the check is cheap to call on every runtime
start, status query, and serve request."""
return os.path.isfile(os.path.join(workspace_path, "run.sh"))
def read_env_value(env_path: str, key: str) -> Optional[str]:
"""Parse one value out of a workspace's `.env` without the cost of a
full subprocess-source. Strips quotes + trailing comments. Returns
None if the file or key is missing."""
if not os.path.exists(env_path):
return None
try:
with open(env_path, encoding="utf-8") as f:
for raw in f:
line = raw.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
k, _, v = line.partition("=")
if k.strip() != key:
continue
v = v.strip()
# Strip an inline `# comment`. Naive; bash semantics are more permissive, but values we write don't contain `#`.
if "#" in v:
v = v.split("#", 1)[0].rstrip()
if (v.startswith('"') and v.endswith('"')) or (v.startswith("'") and v.endswith("'")):
v = v[1:-1]
return v
except Exception:
logger.exception("failed reading %s from %s", key, env_path)
return None