mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/usestrix/strix.git
synced 2026-09-20 08:03:42 +08:00
Compare commits
4 Commits
main
...
fix/warmup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da6059f243 | ||
|
|
17d2accdba | ||
|
|
de1c590db7 | ||
|
|
33cb916aa9 |
@@ -41,6 +41,7 @@ from strix.interface.update_check import (
|
||||
from strix.interface.utils import (
|
||||
build_final_stats_text,
|
||||
)
|
||||
from strix.llm.warmup import start_import_warmup, wait_for_import_warmup
|
||||
from strix.telemetry import posthog, scarf
|
||||
from strix.telemetry.logging import configure_dependency_logging
|
||||
|
||||
@@ -450,8 +451,6 @@ def main() -> None:
|
||||
|
||||
sys.exit(run_cloud(sys.argv[2:]))
|
||||
|
||||
from strix.llm.warmup import start_import_warmup
|
||||
|
||||
start_import_warmup()
|
||||
|
||||
args = parse_arguments()
|
||||
@@ -466,6 +465,9 @@ def main() -> None:
|
||||
pull_docker_image()
|
||||
validate_environment()
|
||||
|
||||
# Everything below imports the scan engine; do not race the warm-up thread.
|
||||
wait_for_import_warmup()
|
||||
|
||||
if args.non_interactive:
|
||||
_bootstrap_scan(args)
|
||||
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
"""Background 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.
|
||||
Caido 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). The Docker SDK is not on the
|
||||
list: the Docker checks import it on the main thread during that same window.
|
||||
|
||||
The main thread must call :func:`wait_for_import_warmup` before its first
|
||||
import from that graph. Two threads that enter the same package graph from
|
||||
different modules hold each other's import locks, and CPython breaks the cycle
|
||||
by failing one of the imports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
|
||||
|
||||
@@ -24,45 +26,17 @@ WARMUP_MODULES = (
|
||||
"strix.core.runner",
|
||||
"litellm",
|
||||
"caido_sdk_client",
|
||||
"docker",
|
||||
)
|
||||
|
||||
_lock = threading.Lock()
|
||||
_thread: threading.Thread | None = None
|
||||
|
||||
|
||||
def _purge_orphaned_modules(before: frozenset[str]) -> None:
|
||||
"""Remove submodules stranded by an import attempt that just failed.
|
||||
|
||||
When a package import fails partway (for example CPython's import-lock
|
||||
deadlock avoidance breaking a cross-thread cycle), the failed package is
|
||||
removed from ``sys.modules`` but submodules it already finished stay
|
||||
behind. A later import of one of those submodules then short-circuits on
|
||||
the cached entry without re-importing its parent, and re-entering the
|
||||
parent from inside a submodule crashes with "partially initialized
|
||||
module". Dropping the orphans (cached submodules whose ancestor package is
|
||||
gone) restores a clean slate, and touches nothing another thread imported
|
||||
successfully.
|
||||
"""
|
||||
added = set(sys.modules) - before
|
||||
for name in added:
|
||||
parent = name.rpartition(".")[0]
|
||||
while parent:
|
||||
if parent not in sys.modules:
|
||||
sys.modules.pop(name, None)
|
||||
logger.debug("Import warm-up purged orphaned module %r", name)
|
||||
break
|
||||
parent = parent.rpartition(".")[0]
|
||||
|
||||
|
||||
def _warm(modules: tuple[str, ...]) -> None:
|
||||
for name in modules:
|
||||
before = frozenset(sys.modules)
|
||||
try:
|
||||
importlib.import_module(name)
|
||||
except Exception: # noqa: BLE001 - a failed warm-up must never fail the run.
|
||||
logger.debug("Import warm-up for %r failed", name, exc_info=True)
|
||||
_purge_orphaned_modules(before)
|
||||
|
||||
|
||||
def start_import_warmup(modules: tuple[str, ...] = WARMUP_MODULES) -> threading.Thread:
|
||||
@@ -72,11 +46,15 @@ def start_import_warmup(modules: tuple[str, ...] = WARMUP_MODULES) -> threading.
|
||||
runtime that has no local Docker) warm a narrower set.
|
||||
"""
|
||||
global _thread # noqa: PLW0603
|
||||
with _lock:
|
||||
if _thread is not None:
|
||||
return _thread
|
||||
if _thread is None:
|
||||
_thread = threading.Thread(
|
||||
target=_warm, args=(modules,), name="strix-import-warmup", daemon=True
|
||||
)
|
||||
_thread.start()
|
||||
return _thread
|
||||
return _thread
|
||||
|
||||
|
||||
def wait_for_import_warmup() -> None:
|
||||
"""Block until the warm-up thread has finished, if one was started."""
|
||||
if _thread is not None:
|
||||
_thread.join()
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"""The import warm-up thread must never leave the import system poisoned.
|
||||
"""The import warm-up thread must never race the main thread into the engine.
|
||||
|
||||
Field failure: the warm-up thread's ``strix.core.runner`` import and the main
|
||||
thread's ``strix.report`` import both walked the agents SDK graph, and the two
|
||||
held each other's import locks (report -> dedupe -> agents while runner ->
|
||||
hooks -> report.state). CPython's deadlock avoidance breaks such a cycle by
|
||||
failing one import, which strands finished submodules in ``sys.modules`` with
|
||||
their parent package gone — and the next import of one of those submodules
|
||||
crashes with "partially initialized module".
|
||||
Two threads that enter the same package graph from different modules hold
|
||||
each other's import locks (warm-up: ``strix.core.runner`` -> ``agents``;
|
||||
main: ``agents.models.interface``). CPython breaks such a cycle by failing one
|
||||
of the imports, so the main thread waits for the warm-up before its first
|
||||
engine import.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -14,10 +12,16 @@ from __future__ import annotations
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
import threading
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from strix.llm import warmup
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
|
||||
|
||||
def _run(code: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run( # noqa: S603
|
||||
[sys.executable, "-c", textwrap.dedent(code)],
|
||||
@@ -56,44 +60,49 @@ def test_check_duplicate_resolves_lazily() -> None:
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_failed_warm_import_purges_orphaned_submodules() -> None:
|
||||
def test_wait_for_import_warmup_lets_main_thread_import_the_agents_graph() -> None:
|
||||
result = _run(
|
||||
"""
|
||||
import sys
|
||||
|
||||
from strix.llm.warmup import _warm
|
||||
from strix.llm.warmup import start_import_warmup, wait_for_import_warmup
|
||||
|
||||
# A package whose import fails after a submodule already completed:
|
||||
# CPython removes the package but leaves the submodule stranded.
|
||||
import pathlib
|
||||
import tempfile
|
||||
# Same shape as the CLI: warm-up starts, then the main thread needs a
|
||||
# module from the middle of the agents graph.
|
||||
start_import_warmup()
|
||||
wait_for_import_warmup()
|
||||
|
||||
root = pathlib.Path(tempfile.mkdtemp())
|
||||
pkg = root / "stranded_pkg"
|
||||
pkg.mkdir()
|
||||
(pkg / "ok.py").write_text("VALUE = 1")
|
||||
(pkg / "__init__.py").write_text("from . import ok\\nraise RuntimeError('boom')")
|
||||
sys.path.insert(0, str(root))
|
||||
from agents.models.interface import ModelTracing # noqa: F401
|
||||
|
||||
_warm(("stranded_pkg",))
|
||||
|
||||
assert "stranded_pkg" not in sys.modules
|
||||
assert "stranded_pkg.ok" not in sys.modules, "orphan survived the purge"
|
||||
|
||||
# And the subtree imports cleanly afterwards up to the real error.
|
||||
try:
|
||||
import stranded_pkg # noqa: F401
|
||||
except RuntimeError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected the package's own error")
|
||||
assert "agents" in sys.modules
|
||||
assert "agents.models" in sys.modules
|
||||
assert "strix.core.runner" in sys.modules
|
||||
"""
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_purge_does_not_touch_preexisting_or_healthy_modules() -> None:
|
||||
before = frozenset(sys.modules) - {"strix.llm.warmup"}
|
||||
warmup._purge_orphaned_modules(before)
|
||||
assert "strix.llm.warmup" in sys.modules # parent chain intact -> kept
|
||||
assert "strix" in sys.modules
|
||||
def test_wait_for_import_warmup_blocks_until_the_thread_finishes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
release = threading.Event()
|
||||
monkeypatch.setattr(warmup, "_warm", lambda _modules: release.wait())
|
||||
monkeypatch.setattr(warmup, "_thread", None)
|
||||
warmup.start_import_warmup(())
|
||||
|
||||
waiter = threading.Thread(target=warmup.wait_for_import_warmup)
|
||||
waiter.start()
|
||||
waiter.join(0.2)
|
||||
assert waiter.is_alive(), "returned before the warm-up finished"
|
||||
|
||||
release.set()
|
||||
waiter.join(5)
|
||||
assert not waiter.is_alive()
|
||||
|
||||
|
||||
def test_failed_warm_import_does_not_raise() -> None:
|
||||
warmup._warm(("strix_no_such_module_for_warmup_test",))
|
||||
|
||||
|
||||
def test_wait_for_import_warmup_is_a_no_op_without_a_thread() -> None:
|
||||
warmup.wait_for_import_warmup()
|
||||
|
||||
Reference in New Issue
Block a user