From bca0050dcb1dede2bce49bad2734927d4a8a5693 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 16 Jun 2026 21:59:16 -0700 Subject: [PATCH] [eric] perf: start 9router in background instead of blocking the http bind - service lifespan was awaiting ensure_9router (~7.4s, up to ~18s cold) on the boot path - dispatch already ensures it lazily; serialize ensure_running so no double-spawn - cuts warm backend-ready ~9-10s toward ~2-3s --- backend/apps/nine_router/process.py | 15 +++++++++++++++ backend/apps/service/service.py | 19 +++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/backend/apps/nine_router/process.py b/backend/apps/nine_router/process.py index b34a51e0..12935c00 100644 --- a/backend/apps/nine_router/process.py +++ b/backend/apps/nine_router/process.py @@ -57,6 +57,11 @@ NINE_ROUTER_NPM_VERSION = os.environ.get("OPENSWARM_ROUTER_VERSION", "0.3.60") _process: subprocess.Popen | None = None +# Serializes ensure_running() so a background auto-start and a concurrent +# dispatch-time ensure can't both spawn 9Router (double-bind on :20128). Lazily +# created so module import doesn't require a running event loop. +_start_lock: "asyncio.Lock | None" = None + # Short TTL cache for positive is_running() results. The probe is a sync # httpx.get that blocks the event loop, and under load (9Router busy # streaming inference) it can exceed its 2s timeout and return False even @@ -334,6 +339,16 @@ def _report_start_failure(reason: str, *, detail: str = "", **fields: Any) -> No async def ensure_running(): + """Start 9Router if not already running. Serialized so concurrent callers + (the background auto-start + a dispatch-time ensure) can't double-spawn.""" + global _start_lock + if _start_lock is None: + _start_lock = asyncio.Lock() + async with _start_lock: + await _ensure_running_impl() + + +async def _ensure_running_impl(): """Start 9Router if not already running.""" global _process _is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1" diff --git a/backend/apps/service/service.py b/backend/apps/service/service.py index 532eab62..93c70851 100644 --- a/backend/apps/service/service.py +++ b/backend/apps/service/service.py @@ -33,6 +33,7 @@ logger = logging.getLogger(__name__) _pulse_task: asyncio.Task | None = None _drain_task: asyncio.Task | None = None +_9r_start_task: asyncio.Task | None = None _last_9r_cost: float | None = None _last_9r_prompt_tokens: int | None = None @@ -121,7 +122,7 @@ async def _drain_loop(): @asynccontextmanager async def service_lifespan(): - global _pulse_task, _drain_task + global _pulse_task, _drain_task, _9r_start_task try: from backend.apps.settings.settings import load_settings, _save_settings @@ -193,7 +194,13 @@ async def service_lifespan(): try: from backend.apps.nine_router import ensure_running as ensure_9router - await ensure_9router() + # Start 9Router in the BACKGROUND instead of awaiting it here. Awaiting + # it was ~7s (up to ~18s cold) of the startup critical path, blocking the + # HTTP bind and the whole UI behind it. 9Router is only needed when the + # user sends an agent message, and the dispatch path calls ensure_running() + # itself (now serialized, so no double-spawn), so the first message waits + # for readiness lazily. This is the single biggest warm-startup win. + _9r_start_task = asyncio.create_task(ensure_9router()) except Exception as e: logger.debug(f"9Router auto-start skipped: {e}") @@ -218,6 +225,14 @@ async def service_lifespan(): pass _drain_task = None + if _9r_start_task and not _9r_start_task.done(): + _9r_start_task.cancel() + try: + await _9r_start_task + except (asyncio.CancelledError, Exception): + pass + _9r_start_task = None + try: from backend.apps.nine_router import stop as stop_9router stop_9router()