Files

112 lines
4.3 KiB
Python

"""Boot the OpenSwarm backend inside the container and wait until it answers.
Spawned the same way the desktop shell spawns it (`python -m uvicorn backend.main:app`
on loopback) so the cloud path and the laptop path are the same code on the same
socket. Loopback, not 0.0.0.0: every caller of this API lives in this container, and
the agent running inside it has Bash, so there is no reason to publish the port onto
the machine's private network.
"""
import os
import subprocess
import time
from typing import Dict, List, Optional
import httpx
from pydantic import BaseModel, ConfigDict, InstanceOf
from typeguard import typechecked
HOST = "127.0.0.1"
HEALTH_PATH = "/api/health/check"
# 9Router is a Next.js standalone server: it binds `process.env.HOSTNAME || '0.0.0.0'`, and Docker sets HOSTNAME to the container id, so left alone it listens on the container's eth0 address and every probe of 127.0.0.1:20128 gets ECONNREFUSED. Set on the backend's env because the backend is what spawns node.
ROUTER_BIND_HOSTNAME = "127.0.0.1"
AUTH_TOKEN_FILENAME = "auth.token"
# The backend imports the whole app graph before it binds; on a cold Fly machine that has been measured in tens of seconds, so the budget is generous rather than tight.
BOOT_TIMEOUT_SECONDS = 120.0
SHUTDOWN_GRACE_SECONDS = 10.0
class BackendUnavailable(RuntimeError):
"""The backend never came up, or died while we were using it."""
class BackendProcess(BaseModel):
model_config = ConfigDict(validate_assignment=True)
process: InstanceOf[subprocess.Popen]
base_url: str
token: str
@typechecked
def headers(self) -> Dict[str, str]:
return {"Authorization": f"Bearer {self.token}"}
@typechecked
def is_alive(self) -> bool:
return self.process.poll() is None
@typechecked
def p_command(port: int) -> List[str]:
return ["python3", "-m", "uvicorn", "backend.main:app", "--host", HOST, "--port", str(port)]
@typechecked
def p_read_auth_token(data_root: str) -> str:
"""The backend mints this before it binds, so by the time health passes the file exists."""
path = os.path.join(data_root, AUTH_TOKEN_FILENAME)
try:
with open(path, "r", encoding="utf-8") as handle:
return handle.read().strip()
except OSError as exc:
raise BackendUnavailable(f"backend is up but its auth token is unreadable at {path}: {exc}") from exc
@typechecked
def start_backend(app_root: str, data_root: str, port: int, deadline: float) -> BackendProcess:
"""Spawn the backend and block until it answers health, or raise BackendUnavailable."""
environment = dict(os.environ)
environment["OPENSWARM_DATA_ROOT"] = data_root
environment["OPENSWARM_HEADLESS"] = "1"
environment["OPENSWARM_PORT"] = str(port)
environment["OPENSWARM_HOST"] = HOST
environment["HOSTNAME"] = ROUTER_BIND_HOSTNAME
environment["PYTHONPATH"] = app_root
process = subprocess.Popen(p_command(port), cwd=app_root, env=environment)
base_url = f"http://{HOST}:{port}"
budget = min(time.monotonic() + BOOT_TIMEOUT_SECONDS, deadline)
with httpx.Client(timeout=2.0) as client:
while time.monotonic() < budget:
if process.poll() is not None:
raise BackendUnavailable(f"backend exited during startup with code {process.returncode}")
try:
healthy = client.get(f"{base_url}{HEALTH_PATH}").status_code == 200
except httpx.HTTPError:
healthy = False
if healthy:
try:
token = p_read_auth_token(data_root)
except BackendUnavailable:
stop_backend(process)
raise
return BackendProcess(process=process, base_url=base_url, token=token)
time.sleep(0.25)
stop_backend(process)
raise BackendUnavailable(f"backend did not answer {HEALTH_PATH} within its startup budget")
@typechecked
def stop_backend(process: Optional[subprocess.Popen]) -> None:
"""SIGTERM then SIGKILL. The machine is about to die anyway; this just stops the logs mid-sentence."""
if process is None or process.poll() is not None:
return
process.terminate()
try:
process.wait(timeout=SHUTDOWN_GRACE_SECONDS)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=SHUTDOWN_GRACE_SECONDS)