diff --git a/strix/interface/main.py b/strix/interface/main.py index 96459978..a22d66ff 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() 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