Skip to content

Commit 5e5a931

Browse files
jbeckwith-oaicodex
andauthored
fix: normalize Windows subprocess exit statuses (#35)
Map unsigned and NTSTATUS-style Windows subprocess failures to exit code 1 before sys.exit(), and preserve POSIX signal semantics on Unix. Co-authored-by: Codex <noreply@openai.com>
1 parent f661495 commit 5e5a931

File tree

2 files changed

+67
-1
lines changed

2 files changed

+67
-1
lines changed

src/promptfoo/cli.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
_WRAPPER_ENV = "PROMPTFOO_PY_WRAPPER"
1717
_WINDOWS_SHELL_EXTENSIONS = (".bat", ".cmd")
18+
_WINDOWS_STATUS_SIGN_BIT = 1 << 31
1819
_VERSION_ENV = "PROMPTFOO_VERSION"
1920

2021

@@ -173,6 +174,18 @@ def _run_command(cmd: list[str], env: Optional[dict[str, str]] = None) -> subpro
173174
return subprocess.run(cmd, env=env)
174175

175176

177+
def _normalize_exit_code(returncode: int) -> int:
178+
"""Normalize subprocess return codes into portable process exit codes."""
179+
if os.name == "nt":
180+
if returncode < 0 or returncode & _WINDOWS_STATUS_SIGN_BIT:
181+
return 1
182+
return returncode
183+
184+
if returncode < 0:
185+
return 128 + min(abs(returncode), 127)
186+
return returncode
187+
188+
176189
def main() -> NoReturn:
177190
"""
178191
Main entry point for the promptfoo CLI wrapper.
@@ -207,7 +220,7 @@ def main() -> NoReturn:
207220
print("Or ensure Node.js is properly installed.", file=sys.stderr)
208221
sys.exit(1)
209222

210-
sys.exit(result.returncode)
223+
sys.exit(_normalize_exit_code(result.returncode))
211224
except KeyboardInterrupt:
212225
sys.exit(130)
213226

tests/test_cli.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
_WRAPPER_ENV,
2323
_find_external_promptfoo,
2424
_find_windows_promptfoo,
25+
_normalize_exit_code,
2526
_normalize_path,
2627
_requires_shell,
2728
_resolve_argv0,
@@ -358,6 +359,34 @@ def test_run_command_passes_environment(self, monkeypatch: pytest.MonkeyPatch) -
358359
assert call_args.kwargs.get("env") == env
359360

360361

362+
class TestExitCodeNormalization:
363+
"""Test subprocess exit code normalization."""
364+
365+
@pytest.mark.parametrize("returncode", [0, 1, 100, 255])
366+
def test_normalize_exit_code_preserves_standard_codes(
367+
self, returncode: int, monkeypatch: pytest.MonkeyPatch
368+
) -> None:
369+
"""Standard shell exit codes pass through unchanged."""
370+
monkeypatch.setattr(os, "name", "nt")
371+
372+
assert _normalize_exit_code(returncode) == returncode
373+
374+
@pytest.mark.parametrize("returncode", [4294967295, 3221226505, -1])
375+
def test_normalize_exit_code_maps_windows_error_statuses(
376+
self, returncode: int, monkeypatch: pytest.MonkeyPatch
377+
) -> None:
378+
"""Windows unsigned and NTSTATUS failure values map to exit code 1."""
379+
monkeypatch.setattr(os, "name", "nt")
380+
381+
assert _normalize_exit_code(returncode) == 1
382+
383+
def test_normalize_exit_code_maps_unix_signal_status(self, monkeypatch: pytest.MonkeyPatch) -> None:
384+
"""Unix signal-style negative return codes map to 128 + signal number."""
385+
monkeypatch.setattr(os, "name", "posix")
386+
387+
assert _normalize_exit_code(-15) == 143
388+
389+
361390
# =============================================================================
362391
# Integration Tests for main()
363392
# =============================================================================
@@ -533,6 +562,30 @@ def test_main_returns_subprocess_exit_code(self, monkeypatch: pytest.MonkeyPatch
533562

534563
assert exc_info.value.code == 42
535564

565+
@pytest.mark.parametrize("raw_returncode", [4294967295, 3221226505])
566+
def test_main_normalizes_windows_error_statuses(self, raw_returncode: int, monkeypatch: pytest.MonkeyPatch) -> None:
567+
"""Converts Windows-specific subprocess statuses into a stable exit code."""
568+
monkeypatch.setattr(os, "name", "nt")
569+
monkeypatch.setattr(sys, "argv", ["promptfoo", "eval", "-c", "missing.yaml"])
570+
monkeypatch.setattr(
571+
"shutil.which",
572+
lambda cmd, path=None: {
573+
"node": "C:\\Program Files\\nodejs\\node.exe",
574+
"npx": "C:\\Program Files\\nodejs\\npx.cmd",
575+
}.get(cmd),
576+
)
577+
monkeypatch.setattr("promptfoo.cli.record_wrapper_used", lambda mode: None)
578+
monkeypatch.setattr(
579+
subprocess,
580+
"run",
581+
MagicMock(return_value=subprocess.CompletedProcess([], raw_returncode)),
582+
)
583+
584+
with pytest.raises(SystemExit) as exc_info:
585+
main()
586+
587+
assert exc_info.value.code == 1
588+
536589

537590
# =============================================================================
538591
# Platform-Specific Tests

0 commit comments

Comments
 (0)