From 51bd1f990afdd9f66302ea0ab946479e86b49e51 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 6 Sep 2026 20:58:03 -0700 Subject: [PATCH] [eric] ci: a test process never spawns a router (OSW_NEVER_SPAWN_ROUTER), the boot hook's sync npm install hung the suite on every router-less runner; the slot-size test locates its source on Windows (ENG-486) Co-Authored-By: Claude Fable 5.1 --- .github/workflows/suites-matrix.yml | 1 + backend/apps/nine_router/process.py | 21 +++++++++-- backend/tests/conftest.py | 3 +- backend/tests/test_router_spawn_hold.py | 36 +++++++++++++++++++ .../pages/AgentChat/browserSlotSize.test.ts | 3 +- 5 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_router_spawn_hold.py diff --git a/.github/workflows/suites-matrix.yml b/.github/workflows/suites-matrix.yml index 6e9c30a2..28baa561 100644 --- a/.github/workflows/suites-matrix.yml +++ b/.github/workflows/suites-matrix.yml @@ -28,6 +28,7 @@ jobs: env: OSW_NEVER_KILL_ROUTER: '1' OSW_DISABLE_AUTO_RESUME: '1' + OSW_NEVER_SPAWN_ROUTER: '1' # The packaged app spawns its backend with PYTHONUTF8=1 (electron/main.js); the suite runs the way the product runs, or Windows' cp1252 default fails collection on the first source read. PYTHONUTF8: '1' CSC_IDENTITY_AUTO_DISCOVERY: 'false' diff --git a/backend/apps/nine_router/process.py b/backend/apps/nine_router/process.py index 00a85920..0723fc81 100644 --- a/backend/apps/nine_router/process.py +++ b/backend/apps/nine_router/process.py @@ -414,10 +414,28 @@ def p_report_start_failure(reason: str, *, detail: str = "", **fields: Any) -> N logger.debug("9router start-failure diagnostic submit failed", exc_info=True) +def spawn_held_because() -> str | None: + """A declared switch, never an incidental fact: the suite and CI set it, the product never does.""" + if os.environ.get("OSW_NEVER_SPAWN_ROUTER") == "1": + return "OSW_NEVER_SPAWN_ROUTER=1 (a test run adopts a router or has none)" + return None + + +p_spawn_hold_said = False + + 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 p_start_lock + global p_start_lock, p_spawn_hold_said + held = spawn_held_because() + if held: + # The boot-time start ran a sync `npm install 9router` (up to 300 s) INSIDE the event loop on a runner with no + # router, and the whole suite hung behind it (macOS CI, 2026-09-06). A held spawn says so once and never blocks. + if not p_spawn_hold_said: + p_spawn_hold_said = True + logger.warning(f"[9router] NOT starting the router because {held}; every lane that needs it is unserved in this process") + return if p_start_lock is None: p_start_lock = asyncio.Lock() async with p_start_lock: @@ -566,7 +584,6 @@ async def p_ensure_running_impl(): if p_process is not None and p_process.poll() is None: logger.info("9Router already running (ours) on port %d", NINE_ROUTER_PORT) return - import subprocess as p_sp try: p_stale = stale_router_pids() if p_stale and router_kill_held_because(): diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 635ee185..cb93de54 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -24,7 +24,6 @@ assert "backend.config.paths" not in sys.modules, "conftest must set OPENSWARM_D os.environ["OPENSWARM_DATA_ROOT"] = tempfile.mkdtemp(prefix="osw_test_data_") import asyncio -from datetime import datetime, timezone from types import SimpleNamespace import pytest @@ -48,6 +47,8 @@ def _isolate_browser_state(monkeypatch): monkeypatch.setenv("OSW_DISABLE_AUTO_RESUME", "1") # A suite run must never kill a router it did not start; that is the user's app (ENG-393). monkeypatch.setenv("OSW_NEVER_KILL_ROUTER", "1") + # ...and never START one: the boot hook ran `npm install 9router` inside the event loop on a router-less CI runner and hung the suite. + monkeypatch.setenv("OSW_NEVER_SPAWN_ROUTER", "1") monkeypatch.setenv("OSW_PRESTAGE", "0") monkeypatch.setenv("OSW_FASTREAD_HOP", "0") monkeypatch.setenv("OSW_PRELUDE_TRIM", "0") diff --git a/backend/tests/test_router_spawn_hold.py b/backend/tests/test_router_spawn_hold.py new file mode 100644 index 00000000..76be51e2 --- /dev/null +++ b/backend/tests/test_router_spawn_hold.py @@ -0,0 +1,36 @@ +"""A test process never starts a router: the boot hook's sync npm install hung the whole suite on a router-less runner.""" +import logging + +import pytest + +from backend.apps.nine_router import process + + +@pytest.mark.asyncio +async def test_a_held_spawn_never_reaches_the_installer_and_says_so_once(monkeypatch): + monkeypatch.setenv("OSW_NEVER_SPAWN_ROUTER", "1") + process.p_spawn_hold_said = False + + async def boom(): + raise AssertionError("the installer ran under a held spawn") + + monkeypatch.setattr(process, "p_ensure_running_impl", boom) + seen = [] + handler = logging.Handler() + handler.emit = lambda rec: seen.append(rec.getMessage()) + process.logger.addHandler(handler) + try: + await process.ensure_running() + await process.ensure_running() + finally: + process.logger.removeHandler(handler) + said = [m for m in seen if "NOT starting the router" in m] + assert len(said) == 1, said + + +def test_the_suite_and_ci_declare_the_hold(): + import os + import pathlib + assert os.environ.get("OSW_NEVER_SPAWN_ROUTER") == "1", "conftest must set it" + ci = pathlib.Path(".github/workflows/suites-matrix.yml").read_text(encoding="utf-8") + assert "OSW_NEVER_SPAWN_ROUTER: '1'" in ci diff --git a/frontend/src/app/pages/AgentChat/browserSlotSize.test.ts b/frontend/src/app/pages/AgentChat/browserSlotSize.test.ts index 60a43362..ce8d14a3 100644 --- a/frontend/src/app/pages/AgentChat/browserSlotSize.test.ts +++ b/frontend/src/app/pages/AgentChat/browserSlotSize.test.ts @@ -85,9 +85,10 @@ test('the component CSS still matches the constants this file tests', async () = const url = await import('node:url'); const here = url.fileURLToPath(new URL('.', import.meta.url)); // The test runs from .test-build, so resolve the source next to it by name. + // Both separators: on Windows `here` is D:\...\.test-build\..., and a '/'-only replace found nothing (CI, 2026-09-06). const candidates = [ here + 'AgentChat.tsx', - here.replace('/.test-build/', '/src/') + 'AgentChat.tsx', + here.replace(/([\\/])\.test-build([\\/])/, '$1src$2') + 'AgentChat.tsx', ]; const path = candidates.find((p) => fs.existsSync(p)); assert.ok(path, `could not locate AgentChat.tsx from ${here}`);