From c7b2c905188c137e40621a65bccefd9f2d7122b6 Mon Sep 17 00:00:00 2001 From: Cowork Supervisor Date: Sun, 12 Jul 2026 12:48:03 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20spawn=5Ftest=20never=20raises=20?= =?UTF-8?q?=E2=80=94=20coerce=20env/command=20to=20str,=20wrap=20unexpecte?= =?UTF-8?q?d=20errors=20(#34)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #34 Co-Authored-By: Claude Fable 5 --- bcc_core.py | 24 ++++++++++++++++++++++-- tests/test_core.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/bcc_core.py b/bcc_core.py index 7a8574f..8f89be6 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -1591,12 +1591,29 @@ def spawn_test(data: dict, timeout: float = 3.0) -> dict: "crashed" — exited with a non-zero code before timeout "not_found" — command could not be resolved to an executable "not_applicable" — remote server or no command; nothing to spawn + "error" — unexpected internal failure while spawning/observing returncode: int | None stderr: str (first ~4 KB) detail: str + Never raises: the GUI threads (Test launch / Test all) re-enable their + buttons only when a result arrives, so an escaping exception would leave + the UI stuck. Anything unexpected comes back as outcome "error". + Run this off the UI thread — it blocks for up to `timeout` seconds. """ + try: + return _spawn_test_impl(data, timeout) + except Exception as e: + return { + "outcome": "error", + "returncode": None, + "stderr": "", + "detail": f"unexpected error: {e!r}", + } + + +def _spawn_test_impl(data: dict, timeout: float) -> dict: if "url" in data and "command" not in data: return { "outcome": "not_applicable", @@ -1605,7 +1622,9 @@ def spawn_test(data: dict, timeout: float = 3.0) -> dict: "detail": "remote server", } - cmd = (data.get("command") or "").strip() + # str() first: pasted JSON can legally carry a non-string here and the + # value never round-trips through the editor before a Test all run. + cmd = str(data.get("command") or "").strip() if not cmd: return { "outcome": "not_applicable", @@ -1627,7 +1646,8 @@ def spawn_test(data: dict, timeout: float = 3.0) -> dict: args_list = [resolved_cmd] + [str(a) for a in (data.get("args") or [])] merged_env = {**os.environ, "PATH": augmented_path()} - merged_env.update(data.get("env") or {}) + # Popen rejects non-string env values; pasted JSON may carry numbers. + merged_env.update({str(k): str(v) for k, v in (data.get("env") or {}).items()}) stderr_chunks: list[bytes] = [] diff --git a/tests/test_core.py b/tests/test_core.py index b07db51..dfca4e7 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -326,6 +326,42 @@ def test_spawn_test_large_stderr_does_not_deadlock(): assert len(r["stderr"]) <= c._STDERR_CAP +def test_spawn_test_non_string_env_value(): + # Pasted JSON can carry numeric env values ("env": {"PORT": 8080}) that never + # round-trip through the editor. Popen rejects non-str env; spawn_test must + # coerce instead of letting TypeError escape (which would leave the GUI's + # Test launch / Test all buttons stuck disabled). + r = c.spawn_test( + { + "command": sys.executable, + "args": ["-c", "import os; raise SystemExit(0 if os.environ['PORT'] == '8080' else 1)"], + "env": {"PORT": 8080}, + }, + timeout=2.0, + ) + assert r["outcome"] == "exited" + assert r["returncode"] == 0 + + +def test_spawn_test_non_string_command(): + # A non-string command must classify, not raise (str(123) resolves to nothing). + r = c.spawn_test({"command": 123}, timeout=0.3) + assert r["outcome"] == "not_found" + + +def test_spawn_test_internal_error_yields_result(monkeypatch): + # Any unexpected exception inside the spawn path must come back as a result + # dict (outcome "error"), never escape — the UI only re-enables its buttons + # when a result arrives. + def boom(*a, **k): + raise RuntimeError("simulated internal failure") + + monkeypatch.setattr(c.shutil, "which", boom) + r = c.spawn_test({"command": "python3"}, timeout=0.3) + assert r["outcome"] == "error" + assert "simulated internal failure" in r["detail"] + + # --------------------------------------------------------------------------- # # 8. Backup restore # --------------------------------------------------------------------------- #