Merge pull request 'fix: spawn_test never raises — str-coerce env/command, wrap unexpected errors (#34)' (#41) from fix/34-spawn-test-env into main
This commit was merged in pull request #41.
This commit is contained in:
+22
-2
@@ -1591,12 +1591,29 @@ def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
|||||||
"crashed" — exited with a non-zero code before timeout
|
"crashed" — exited with a non-zero code before timeout
|
||||||
"not_found" — command could not be resolved to an executable
|
"not_found" — command could not be resolved to an executable
|
||||||
"not_applicable" — remote server or no command; nothing to spawn
|
"not_applicable" — remote server or no command; nothing to spawn
|
||||||
|
"error" — unexpected internal failure while spawning/observing
|
||||||
returncode: int | None
|
returncode: int | None
|
||||||
stderr: str (first ~4 KB)
|
stderr: str (first ~4 KB)
|
||||||
detail: str
|
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.
|
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:
|
if "url" in data and "command" not in data:
|
||||||
return {
|
return {
|
||||||
"outcome": "not_applicable",
|
"outcome": "not_applicable",
|
||||||
@@ -1605,7 +1622,9 @@ def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
|||||||
"detail": "remote server",
|
"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:
|
if not cmd:
|
||||||
return {
|
return {
|
||||||
"outcome": "not_applicable",
|
"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 [])]
|
args_list = [resolved_cmd] + [str(a) for a in (data.get("args") or [])]
|
||||||
|
|
||||||
merged_env = {**os.environ, "PATH": augmented_path()}
|
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] = []
|
stderr_chunks: list[bytes] = []
|
||||||
|
|
||||||
|
|||||||
@@ -326,6 +326,42 @@ def test_spawn_test_large_stderr_does_not_deadlock():
|
|||||||
assert len(r["stderr"]) <= c._STDERR_CAP
|
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
|
# 8. Backup restore
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
Reference in New Issue
Block a user