This commit is contained in:
Rome Thorstenson 2026-06-29 19:10:30 -07:00 • committed by GitHub
commit 672fae5663
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 76 additions and 1 deletions

View file

@ -310,6 +310,7 @@ def _positive_budget(value: str) -> float:
except ValueError as exc:
raise argparse.ArgumentTypeError(f"invalid float value: {value!r}") from exc
import math
if not math.isfinite(budget) or budget <= 0:
raise argparse.ArgumentTypeError("must be a finite number greater than 0")
return budget
@ -824,7 +825,11 @@ def main() -> None:
exit_reason = "error"
posthog.error("unhandled_exception", str(e))
scarf.error("unhandled_exception", str(e))
raise
# The CLI/TUI layer already rendered a user-friendly error panel for
# unhandled provider errors (e.g. openai.BadRequestError). Re-raising
# here surfaces a raw traceback (and a PyInstaller crash dump in the
# binary release), so exit cleanly with a non-zero status instead.
sys.exit(1)
finally:
report_state = get_global_report_state()
if report_state:

70
tests/test_main_exit.py Normal file
View file

@ -0,0 +1,70 @@
"""Tests for clean process exit on unhandled provider errors in ``main``."""
import importlib
from argparse import Namespace
from collections.abc import Iterator
from unittest.mock import MagicMock
import httpx
import openai
import pytest
# ``strix.interface.__init__`` rebinds the ``main`` attribute to the function,
# shadowing the submodule, so import the module object explicitly.
main_module = importlib.import_module("strix.interface.main")
def _build_args() -> Namespace:
return Namespace(
config=None,
resume="test-run",
run_name=None,
targets_info=[],
instruction=None,
scan_mode="default",
non_interactive=True,
)
def _bad_request_error() -> openai.BadRequestError:
response = httpx.Response(400, request=httpx.Request("POST", "http://test"))
return openai.BadRequestError(message="bad request", response=response, body=None)
@pytest.fixture
def _patched_main(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
settings = MagicMock()
monkeypatch.setattr(main_module, "parse_arguments", _build_args)
monkeypatch.setattr(main_module, "check_docker_installed", lambda: None)
monkeypatch.setattr(main_module, "pull_docker_image", lambda: None)
monkeypatch.setattr(main_module, "validate_environment", lambda: None)
monkeypatch.setattr(main_module, "persist_current", lambda: None)
monkeypatch.setattr(main_module, "load_settings", lambda: settings)
monkeypatch.setattr(main_module, "get_global_report_state", lambda: None)
monkeypatch.setattr(main_module, "posthog", MagicMock())
monkeypatch.setattr(main_module, "scarf", MagicMock())
# The first ``asyncio.run`` call is the LLM warm-up (let it pass); the
# second drives the scan and is where an unhandled provider error surfaces.
state = {"calls": 0}
def fake_run(coro: object) -> None:
if hasattr(coro, "close"):
coro.close() # never execute the coroutine body
state["calls"] += 1
if state["calls"] == 1:
return
raise _bad_request_error()
monkeypatch.setattr(main_module.asyncio, "run", fake_run)
yield
def test_bad_request_error_exits_cleanly(_patched_main: None) -> None:
"""An unhandled ``BadRequestError`` must exit(1), not re-raise."""
with pytest.raises(SystemExit) as exc_info:
main_module.main()
assert exc_info.value.code == 1
assert not isinstance(exc_info.value, openai.BadRequestError)