From 414d97b68fafd2ddae6e285395e0c9ef08030730 Mon Sep 17 00:00:00 2001 From: thangcongtran <172007512+thehashes@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:24:21 +0700 Subject: [PATCH 1/2] fix(warmup): import scan deps synchronously to avoid agents-SDK import race start_import_warmup() ran the heavy dependency pre-import on a background daemon thread to overlap it with CLI startup I/O. That races the main thread: the warm-up imports strix.core.runner, which pulls in the agents SDK, whose package graph has internal circular imports. CPython breaks import cycles by exposing a partially initialized module, and that partial state is visible across threads, so when the warm-up thread and the main thread (warm_up_llm imports agents.model_settings / agents.models.interface) import that graph concurrently they intermittently crash with: KeyError: 'agents' ImportError: cannot import name 'AgentOutputSchemaBase' from partially initialized module 'agents.agent_output' (most likely due to a circular import) This reproduces reliably on some hosts (e.g. WSL2) and aborts every scan at bootstrap. The per-module import lock does not help, because the import system intentionally hands out partial modules mid-cycle to avoid deadlock. Warm-up only runs on the scan path, which eagerly imports all of these modules anyway, so importing synchronously on the calling thread adds no net work; it only removes the unsound concurrent-import overlap. Replace the daemon thread with a synchronous, run-once import guarded by the existing lock. start_import_warmup() now returns None (its Thread return value was never used). --- strix/llm/warmup.py | 55 ++++++++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/strix/llm/warmup.py b/strix/llm/warmup.py index 98da959d..fa04b65c 100644 --- a/strix/llm/warmup.py +++ b/strix/llm/warmup.py @@ -1,13 +1,28 @@ -"""Background pre-import of the heavy scan dependencies. +"""Pre-import of the heavy scan dependencies. The scan engine's import graph (the agents SDK, OpenAI client, LiteLLM, the Caido SDK, the Docker SDK) costs seconds to import cold, but none of it is -needed until a scan actually starts. Importing it on a daemon thread at CLI -entry overlaps that cost with the I/O-bound startup work that always precedes -a scan (argument parsing, Docker checks, image pull, TUI setup), so by the -time the scan begins the modules are already in ``sys.modules``. Any thread -that needs one of them before the warm-up finishes just blocks on the normal -import lock, so behaviour is unchanged either way. +needed until a scan actually starts. We import it here, once, at the start of +the scan bootstrap so the cost is paid up front rather than on first use deep +in the run. + +This used to run on a background daemon thread to overlap the import cost with +the I/O-bound startup work that precedes a scan. That is unsound: the warm-up +imports ``strix.core.runner``, which pulls in the agents SDK, whose package +graph has internal circular imports. CPython resolves circular imports by +returning a *partially initialized* module to break the cycle, and that partial +state is observable from other threads. When the warm-up thread and the main +thread (``warm_up_llm`` imports ``agents.model_settings`` / +``agents.models.interface``) import that graph concurrently, they intermittently +observe each other's partial ``agents`` package and crash with +``KeyError: 'agents'`` or "cannot import name ... from partially initialized +module 'agents.agent_output'". This reproduces reliably on some hosts (e.g. +WSL2). The per-module import lock does not prevent it precisely because the +import system deliberately hands out partial modules mid-cycle. + +Warm-up only runs on the scan path, which eagerly imports all of these modules +anyway, so importing synchronously here adds no net work - it just removes the +racy overlap. See https://github.com/usestrix/strix (import-warmup race). """ from __future__ import annotations @@ -27,7 +42,7 @@ WARMUP_MODULES = ( ) _lock = threading.Lock() -_thread: threading.Thread | None = None +_warmed: bool = False def _warm(modules: tuple[str, ...]) -> None: @@ -38,18 +53,18 @@ def _warm(modules: tuple[str, ...]) -> None: logger.debug("Import warm-up for %r failed", name, exc_info=True) -def start_import_warmup(modules: tuple[str, ...] = WARMUP_MODULES) -> threading.Thread: - """Start importing the heavy scan dependencies in the background, once. +def start_import_warmup(modules: tuple[str, ...] = WARMUP_MODULES) -> None: + """Import the heavy scan dependencies once, synchronously. - ``modules`` lets embedders that never touch some backends (e.g. a cloud - runtime that has no local Docker) warm a narrower set. + Runs on the calling thread rather than a background daemon: importing the + agents-SDK-bearing graph concurrently with the main thread races on the + SDK's internal circular imports (see module docstring). ``modules`` lets + embedders that never touch some backends (e.g. a cloud runtime that has no + local Docker) warm a narrower set. """ - global _thread # noqa: PLW0603 + global _warmed # noqa: PLW0603 with _lock: - if _thread is not None: - return _thread - _thread = threading.Thread( - target=_warm, args=(modules,), name="strix-import-warmup", daemon=True - ) - _thread.start() - return _thread + if _warmed: + return + _warm(modules) + _warmed = True From d0c926b3f079ab92c919abe0f0e80632f5177e55 Mon Sep 17 00:00:00 2001 From: thehashes <172007512+thehashes@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:37:39 +0700 Subject: [PATCH 2/2] fix(warmup): move synchronous warmup onto confirmed scan path start_import_warmup() ran before parse_arguments(), so cold --help, --version, --update and interactive setup paid the full scan import graph. Now that warm-up is synchronous the early placement buys nothing; move the call to the top of _bootstrap_scan(), which only runs when a scan actually proceeds (not args.needs_setup). --- strix/interface/main.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/strix/interface/main.py b/strix/interface/main.py index 45e114b5..c1b969b7 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -393,6 +393,15 @@ def _bootstrap_scan(args: argparse.Namespace) -> None: preflight and run preparation happen inside the TUI so the interface paints immediately instead of waiting on a model round trip. """ + # Pre-import the heavy scan dependencies once, on the confirmed scan path. + # This is deliberately not done before argument parsing: warm-up is + # synchronous (see strix.llm.warmup), so running it earlier would make cold + # --help/--version/--update and interactive setup wait on the full scan + # import graph for no benefit. + from strix.llm.warmup import start_import_warmup + + start_import_warmup() + validate_environment() if not args.non_interactive: return @@ -431,10 +440,6 @@ def main() -> None: sys.exit(run_auth(sys.argv[2:])) - from strix.llm.warmup import start_import_warmup - - start_import_warmup() - args = parse_arguments() start_background_check()