From 083dd4cd8a3336e5f813d4194ffabf96d357059a Mon Sep 17 00:00:00 2001 From: G4614 <92488762+G4614@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:35:01 +0800 Subject: [PATCH 01/13] fix(python): raise ExecError for command start failures --- sdks/python/boxlite/exec.py | 12 ++++++++++++ sdks/python/boxlite/simplebox.py | 17 +++++++++++++---- sdks/python/boxlite/sync_api/_simplebox.py | 16 ++++++++++++---- sdks/python/tests/test_simplebox.py | 11 +++++++++++ sdks/python/tests/test_sync_simplebox.py | 8 ++++++++ 5 files changed, 56 insertions(+), 8 deletions(-) diff --git a/sdks/python/boxlite/exec.py b/sdks/python/boxlite/exec.py index 576dcd4d6..fc8eff200 100644 --- a/sdks/python/boxlite/exec.py +++ b/sdks/python/boxlite/exec.py @@ -28,3 +28,15 @@ class ExecResult: stdout: str stderr: str error_message: str | None = None + + +def _looks_like_command_start_error(message: str) -> bool: + """Return true for errors caused by an invalid user command.""" + lowered = message.lower() + return ( + ("executable" in lowered and "not found" in lowered) + or "not found in $path" in lowered + or "not found in path" in lowered + or "does not have correct permissions" in lowered + or "permission denied" in lowered + ) diff --git a/sdks/python/boxlite/simplebox.py b/sdks/python/boxlite/simplebox.py index bc56cc638..4e7ff28b8 100644 --- a/sdks/python/boxlite/simplebox.py +++ b/sdks/python/boxlite/simplebox.py @@ -9,7 +9,8 @@ from enum import IntEnum from typing import Optional, TYPE_CHECKING -from .exec import ExecResult +from .errors import ExecError +from .exec import ExecResult, _looks_like_command_start_error if TYPE_CHECKING: from .boxlite import Boxlite @@ -266,10 +267,18 @@ async def exec( # Convert env dict to list of tuples if provided env_list = list(env.items()) if env else None + command_display = " ".join([cmd, *args]) + # Execute via Rust (returns PyExecution) - execution = await self._box.exec( - cmd, arg_list, env_list, user=user, timeout_secs=timeout, cwd=cwd - ) + try: + execution = await self._box.exec( + cmd, arg_list, env_list, user=user, timeout_secs=timeout, cwd=cwd + ) + except Exception as e: + message = str(e) + if _looks_like_command_start_error(message): + raise ExecError(command_display, 127, message) from e + raise # Get streams from Rust execution try: diff --git a/sdks/python/boxlite/sync_api/_simplebox.py b/sdks/python/boxlite/sync_api/_simplebox.py index 6406e81af..31ba6dd85 100644 --- a/sdks/python/boxlite/sync_api/_simplebox.py +++ b/sdks/python/boxlite/sync_api/_simplebox.py @@ -9,7 +9,8 @@ import logging from typing import TYPE_CHECKING, Dict, Optional -from ..exec import ExecResult +from ..errors import ExecError +from ..exec import ExecResult, _looks_like_command_start_error if TYPE_CHECKING: from ._boxlite import SyncBoxlite @@ -190,11 +191,18 @@ def exec( # Access the underlying async Box directly async_box = self._box._box + command_display = " ".join([cmd, *args]) async def _exec_and_collect(): - execution = await async_box.exec( - cmd, arg_list, env_list, user=user, timeout_secs=timeout, cwd=cwd - ) + try: + execution = await async_box.exec( + cmd, arg_list, env_list, user=user, timeout_secs=timeout, cwd=cwd + ) + except Exception as e: + message = str(e) + if _looks_like_command_start_error(message): + raise ExecError(command_display, 127, message) from e + raise stdout_lines = [] stderr_lines = [] diff --git a/sdks/python/tests/test_simplebox.py b/sdks/python/tests/test_simplebox.py index 3f6d9601c..744eeedad 100644 --- a/sdks/python/tests/test_simplebox.py +++ b/sdks/python/tests/test_simplebox.py @@ -16,3 +16,14 @@ async def test_simplebox_metrics(shared_runtime): metrics = await box.metrics() assert metrics is not None assert metrics.commands_executed_total >= 1 + + +async def test_simplebox_command_not_found_raises_exec_error(shared_runtime): + """Direct command start failures raise ExecError, not bare RuntimeError.""" + async with boxlite.SimpleBox( + image="alpine:latest", runtime=shared_runtime + ) as box: + with pytest.raises(boxlite.ExecError) as exc: + await box.exec("definitely-not-a-boxlite-command-xyz") + assert exc.value.exit_code == 127 + assert "not found" in exc.value.stderr.lower() diff --git a/sdks/python/tests/test_sync_simplebox.py b/sdks/python/tests/test_sync_simplebox.py index 928cfd031..fdcf24104 100644 --- a/sdks/python/tests/test_sync_simplebox.py +++ b/sdks/python/tests/test_sync_simplebox.py @@ -82,6 +82,14 @@ def test_metrics(self, shared_sync_runtime): assert metrics is not None assert metrics.commands_executed_total >= 1 + def test_command_not_found_raises_exec_error(self, shared_sync_runtime): + """Direct command start failures raise ExecError, not bare RuntimeError.""" + with SyncSimpleBox(image="alpine:latest", runtime=shared_sync_runtime) as box: + with pytest.raises(boxlite.ExecError) as exc: + box.exec("definitely-not-a-boxlite-command-xyz") + assert exc.value.exit_code == 127 + assert "not found" in exc.value.stderr.lower() + def test_custom_working_dir(self, shared_sync_runtime): """Can set custom working directory.""" with SyncSimpleBox( From dd50047c0c48e2a3d356ce5fa8e2f599957b5110 Mon Sep 17 00:00:00 2001 From: G4614 <92488762+G4614@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:07:16 +0800 Subject: [PATCH 02/13] fix(python): preserve command start error type --- sdks/python/boxlite/errors.py | 9 +++++++++ sdks/python/boxlite/exec.py | 14 ++------------ sdks/python/boxlite/simplebox.py | 11 ++++------- sdks/python/boxlite/sync_api/_simplebox.py | 11 ++++------- sdks/python/src/box_handle.rs | 4 ++-- sdks/python/src/lib.rs | 2 ++ sdks/python/src/util.rs | 12 +++++++++++- sdks/python/tests/test_simplebox.py | 4 +--- sdks/python/tests/test_sync_simplebox.py | 4 ++-- src/boxlite/src/portal/interfaces/exec.rs | 8 ++++---- 10 files changed, 41 insertions(+), 38 deletions(-) diff --git a/sdks/python/boxlite/errors.py b/sdks/python/boxlite/errors.py index 482c7301a..196d9cb62 100644 --- a/sdks/python/boxlite/errors.py +++ b/sdks/python/boxlite/errors.py @@ -6,6 +6,15 @@ __all__ = ["BoxliteError", "ExecError", "TimeoutError", "ParseError"] +try: + from .boxlite import CommandStartError as _CommandStartError +except ImportError: + + class _CommandStartError(RuntimeError): + """Fallback used when the native extension is unavailable.""" + + pass + class BoxliteError(Exception): """Base exception for all boxlite errors.""" diff --git a/sdks/python/boxlite/exec.py b/sdks/python/boxlite/exec.py index fc8eff200..94371b059 100644 --- a/sdks/python/boxlite/exec.py +++ b/sdks/python/boxlite/exec.py @@ -4,6 +4,8 @@ Provides Docker-like API for executing commands in boxes. """ +from __future__ import annotations + from dataclasses import dataclass __all__ = [ @@ -28,15 +30,3 @@ class ExecResult: stdout: str stderr: str error_message: str | None = None - - -def _looks_like_command_start_error(message: str) -> bool: - """Return true for errors caused by an invalid user command.""" - lowered = message.lower() - return ( - ("executable" in lowered and "not found" in lowered) - or "not found in $path" in lowered - or "not found in path" in lowered - or "does not have correct permissions" in lowered - or "permission denied" in lowered - ) diff --git a/sdks/python/boxlite/simplebox.py b/sdks/python/boxlite/simplebox.py index 4e7ff28b8..7ac6f4dab 100644 --- a/sdks/python/boxlite/simplebox.py +++ b/sdks/python/boxlite/simplebox.py @@ -9,8 +9,8 @@ from enum import IntEnum from typing import Optional, TYPE_CHECKING -from .errors import ExecError -from .exec import ExecResult, _looks_like_command_start_error +from .errors import ExecError, _CommandStartError +from .exec import ExecResult if TYPE_CHECKING: from .boxlite import Boxlite @@ -274,11 +274,8 @@ async def exec( execution = await self._box.exec( cmd, arg_list, env_list, user=user, timeout_secs=timeout, cwd=cwd ) - except Exception as e: - message = str(e) - if _looks_like_command_start_error(message): - raise ExecError(command_display, 127, message) from e - raise + except _CommandStartError as e: + raise ExecError(command_display, 127, str(e)) from e # Get streams from Rust execution try: diff --git a/sdks/python/boxlite/sync_api/_simplebox.py b/sdks/python/boxlite/sync_api/_simplebox.py index 31ba6dd85..9fb61cfd8 100644 --- a/sdks/python/boxlite/sync_api/_simplebox.py +++ b/sdks/python/boxlite/sync_api/_simplebox.py @@ -9,8 +9,8 @@ import logging from typing import TYPE_CHECKING, Dict, Optional -from ..errors import ExecError -from ..exec import ExecResult, _looks_like_command_start_error +from ..errors import ExecError, _CommandStartError +from ..exec import ExecResult if TYPE_CHECKING: from ._boxlite import SyncBoxlite @@ -198,11 +198,8 @@ async def _exec_and_collect(): execution = await async_box.exec( cmd, arg_list, env_list, user=user, timeout_secs=timeout, cwd=cwd ) - except Exception as e: - message = str(e) - if _looks_like_command_start_error(message): - raise ExecError(command_display, 127, message) from e - raise + except _CommandStartError as e: + raise ExecError(command_display, 127, str(e)) from e stdout_lines = [] stderr_lines = [] diff --git a/sdks/python/src/box_handle.rs b/sdks/python/src/box_handle.rs index 3a49adddf..7196e880e 100644 --- a/sdks/python/src/box_handle.rs +++ b/sdks/python/src/box_handle.rs @@ -6,7 +6,7 @@ use crate::metrics::PyBoxMetrics; use crate::network::PyNetworkHandle; use crate::snapshot_options::{PyCloneOptions, PyExportOptions}; use crate::snapshots::PySnapshotHandle; -use crate::util::map_err; +use crate::util::{map_err, map_exec_err}; use boxlite::{BoxCommand, CloneOptions, ExportOptions, LiteBox}; use pyo3::prelude::*; @@ -87,7 +87,7 @@ impl PyBox { cmd = cmd.working_dir(cwd); } - let execution = handle.exec(cmd).await.map_err(map_err)?; + let execution = handle.exec(cmd).await.map_err(map_exec_err)?; Ok(PyExecution { execution: Arc::new(execution), diff --git a/sdks/python/src/lib.rs b/sdks/python/src/lib.rs index 6ff730b17..cefc703be 100644 --- a/sdks/python/src/lib.rs +++ b/sdks/python/src/lib.rs @@ -28,6 +28,7 @@ use crate::options::{ use crate::runtime::PyBoxlite; use crate::snapshot_options::{PyCloneOptions, PyExportOptions, PySnapshotOptions}; use crate::snapshots::{PySnapshotHandle, PySnapshotInfo}; +use crate::util::CommandStartError; use crate::volumes::{PyVolumeHandle, PyVolumeInfo}; use pyo3::prelude::*; @@ -71,6 +72,7 @@ fn boxlite_python(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add("CommandStartError", m.py().get_type::())?; Ok(()) } diff --git a/sdks/python/src/util.rs b/sdks/python/src/util.rs index ea544c5b5..128532953 100644 --- a/sdks/python/src/util.rs +++ b/sdks/python/src/util.rs @@ -1,5 +1,15 @@ -use pyo3::{exceptions::PyRuntimeError, prelude::*}; +use boxlite::BoxliteError; +use pyo3::{create_exception, exceptions::PyRuntimeError, prelude::*}; + +create_exception!(boxlite_python, CommandStartError, PyRuntimeError); pub(crate) fn map_err(err: impl std::fmt::Display) -> PyErr { PyRuntimeError::new_err(err.to_string()) } + +pub(crate) fn map_exec_err(err: BoxliteError) -> PyErr { + match err { + BoxliteError::Execution(message) => CommandStartError::new_err(message), + other => map_err(other), + } +} diff --git a/sdks/python/tests/test_simplebox.py b/sdks/python/tests/test_simplebox.py index 744eeedad..91cdf565c 100644 --- a/sdks/python/tests/test_simplebox.py +++ b/sdks/python/tests/test_simplebox.py @@ -20,9 +20,7 @@ async def test_simplebox_metrics(shared_runtime): async def test_simplebox_command_not_found_raises_exec_error(shared_runtime): """Direct command start failures raise ExecError, not bare RuntimeError.""" - async with boxlite.SimpleBox( - image="alpine:latest", runtime=shared_runtime - ) as box: + async with boxlite.SimpleBox(image="alpine:latest", runtime=shared_runtime) as box: with pytest.raises(boxlite.ExecError) as exc: await box.exec("definitely-not-a-boxlite-command-xyz") assert exc.value.exit_code == 127 diff --git a/sdks/python/tests/test_sync_simplebox.py b/sdks/python/tests/test_sync_simplebox.py index fdcf24104..c72293fbb 100644 --- a/sdks/python/tests/test_sync_simplebox.py +++ b/sdks/python/tests/test_sync_simplebox.py @@ -11,7 +11,7 @@ # Try to import sync API - skip if greenlet not installed try: - from boxlite import SyncSimpleBox + from boxlite import ExecError, SyncSimpleBox SYNC_AVAILABLE = True except ImportError: @@ -85,7 +85,7 @@ def test_metrics(self, shared_sync_runtime): def test_command_not_found_raises_exec_error(self, shared_sync_runtime): """Direct command start failures raise ExecError, not bare RuntimeError.""" with SyncSimpleBox(image="alpine:latest", runtime=shared_sync_runtime) as box: - with pytest.raises(boxlite.ExecError) as exc: + with pytest.raises(ExecError) as exc: box.exec("definitely-not-a-boxlite-command-xyz") assert exc.value.exit_code == 127 assert "not found" in exc.value.stderr.lower() diff --git a/src/boxlite/src/portal/interfaces/exec.rs b/src/boxlite/src/portal/interfaces/exec.rs index e9c9e0e37..1f165b8a0 100644 --- a/src/boxlite/src/portal/interfaces/exec.rs +++ b/src/boxlite/src/portal/interfaces/exec.rs @@ -60,10 +60,10 @@ impl ExecutionInterface { // Start execution let exec_response = self.client.exec(request).await?.into_inner(); if let Some(err) = exec_response.error { - return Err(BoxliteError::Internal(format!( - "{}: {}", - err.reason, err.detail - ))); + return Err(match err.reason.as_str() { + "spawn_failed" => BoxliteError::Execution(err.detail), + _ => BoxliteError::Internal(format!("{}: {}", err.reason, err.detail)), + }); } let execution_id = exec_response.execution_id.clone(); From d978143ed5db6651a40b2b3bc317af1fd85e8f06 Mon Sep 17 00:00:00 2001 From: G4614 <92488762+G4614@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:54:43 +0800 Subject: [PATCH 03/13] fix: preserve exec errors through REST --- .../pkg/api/controllers/boxlite_exec.go | 32 +++++++++++-------- .../pkg/api/controllers/boxlite_exec_test.go | 22 +++++++++++++ sdks/go/boxlite_test.go | 13 ++++++++ sdks/go/errors.go | 6 ++++ 4 files changed, 59 insertions(+), 14 deletions(-) diff --git a/apps/runner/pkg/api/controllers/boxlite_exec.go b/apps/runner/pkg/api/controllers/boxlite_exec.go index 28907a74f..3b85cf222 100644 --- a/apps/runner/pkg/api/controllers/boxlite_exec.go +++ b/apps/runner/pkg/api/controllers/boxlite_exec.go @@ -75,26 +75,30 @@ func BoxliteExec(ctx *gin.Context) { execId, err := execManager.Start(ctx.Request.Context(), bx, boxId, startOpts) if err != nil { - // Classify so a stopped / wrong-state box surfaces as 4xx, not - // 500. Without this the box-stopped case leaks - // internal error: HTTP 500 Internal Server Error: - // {"error":"exec failed: failed to start execution: boxlite: stopped: - // Handle invalidated after stop(). ... (code=11)"} - // and the SDK's 'all 5xx is bug' guard fires. The Go SDK already - // gives us a typed bool for the two states that aren't 500 from - // the runner's perspective (the box exists but isn't accepting - // execs right now). - if sdkboxlite.IsStopped(err) || sdkboxlite.IsInvalidState(err) { - ctx.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("exec failed: %s", err)}) - return - } - ctx.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("exec failed: %s", err)}) + writeExecStartError(ctx, err) return } ctx.JSON(http.StatusCreated, ExecResponse{ExecutionID: execId}) } +func writeExecStartError(ctx *gin.Context, err error) { + if sdkboxlite.IsExecution(err) { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{ + "message": fmt.Sprintf("exec failed: %s", err), + "code": "execution_failed", + }) + return + } + + // A stopped or wrong-state box exists but is not accepting execs. + if sdkboxlite.IsStopped(err) || sdkboxlite.IsInvalidState(err) { + ctx.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("exec failed: %s", err)}) + return + } + ctx.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("exec failed: %s", err)}) +} + // allowedExecSignals is the whitelist of POSIX signal numbers callers may // deliver via the signal endpoint. SIGKILL (9) is excluded — clients should // use DELETE /executions/{id} (BoxliteExecKill) for that, which also evicts diff --git a/apps/runner/pkg/api/controllers/boxlite_exec_test.go b/apps/runner/pkg/api/controllers/boxlite_exec_test.go index 5227857b8..f0a9e133d 100644 --- a/apps/runner/pkg/api/controllers/boxlite_exec_test.go +++ b/apps/runner/pkg/api/controllers/boxlite_exec_test.go @@ -11,10 +11,32 @@ import ( "sync/atomic" "testing" + sdkboxlite "github.com/boxlite-ai/boxlite/sdks/go" "github.com/boxlite-ai/runner/pkg/boxlite" "github.com/gin-gonic/gin" ) +func TestWriteExecStartErrorReturnsStructuredExecutionError(t *testing.T) { + err := &sdkboxlite.Error{Code: sdkboxlite.ErrExecution, Message: "spawn failed"} + w := runHandler(http.MethodPost, "/exec", "/exec", nil, func(ctx *gin.Context) { + writeExecStartError(ctx, err) + }) + + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected 422, got %d body=%s", w.Code, w.Body.String()) + } + var body struct { + Message string `json:"message"` + Code string `json:"code"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal failed: %v body=%s", err, w.Body.String()) + } + if body.Code != "execution_failed" || !strings.Contains(body.Message, "spawn failed") { + t.Fatalf("unexpected response: %+v", body) + } +} + // signalCapturingExec is a minimal boxlite.ExecHandle stub that records // every Signal call so tests can assert the controller plumbed the // requested value through ExecManager rather than coercing it. diff --git a/sdks/go/boxlite_test.go b/sdks/go/boxlite_test.go index 77001fe9d..41d0f2119 100644 --- a/sdks/go/boxlite_test.go +++ b/sdks/go/boxlite_test.go @@ -59,6 +59,19 @@ func TestIsInvalidState(t *testing.T) { } } +func TestIsExecution(t *testing.T) { + err := &Error{Code: ErrExecution, Message: "spawn failed"} + if !IsExecution(err) { + t.Error("expected IsExecution to return true") + } + if IsExecution(errors.New("other")) { + t.Error("expected IsExecution to return false for non-Error") + } + if IsExecution(&Error{Code: ErrInternal, Message: "internal"}) { + t.Error("expected IsExecution to return false for different code") + } +} + func TestIsStopped(t *testing.T) { err := &Error{Code: ErrStopped, Message: "runtime shut down"} if !IsStopped(err) { diff --git a/sdks/go/errors.go b/sdks/go/errors.go index f10163786..85c8ff1a5 100644 --- a/sdks/go/errors.go +++ b/sdks/go/errors.go @@ -69,6 +69,12 @@ func IsInvalidState(err error) bool { return errors.As(err, &e) && e.Code == ErrInvalidState } +// IsExecution reports whether err indicates a command execution failure. +func IsExecution(err error) bool { + var e *Error + return errors.As(err, &e) && e.Code == ErrExecution +} + // IsStopped reports whether err indicates a stopped or shut down resource. func IsStopped(err error) bool { var e *Error From c48d44d04c8f81060c870ec00be24d9c5adb0893 Mon Sep 17 00:00:00 2001 From: G4614 <92488762+G4614@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:14:29 +0800 Subject: [PATCH 04/13] fix(python): distinguish command start exit codes --- sdks/python/boxlite/errors.py | 12 +++++------- sdks/python/boxlite/simplebox.py | 16 ++++++++++++++-- sdks/python/boxlite/sync_api/_simplebox.py | 16 ++++++++++++++-- sdks/python/tests/test_errors.py | 21 ++++++++++++++++++++- sdks/python/tests/test_simplebox.py | 9 +++++++++ sdks/python/tests/test_sync_simplebox.py | 11 +++++++++++ 6 files changed, 73 insertions(+), 12 deletions(-) diff --git a/sdks/python/boxlite/errors.py b/sdks/python/boxlite/errors.py index 196d9cb62..333e13a09 100644 --- a/sdks/python/boxlite/errors.py +++ b/sdks/python/boxlite/errors.py @@ -6,14 +6,12 @@ __all__ = ["BoxliteError", "ExecError", "TimeoutError", "ParseError"] -try: - from .boxlite import CommandStartError as _CommandStartError -except ImportError: - class _CommandStartError(RuntimeError): - """Fallback used when the native extension is unavailable.""" - - pass +def _command_start_exit_code(message: str) -> int: + """Translate a pre-exec spawn failure to the conventional shell code.""" + detail = message.casefold() + not_found_markers = ("not found in $path", "no such file or directory", "os error 2") + return 127 if any(marker in detail for marker in not_found_markers) else 126 class BoxliteError(Exception): diff --git a/sdks/python/boxlite/simplebox.py b/sdks/python/boxlite/simplebox.py index 7ac6f4dab..b54bba91f 100644 --- a/sdks/python/boxlite/simplebox.py +++ b/sdks/python/boxlite/simplebox.py @@ -9,9 +9,18 @@ from enum import IntEnum from typing import Optional, TYPE_CHECKING -from .errors import ExecError, _CommandStartError +from .errors import ExecError, _command_start_exit_code from .exec import ExecResult +try: + from .boxlite import CommandStartError as _CommandStartError +except ImportError: + + class _CommandStartError(RuntimeError): + """Fallback for native extensions built before CommandStartError.""" + + pass + if TYPE_CHECKING: from .boxlite import Boxlite @@ -275,7 +284,10 @@ async def exec( cmd, arg_list, env_list, user=user, timeout_secs=timeout, cwd=cwd ) except _CommandStartError as e: - raise ExecError(command_display, 127, str(e)) from e + message = str(e) + raise ExecError( + command_display, _command_start_exit_code(message), message + ) from e # Get streams from Rust execution try: diff --git a/sdks/python/boxlite/sync_api/_simplebox.py b/sdks/python/boxlite/sync_api/_simplebox.py index 9fb61cfd8..bbd239423 100644 --- a/sdks/python/boxlite/sync_api/_simplebox.py +++ b/sdks/python/boxlite/sync_api/_simplebox.py @@ -9,9 +9,18 @@ import logging from typing import TYPE_CHECKING, Dict, Optional -from ..errors import ExecError, _CommandStartError +from ..errors import ExecError, _command_start_exit_code from ..exec import ExecResult +try: + from ..boxlite import CommandStartError as _CommandStartError +except ImportError: + + class _CommandStartError(RuntimeError): + """Fallback for native extensions built before CommandStartError.""" + + pass + if TYPE_CHECKING: from ._boxlite import SyncBoxlite from ._box import SyncBox @@ -199,7 +208,10 @@ async def _exec_and_collect(): cmd, arg_list, env_list, user=user, timeout_secs=timeout, cwd=cwd ) except _CommandStartError as e: - raise ExecError(command_display, 127, str(e)) from e + message = str(e) + raise ExecError( + command_display, _command_start_exit_code(message), message + ) from e stdout_lines = [] stderr_lines = [] diff --git a/sdks/python/tests/test_errors.py b/sdks/python/tests/test_errors.py index fc7703c3d..b7e6892ae 100644 --- a/sdks/python/tests/test_errors.py +++ b/sdks/python/tests/test_errors.py @@ -5,7 +5,26 @@ """ import pytest -from boxlite.errors import BoxliteError, ExecError, TimeoutError, ParseError +from boxlite.errors import ( + BoxliteError, + ExecError, + ParseError, + TimeoutError, + _command_start_exit_code, +) + + +@pytest.mark.parametrize( + ("message", "expected"), + [ + ("executable '/missing' not found in $PATH", 127), + ("No such file or directory (os error 2)", 127), + ("Permission denied (os error 13)", 126), + ("Exec format error (os error 8)", 126), + ], +) +def test_command_start_exit_code(message, expected): + assert _command_start_exit_code(message) == expected class TestBoxliteError: diff --git a/sdks/python/tests/test_simplebox.py b/sdks/python/tests/test_simplebox.py index 91cdf565c..8c9e65e34 100644 --- a/sdks/python/tests/test_simplebox.py +++ b/sdks/python/tests/test_simplebox.py @@ -25,3 +25,12 @@ async def test_simplebox_command_not_found_raises_exec_error(shared_runtime): await box.exec("definitely-not-a-boxlite-command-xyz") assert exc.value.exit_code == 127 assert "not found" in exc.value.stderr.lower() + + +async def test_simplebox_unexecutable_command_raises_exit_code_126(shared_runtime): + async with boxlite.SimpleBox(image="alpine:latest", runtime=shared_runtime) as box: + await box.exec("sh", "-c", "printf '#!/bin/sh\\n' > /tmp/noexec && chmod 644 /tmp/noexec") + with pytest.raises(boxlite.ExecError) as exc: + await box.exec("/tmp/noexec") + assert exc.value.exit_code == 126 + assert "permission denied" in exc.value.stderr.lower() diff --git a/sdks/python/tests/test_sync_simplebox.py b/sdks/python/tests/test_sync_simplebox.py index c72293fbb..c645d4bb3 100644 --- a/sdks/python/tests/test_sync_simplebox.py +++ b/sdks/python/tests/test_sync_simplebox.py @@ -88,6 +88,17 @@ def test_command_not_found_raises_exec_error(self, shared_sync_runtime): with pytest.raises(ExecError) as exc: box.exec("definitely-not-a-boxlite-command-xyz") assert exc.value.exit_code == 127 + + def test_unexecutable_command_raises_exit_code_126(self, shared_sync_runtime): + with SyncSimpleBox(image="alpine:latest", runtime=shared_sync_runtime) as box: + box.exec( + "sh", + "-c", + "printf '#!/bin/sh\\n' > /tmp/noexec && chmod 644 /tmp/noexec", + ) + with pytest.raises(ExecError) as exc: + box.exec("/tmp/noexec") + assert exc.value.exit_code == 126 assert "not found" in exc.value.stderr.lower() def test_custom_working_dir(self, shared_sync_runtime): From d841f03130cf762ac163670818493a3a6171a334 Mon Sep 17 00:00:00 2001 From: G4614 <92488762+G4614@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:22:06 +0800 Subject: [PATCH 05/13] test(python): assert permission-denied spawn detail --- sdks/python/tests/test_sync_simplebox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdks/python/tests/test_sync_simplebox.py b/sdks/python/tests/test_sync_simplebox.py index c645d4bb3..6f7eaaa78 100644 --- a/sdks/python/tests/test_sync_simplebox.py +++ b/sdks/python/tests/test_sync_simplebox.py @@ -99,7 +99,7 @@ def test_unexecutable_command_raises_exit_code_126(self, shared_sync_runtime): with pytest.raises(ExecError) as exc: box.exec("/tmp/noexec") assert exc.value.exit_code == 126 - assert "not found" in exc.value.stderr.lower() + assert "permission" in exc.value.stderr.lower() def test_custom_working_dir(self, shared_sync_runtime): """Can set custom working directory.""" From e974414382c8db1ec71d854c2e55faea881c857c Mon Sep 17 00:00:00 2001 From: G4614 <92488762+G4614@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:26:27 +0800 Subject: [PATCH 06/13] style(python): format exec error changes --- sdks/python/boxlite/errors.py | 6 +++++- sdks/python/boxlite/simplebox.py | 1 + sdks/python/boxlite/sync_api/_simplebox.py | 1 + sdks/python/tests/test_simplebox.py | 4 +++- 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/sdks/python/boxlite/errors.py b/sdks/python/boxlite/errors.py index 333e13a09..e0c47dd25 100644 --- a/sdks/python/boxlite/errors.py +++ b/sdks/python/boxlite/errors.py @@ -10,7 +10,11 @@ def _command_start_exit_code(message: str) -> int: """Translate a pre-exec spawn failure to the conventional shell code.""" detail = message.casefold() - not_found_markers = ("not found in $path", "no such file or directory", "os error 2") + not_found_markers = ( + "not found in $path", + "no such file or directory", + "os error 2", + ) return 127 if any(marker in detail for marker in not_found_markers) else 126 diff --git a/sdks/python/boxlite/simplebox.py b/sdks/python/boxlite/simplebox.py index b54bba91f..fe8a85b64 100644 --- a/sdks/python/boxlite/simplebox.py +++ b/sdks/python/boxlite/simplebox.py @@ -21,6 +21,7 @@ class _CommandStartError(RuntimeError): pass + if TYPE_CHECKING: from .boxlite import Boxlite diff --git a/sdks/python/boxlite/sync_api/_simplebox.py b/sdks/python/boxlite/sync_api/_simplebox.py index bbd239423..78f851d8f 100644 --- a/sdks/python/boxlite/sync_api/_simplebox.py +++ b/sdks/python/boxlite/sync_api/_simplebox.py @@ -21,6 +21,7 @@ class _CommandStartError(RuntimeError): pass + if TYPE_CHECKING: from ._boxlite import SyncBoxlite from ._box import SyncBox diff --git a/sdks/python/tests/test_simplebox.py b/sdks/python/tests/test_simplebox.py index 8c9e65e34..4ce3a8639 100644 --- a/sdks/python/tests/test_simplebox.py +++ b/sdks/python/tests/test_simplebox.py @@ -29,7 +29,9 @@ async def test_simplebox_command_not_found_raises_exec_error(shared_runtime): async def test_simplebox_unexecutable_command_raises_exit_code_126(shared_runtime): async with boxlite.SimpleBox(image="alpine:latest", runtime=shared_runtime) as box: - await box.exec("sh", "-c", "printf '#!/bin/sh\\n' > /tmp/noexec && chmod 644 /tmp/noexec") + await box.exec( + "sh", "-c", "printf '#!/bin/sh\\n' > /tmp/noexec && chmod 644 /tmp/noexec" + ) with pytest.raises(boxlite.ExecError) as exc: await box.exec("/tmp/noexec") assert exc.value.exit_code == 126 From b09fa9bb60ae84588df620813746e771fddbbc22 Mon Sep 17 00:00:00 2001 From: G4614 <92488762+G4614@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:46:00 +0800 Subject: [PATCH 07/13] test(e2e): enforce typed exec start failures --- .../test/e2e/cases/test_error_code_mapping.py | 30 ++++++++----------- .../test/e2e/cases/test_exec_comprehensive.py | 30 +++++-------------- 2 files changed, 20 insertions(+), 40 deletions(-) diff --git a/scripts/test/e2e/cases/test_error_code_mapping.py b/scripts/test/e2e/cases/test_error_code_mapping.py index 07a7f5a83..6e5e90d5c 100644 --- a/scripts/test/e2e/cases/test_error_code_mapping.py +++ b/scripts/test/e2e/cases/test_error_code_mapping.py @@ -180,30 +180,24 @@ async def test_image_pull_failed_returns_422(rt): ), f"422 body does not explain the cause: {body_str}" -@pytest.mark.xfail( - strict=True, - reason=( - "Production bug: exec'ing a non-existent binary surfaces " - "'boxlite: internal error: spawn_failed' (code=1, ErrInternal) → HTTP " - "500 instead of ExecutionError (code=10) → HTTP 422 per the canonical " - "table at src/shared/src/errors.rs:198-280. The Rust spawn path " - "(boxlite-shim → exec process build) wraps the executable-not-found " - "case as ErrInternal/SpawnFailed rather than ErrExecution, so the " - "classifyExecError fix in this PR can't route it correctly." - ), -) @pytest.mark.asyncio async def test_execution_invalid_command_returns_422(rt, image): """Exec'ing a missing binary inside a real box should surface ExecutionError → 422 (not 500).""" box = await rt.create(boxlite.BoxOptions(image=image, auto_remove=True)) try: - with pytest.raises(Exception) as exc_info: - ex = await box.exec("/nonexistent/binary", [], None) - await ex.wait() - msg = str(exc_info.value).lower() - assert "500" not in msg and "internal" not in msg, ( - f"exec missing binary leaked a 5xx: {exc_info.value!r}" + ctx = auth_context() + status, body = _api_call( + "POST", + ctx.v1(f"boxes/{box.id}/exec"), + {"command": "/nonexistent/binary", "args": []}, + ) + _assert_http_code( + status, + body, + expected_status=422, + expected_code_substr="execution_failed", + msg="POST /boxes/{id}/exec with a missing binary", ) finally: await rt.remove(box.id, force=True) diff --git a/scripts/test/e2e/cases/test_exec_comprehensive.py b/scripts/test/e2e/cases/test_exec_comprehensive.py index 7775f3a70..711d75766 100644 --- a/scripts/test/e2e/cases/test_exec_comprehensive.py +++ b/scripts/test/e2e/cases/test_exec_comprehensive.py @@ -21,6 +21,7 @@ import boxlite import pytest +from boxlite.boxlite import CommandStartError from conftest import drain @@ -252,16 +253,9 @@ async def test_env_does_not_leak_across_execs(box): @pytest.mark.asyncio async def test_exec_cwd_nonexistent_returns_error(box): - """Exec with a non-existent cwd should fail, not silently fall back. - The runner may raise an exception (500 spawn_failed) or return a - non-zero exit code — either is acceptable.""" - try: - ex = await box.exec("pwd", [], cwd="/nonexistent/path/xyz") - await drain(ex) - rc = await asyncio.wait_for(ex.wait(), timeout=30) - assert rc.exit_code != 0, "exec with nonexistent cwd should fail" - except Exception: - pass # spawn_failed exception is also correct behaviour + """Exec with a non-existent cwd should be a typed start failure.""" + with pytest.raises(CommandStartError): + await box.exec("pwd", [], cwd="/nonexistent/path/xyz") @pytest.mark.asyncio @@ -281,18 +275,10 @@ async def test_exec_cwd_with_spaces(box): @pytest.mark.asyncio async def test_nonexistent_command_returns_error(box): - """Running a binary that doesn't exist should fail — either a non-zero - exit code or a spawn_failed exception from the runner.""" - try: - ex = await box.exec("this_binary_does_not_exist_xyz", []) - await drain(ex) - rc = await asyncio.wait_for(ex.wait(), timeout=30) - assert rc.exit_code != 0, "nonexistent command should fail" - except Exception as e: - # spawn_failed / "not found in $PATH" is correct behaviour - assert "not found" in str(e).lower() or "spawn_failed" in str(e), ( - f"unexpected error for missing binary: {e}" - ) + """A missing binary should be reported as a typed start failure.""" + with pytest.raises(CommandStartError) as exc_info: + await box.exec("this_binary_does_not_exist_xyz", []) + assert "not found" in str(exc_info.value).lower() # ── streaming output timing ─────────────────────────────────────── From 47e21415664bd99476e633eaa6f79fd3a733b07b Mon Sep 17 00:00:00 2001 From: G4614 <92488762+G4614@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:03:53 +0800 Subject: [PATCH 08/13] test(python): accept guest command lookup errors --- sdks/python/tests/test_simplebox.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sdks/python/tests/test_simplebox.py b/sdks/python/tests/test_simplebox.py index 4ce3a8639..f6d11c9e1 100644 --- a/sdks/python/tests/test_simplebox.py +++ b/sdks/python/tests/test_simplebox.py @@ -24,7 +24,8 @@ async def test_simplebox_command_not_found_raises_exec_error(shared_runtime): with pytest.raises(boxlite.ExecError) as exc: await box.exec("definitely-not-a-boxlite-command-xyz") assert exc.value.exit_code == 127 - assert "not found" in exc.value.stderr.lower() + stderr = exc.value.stderr.lower() + assert "not found" in stderr or "no such file or directory" in stderr async def test_simplebox_unexecutable_command_raises_exit_code_126(shared_runtime): From c2605ec1554de19fffb82419c91e0894370c7859 Mon Sep 17 00:00:00 2001 From: G4614 <92488762+G4614@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:20:34 +0800 Subject: [PATCH 09/13] refactor(runner): inline exec start error mapping --- .../pkg/api/controllers/boxlite_exec.go | 31 ++++++++----------- .../pkg/api/controllers/boxlite_exec_test.go | 22 ------------- 2 files changed, 13 insertions(+), 40 deletions(-) diff --git a/apps/runner/pkg/api/controllers/boxlite_exec.go b/apps/runner/pkg/api/controllers/boxlite_exec.go index 3b85cf222..d0c0e3cbd 100644 --- a/apps/runner/pkg/api/controllers/boxlite_exec.go +++ b/apps/runner/pkg/api/controllers/boxlite_exec.go @@ -75,30 +75,25 @@ func BoxliteExec(ctx *gin.Context) { execId, err := execManager.Start(ctx.Request.Context(), bx, boxId, startOpts) if err != nil { - writeExecStartError(ctx, err) + if sdkboxlite.IsExecution(err) { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{ + "message": fmt.Sprintf("exec failed: %s", err), + "code": "execution_failed", + }) + return + } + // A stopped or wrong-state box exists but is not accepting execs. + if sdkboxlite.IsStopped(err) || sdkboxlite.IsInvalidState(err) { + ctx.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("exec failed: %s", err)}) + return + } + ctx.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("exec failed: %s", err)}) return } ctx.JSON(http.StatusCreated, ExecResponse{ExecutionID: execId}) } -func writeExecStartError(ctx *gin.Context, err error) { - if sdkboxlite.IsExecution(err) { - ctx.JSON(http.StatusUnprocessableEntity, gin.H{ - "message": fmt.Sprintf("exec failed: %s", err), - "code": "execution_failed", - }) - return - } - - // A stopped or wrong-state box exists but is not accepting execs. - if sdkboxlite.IsStopped(err) || sdkboxlite.IsInvalidState(err) { - ctx.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("exec failed: %s", err)}) - return - } - ctx.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("exec failed: %s", err)}) -} - // allowedExecSignals is the whitelist of POSIX signal numbers callers may // deliver via the signal endpoint. SIGKILL (9) is excluded — clients should // use DELETE /executions/{id} (BoxliteExecKill) for that, which also evicts diff --git a/apps/runner/pkg/api/controllers/boxlite_exec_test.go b/apps/runner/pkg/api/controllers/boxlite_exec_test.go index f0a9e133d..5227857b8 100644 --- a/apps/runner/pkg/api/controllers/boxlite_exec_test.go +++ b/apps/runner/pkg/api/controllers/boxlite_exec_test.go @@ -11,32 +11,10 @@ import ( "sync/atomic" "testing" - sdkboxlite "github.com/boxlite-ai/boxlite/sdks/go" "github.com/boxlite-ai/runner/pkg/boxlite" "github.com/gin-gonic/gin" ) -func TestWriteExecStartErrorReturnsStructuredExecutionError(t *testing.T) { - err := &sdkboxlite.Error{Code: sdkboxlite.ErrExecution, Message: "spawn failed"} - w := runHandler(http.MethodPost, "/exec", "/exec", nil, func(ctx *gin.Context) { - writeExecStartError(ctx, err) - }) - - if w.Code != http.StatusUnprocessableEntity { - t.Fatalf("expected 422, got %d body=%s", w.Code, w.Body.String()) - } - var body struct { - Message string `json:"message"` - Code string `json:"code"` - } - if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { - t.Fatalf("unmarshal failed: %v body=%s", err, w.Body.String()) - } - if body.Code != "execution_failed" || !strings.Contains(body.Message, "spawn failed") { - t.Fatalf("unexpected response: %+v", body) - } -} - // signalCapturingExec is a minimal boxlite.ExecHandle stub that records // every Signal call so tests can assert the controller plumbed the // requested value through ExecManager rather than coercing it. From 04d4956e957a576a0687125de432caf7d84d9c54 Mon Sep 17 00:00:00 2001 From: G4614 <92488762+G4614@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:31:51 +0800 Subject: [PATCH 10/13] refactor(python): keep exec start error internal --- scripts/test/e2e/cases/test_exec_comprehensive.py | 6 +++--- sdks/python/boxlite/simplebox.py | 8 ++++---- sdks/python/boxlite/sync_api/_simplebox.py | 8 ++++---- sdks/python/src/lib.rs | 4 ++-- sdks/python/src/util.rs | 4 ++-- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/scripts/test/e2e/cases/test_exec_comprehensive.py b/scripts/test/e2e/cases/test_exec_comprehensive.py index 711d75766..c6d296f18 100644 --- a/scripts/test/e2e/cases/test_exec_comprehensive.py +++ b/scripts/test/e2e/cases/test_exec_comprehensive.py @@ -21,7 +21,7 @@ import boxlite import pytest -from boxlite.boxlite import CommandStartError +from boxlite.boxlite import _ExecStartError from conftest import drain @@ -254,7 +254,7 @@ async def test_env_does_not_leak_across_execs(box): @pytest.mark.asyncio async def test_exec_cwd_nonexistent_returns_error(box): """Exec with a non-existent cwd should be a typed start failure.""" - with pytest.raises(CommandStartError): + with pytest.raises(_ExecStartError): await box.exec("pwd", [], cwd="/nonexistent/path/xyz") @@ -276,7 +276,7 @@ async def test_exec_cwd_with_spaces(box): @pytest.mark.asyncio async def test_nonexistent_command_returns_error(box): """A missing binary should be reported as a typed start failure.""" - with pytest.raises(CommandStartError) as exc_info: + with pytest.raises(_ExecStartError) as exc_info: await box.exec("this_binary_does_not_exist_xyz", []) assert "not found" in str(exc_info.value).lower() diff --git a/sdks/python/boxlite/simplebox.py b/sdks/python/boxlite/simplebox.py index fe8a85b64..db524c4fa 100644 --- a/sdks/python/boxlite/simplebox.py +++ b/sdks/python/boxlite/simplebox.py @@ -13,11 +13,11 @@ from .exec import ExecResult try: - from .boxlite import CommandStartError as _CommandStartError + from .boxlite import _ExecStartError except ImportError: - class _CommandStartError(RuntimeError): - """Fallback for native extensions built before CommandStartError.""" + class _ExecStartError(RuntimeError): + """Fallback for native extensions built before _ExecStartError.""" pass @@ -284,7 +284,7 @@ async def exec( execution = await self._box.exec( cmd, arg_list, env_list, user=user, timeout_secs=timeout, cwd=cwd ) - except _CommandStartError as e: + except _ExecStartError as e: message = str(e) raise ExecError( command_display, _command_start_exit_code(message), message diff --git a/sdks/python/boxlite/sync_api/_simplebox.py b/sdks/python/boxlite/sync_api/_simplebox.py index 78f851d8f..678e8bb2d 100644 --- a/sdks/python/boxlite/sync_api/_simplebox.py +++ b/sdks/python/boxlite/sync_api/_simplebox.py @@ -13,11 +13,11 @@ from ..exec import ExecResult try: - from ..boxlite import CommandStartError as _CommandStartError + from ..boxlite import _ExecStartError except ImportError: - class _CommandStartError(RuntimeError): - """Fallback for native extensions built before CommandStartError.""" + class _ExecStartError(RuntimeError): + """Fallback for native extensions built before _ExecStartError.""" pass @@ -208,7 +208,7 @@ async def _exec_and_collect(): execution = await async_box.exec( cmd, arg_list, env_list, user=user, timeout_secs=timeout, cwd=cwd ) - except _CommandStartError as e: + except _ExecStartError as e: message = str(e) raise ExecError( command_display, _command_start_exit_code(message), message diff --git a/sdks/python/src/lib.rs b/sdks/python/src/lib.rs index cefc703be..9acd6d5ee 100644 --- a/sdks/python/src/lib.rs +++ b/sdks/python/src/lib.rs @@ -28,7 +28,7 @@ use crate::options::{ use crate::runtime::PyBoxlite; use crate::snapshot_options::{PyCloneOptions, PyExportOptions, PySnapshotOptions}; use crate::snapshots::{PySnapshotHandle, PySnapshotInfo}; -use crate::util::CommandStartError; +use crate::util::_ExecStartError; use crate::volumes::{PyVolumeHandle, PyVolumeInfo}; use pyo3::prelude::*; @@ -72,7 +72,7 @@ fn boxlite_python(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; - m.add("CommandStartError", m.py().get_type::())?; + m.add("_ExecStartError", m.py().get_type::<_ExecStartError>())?; Ok(()) } diff --git a/sdks/python/src/util.rs b/sdks/python/src/util.rs index 128532953..3b8d37584 100644 --- a/sdks/python/src/util.rs +++ b/sdks/python/src/util.rs @@ -1,7 +1,7 @@ use boxlite::BoxliteError; use pyo3::{create_exception, exceptions::PyRuntimeError, prelude::*}; -create_exception!(boxlite_python, CommandStartError, PyRuntimeError); +create_exception!(boxlite_python, _ExecStartError, PyRuntimeError); pub(crate) fn map_err(err: impl std::fmt::Display) -> PyErr { PyRuntimeError::new_err(err.to_string()) @@ -9,7 +9,7 @@ pub(crate) fn map_err(err: impl std::fmt::Display) -> PyErr { pub(crate) fn map_exec_err(err: BoxliteError) -> PyErr { match err { - BoxliteError::Execution(message) => CommandStartError::new_err(message), + BoxliteError::Execution(message) => _ExecStartError::new_err(message), other => map_err(other), } } From 9360072d0fdb12acb1959dbee28c3c56bb466239 Mon Sep 17 00:00:00 2001 From: G4614 <92488762+G4614@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:56:08 +0800 Subject: [PATCH 11/13] fix(python): keep exec start handling in simplebox --- .../pkg/api/controllers/boxlite_exec.go | 17 ++++++----- .../test/e2e/cases/test_error_code_mapping.py | 30 +++++++++++-------- .../test/e2e/cases/test_exec_comprehensive.py | 30 ++++++++++++++----- sdks/go/boxlite_test.go | 13 -------- sdks/go/errors.go | 6 ---- sdks/python/boxlite/errors.py | 11 ++----- sdks/python/boxlite/exec.py | 2 -- sdks/python/boxlite/simplebox.py | 19 ++++-------- sdks/python/boxlite/sync_api/_simplebox.py | 19 ++++-------- sdks/python/src/box_handle.rs | 4 +-- sdks/python/src/lib.rs | 2 -- sdks/python/src/util.rs | 12 +------- sdks/python/tests/test_errors.py | 17 ++++++----- sdks/python/tests/test_simplebox.py | 4 +-- sdks/python/tests/test_sync_simplebox.py | 2 +- src/boxlite/src/portal/interfaces/exec.rs | 8 ++--- 16 files changed, 82 insertions(+), 114 deletions(-) diff --git a/apps/runner/pkg/api/controllers/boxlite_exec.go b/apps/runner/pkg/api/controllers/boxlite_exec.go index d0c0e3cbd..28907a74f 100644 --- a/apps/runner/pkg/api/controllers/boxlite_exec.go +++ b/apps/runner/pkg/api/controllers/boxlite_exec.go @@ -75,14 +75,15 @@ func BoxliteExec(ctx *gin.Context) { execId, err := execManager.Start(ctx.Request.Context(), bx, boxId, startOpts) if err != nil { - if sdkboxlite.IsExecution(err) { - ctx.JSON(http.StatusUnprocessableEntity, gin.H{ - "message": fmt.Sprintf("exec failed: %s", err), - "code": "execution_failed", - }) - return - } - // A stopped or wrong-state box exists but is not accepting execs. + // Classify so a stopped / wrong-state box surfaces as 4xx, not + // 500. Without this the box-stopped case leaks + // internal error: HTTP 500 Internal Server Error: + // {"error":"exec failed: failed to start execution: boxlite: stopped: + // Handle invalidated after stop(). ... (code=11)"} + // and the SDK's 'all 5xx is bug' guard fires. The Go SDK already + // gives us a typed bool for the two states that aren't 500 from + // the runner's perspective (the box exists but isn't accepting + // execs right now). if sdkboxlite.IsStopped(err) || sdkboxlite.IsInvalidState(err) { ctx.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("exec failed: %s", err)}) return diff --git a/scripts/test/e2e/cases/test_error_code_mapping.py b/scripts/test/e2e/cases/test_error_code_mapping.py index 6e5e90d5c..07a7f5a83 100644 --- a/scripts/test/e2e/cases/test_error_code_mapping.py +++ b/scripts/test/e2e/cases/test_error_code_mapping.py @@ -180,24 +180,30 @@ async def test_image_pull_failed_returns_422(rt): ), f"422 body does not explain the cause: {body_str}" +@pytest.mark.xfail( + strict=True, + reason=( + "Production bug: exec'ing a non-existent binary surfaces " + "'boxlite: internal error: spawn_failed' (code=1, ErrInternal) → HTTP " + "500 instead of ExecutionError (code=10) → HTTP 422 per the canonical " + "table at src/shared/src/errors.rs:198-280. The Rust spawn path " + "(boxlite-shim → exec process build) wraps the executable-not-found " + "case as ErrInternal/SpawnFailed rather than ErrExecution, so the " + "classifyExecError fix in this PR can't route it correctly." + ), +) @pytest.mark.asyncio async def test_execution_invalid_command_returns_422(rt, image): """Exec'ing a missing binary inside a real box should surface ExecutionError → 422 (not 500).""" box = await rt.create(boxlite.BoxOptions(image=image, auto_remove=True)) try: - ctx = auth_context() - status, body = _api_call( - "POST", - ctx.v1(f"boxes/{box.id}/exec"), - {"command": "/nonexistent/binary", "args": []}, - ) - _assert_http_code( - status, - body, - expected_status=422, - expected_code_substr="execution_failed", - msg="POST /boxes/{id}/exec with a missing binary", + with pytest.raises(Exception) as exc_info: + ex = await box.exec("/nonexistent/binary", [], None) + await ex.wait() + msg = str(exc_info.value).lower() + assert "500" not in msg and "internal" not in msg, ( + f"exec missing binary leaked a 5xx: {exc_info.value!r}" ) finally: await rt.remove(box.id, force=True) diff --git a/scripts/test/e2e/cases/test_exec_comprehensive.py b/scripts/test/e2e/cases/test_exec_comprehensive.py index c6d296f18..7775f3a70 100644 --- a/scripts/test/e2e/cases/test_exec_comprehensive.py +++ b/scripts/test/e2e/cases/test_exec_comprehensive.py @@ -21,7 +21,6 @@ import boxlite import pytest -from boxlite.boxlite import _ExecStartError from conftest import drain @@ -253,9 +252,16 @@ async def test_env_does_not_leak_across_execs(box): @pytest.mark.asyncio async def test_exec_cwd_nonexistent_returns_error(box): - """Exec with a non-existent cwd should be a typed start failure.""" - with pytest.raises(_ExecStartError): - await box.exec("pwd", [], cwd="/nonexistent/path/xyz") + """Exec with a non-existent cwd should fail, not silently fall back. + The runner may raise an exception (500 spawn_failed) or return a + non-zero exit code — either is acceptable.""" + try: + ex = await box.exec("pwd", [], cwd="/nonexistent/path/xyz") + await drain(ex) + rc = await asyncio.wait_for(ex.wait(), timeout=30) + assert rc.exit_code != 0, "exec with nonexistent cwd should fail" + except Exception: + pass # spawn_failed exception is also correct behaviour @pytest.mark.asyncio @@ -275,10 +281,18 @@ async def test_exec_cwd_with_spaces(box): @pytest.mark.asyncio async def test_nonexistent_command_returns_error(box): - """A missing binary should be reported as a typed start failure.""" - with pytest.raises(_ExecStartError) as exc_info: - await box.exec("this_binary_does_not_exist_xyz", []) - assert "not found" in str(exc_info.value).lower() + """Running a binary that doesn't exist should fail — either a non-zero + exit code or a spawn_failed exception from the runner.""" + try: + ex = await box.exec("this_binary_does_not_exist_xyz", []) + await drain(ex) + rc = await asyncio.wait_for(ex.wait(), timeout=30) + assert rc.exit_code != 0, "nonexistent command should fail" + except Exception as e: + # spawn_failed / "not found in $PATH" is correct behaviour + assert "not found" in str(e).lower() or "spawn_failed" in str(e), ( + f"unexpected error for missing binary: {e}" + ) # ── streaming output timing ─────────────────────────────────────── diff --git a/sdks/go/boxlite_test.go b/sdks/go/boxlite_test.go index 41d0f2119..77001fe9d 100644 --- a/sdks/go/boxlite_test.go +++ b/sdks/go/boxlite_test.go @@ -59,19 +59,6 @@ func TestIsInvalidState(t *testing.T) { } } -func TestIsExecution(t *testing.T) { - err := &Error{Code: ErrExecution, Message: "spawn failed"} - if !IsExecution(err) { - t.Error("expected IsExecution to return true") - } - if IsExecution(errors.New("other")) { - t.Error("expected IsExecution to return false for non-Error") - } - if IsExecution(&Error{Code: ErrInternal, Message: "internal"}) { - t.Error("expected IsExecution to return false for different code") - } -} - func TestIsStopped(t *testing.T) { err := &Error{Code: ErrStopped, Message: "runtime shut down"} if !IsStopped(err) { diff --git a/sdks/go/errors.go b/sdks/go/errors.go index 85c8ff1a5..f10163786 100644 --- a/sdks/go/errors.go +++ b/sdks/go/errors.go @@ -69,12 +69,6 @@ func IsInvalidState(err error) bool { return errors.As(err, &e) && e.Code == ErrInvalidState } -// IsExecution reports whether err indicates a command execution failure. -func IsExecution(err error) bool { - var e *Error - return errors.As(err, &e) && e.Code == ErrExecution -} - // IsStopped reports whether err indicates a stopped or shut down resource. func IsStopped(err error) bool { var e *Error diff --git a/sdks/python/boxlite/errors.py b/sdks/python/boxlite/errors.py index e0c47dd25..ba39f8b7a 100644 --- a/sdks/python/boxlite/errors.py +++ b/sdks/python/boxlite/errors.py @@ -7,15 +7,10 @@ __all__ = ["BoxliteError", "ExecError", "TimeoutError", "ParseError"] -def _command_start_exit_code(message: str) -> int: - """Translate a pre-exec spawn failure to the conventional shell code.""" +def _is_command_start_failure(message: str) -> bool: + """Return whether a native RuntimeError came from pre-exec spawning.""" detail = message.casefold() - not_found_markers = ( - "not found in $path", - "no such file or directory", - "os error 2", - ) - return 127 if any(marker in detail for marker in not_found_markers) else 126 + return "spawn_failed" in detail and "failed to spawn" in detail class BoxliteError(Exception): diff --git a/sdks/python/boxlite/exec.py b/sdks/python/boxlite/exec.py index 94371b059..576dcd4d6 100644 --- a/sdks/python/boxlite/exec.py +++ b/sdks/python/boxlite/exec.py @@ -4,8 +4,6 @@ Provides Docker-like API for executing commands in boxes. """ -from __future__ import annotations - from dataclasses import dataclass __all__ = [ diff --git a/sdks/python/boxlite/simplebox.py b/sdks/python/boxlite/simplebox.py index db524c4fa..4d9fcd216 100644 --- a/sdks/python/boxlite/simplebox.py +++ b/sdks/python/boxlite/simplebox.py @@ -9,18 +9,9 @@ from enum import IntEnum from typing import Optional, TYPE_CHECKING -from .errors import ExecError, _command_start_exit_code +from .errors import ExecError, _is_command_start_failure from .exec import ExecResult -try: - from .boxlite import _ExecStartError -except ImportError: - - class _ExecStartError(RuntimeError): - """Fallback for native extensions built before _ExecStartError.""" - - pass - if TYPE_CHECKING: from .boxlite import Boxlite @@ -284,11 +275,11 @@ async def exec( execution = await self._box.exec( cmd, arg_list, env_list, user=user, timeout_secs=timeout, cwd=cwd ) - except _ExecStartError as e: + except RuntimeError as e: message = str(e) - raise ExecError( - command_display, _command_start_exit_code(message), message - ) from e + if _is_command_start_failure(message): + raise ExecError(command_display, 126, message) from e + raise # Get streams from Rust execution try: diff --git a/sdks/python/boxlite/sync_api/_simplebox.py b/sdks/python/boxlite/sync_api/_simplebox.py index 678e8bb2d..fdb035a72 100644 --- a/sdks/python/boxlite/sync_api/_simplebox.py +++ b/sdks/python/boxlite/sync_api/_simplebox.py @@ -9,18 +9,9 @@ import logging from typing import TYPE_CHECKING, Dict, Optional -from ..errors import ExecError, _command_start_exit_code +from ..errors import ExecError, _is_command_start_failure from ..exec import ExecResult -try: - from ..boxlite import _ExecStartError -except ImportError: - - class _ExecStartError(RuntimeError): - """Fallback for native extensions built before _ExecStartError.""" - - pass - if TYPE_CHECKING: from ._boxlite import SyncBoxlite @@ -208,11 +199,11 @@ async def _exec_and_collect(): execution = await async_box.exec( cmd, arg_list, env_list, user=user, timeout_secs=timeout, cwd=cwd ) - except _ExecStartError as e: + except RuntimeError as e: message = str(e) - raise ExecError( - command_display, _command_start_exit_code(message), message - ) from e + if _is_command_start_failure(message): + raise ExecError(command_display, 126, message) from e + raise stdout_lines = [] stderr_lines = [] diff --git a/sdks/python/src/box_handle.rs b/sdks/python/src/box_handle.rs index 7196e880e..3a49adddf 100644 --- a/sdks/python/src/box_handle.rs +++ b/sdks/python/src/box_handle.rs @@ -6,7 +6,7 @@ use crate::metrics::PyBoxMetrics; use crate::network::PyNetworkHandle; use crate::snapshot_options::{PyCloneOptions, PyExportOptions}; use crate::snapshots::PySnapshotHandle; -use crate::util::{map_err, map_exec_err}; +use crate::util::map_err; use boxlite::{BoxCommand, CloneOptions, ExportOptions, LiteBox}; use pyo3::prelude::*; @@ -87,7 +87,7 @@ impl PyBox { cmd = cmd.working_dir(cwd); } - let execution = handle.exec(cmd).await.map_err(map_exec_err)?; + let execution = handle.exec(cmd).await.map_err(map_err)?; Ok(PyExecution { execution: Arc::new(execution), diff --git a/sdks/python/src/lib.rs b/sdks/python/src/lib.rs index 9acd6d5ee..6ff730b17 100644 --- a/sdks/python/src/lib.rs +++ b/sdks/python/src/lib.rs @@ -28,7 +28,6 @@ use crate::options::{ use crate::runtime::PyBoxlite; use crate::snapshot_options::{PyCloneOptions, PyExportOptions, PySnapshotOptions}; use crate::snapshots::{PySnapshotHandle, PySnapshotInfo}; -use crate::util::_ExecStartError; use crate::volumes::{PyVolumeHandle, PyVolumeInfo}; use pyo3::prelude::*; @@ -72,7 +71,6 @@ fn boxlite_python(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; - m.add("_ExecStartError", m.py().get_type::<_ExecStartError>())?; Ok(()) } diff --git a/sdks/python/src/util.rs b/sdks/python/src/util.rs index 3b8d37584..ea544c5b5 100644 --- a/sdks/python/src/util.rs +++ b/sdks/python/src/util.rs @@ -1,15 +1,5 @@ -use boxlite::BoxliteError; -use pyo3::{create_exception, exceptions::PyRuntimeError, prelude::*}; - -create_exception!(boxlite_python, _ExecStartError, PyRuntimeError); +use pyo3::{exceptions::PyRuntimeError, prelude::*}; pub(crate) fn map_err(err: impl std::fmt::Display) -> PyErr { PyRuntimeError::new_err(err.to_string()) } - -pub(crate) fn map_exec_err(err: BoxliteError) -> PyErr { - match err { - BoxliteError::Execution(message) => _ExecStartError::new_err(message), - other => map_err(other), - } -} diff --git a/sdks/python/tests/test_errors.py b/sdks/python/tests/test_errors.py index b7e6892ae..977d4ea7a 100644 --- a/sdks/python/tests/test_errors.py +++ b/sdks/python/tests/test_errors.py @@ -10,21 +10,24 @@ ExecError, ParseError, TimeoutError, - _command_start_exit_code, + _is_command_start_failure, ) @pytest.mark.parametrize( ("message", "expected"), [ - ("executable '/missing' not found in $PATH", 127), - ("No such file or directory (os error 2)", 127), - ("Permission denied (os error 13)", 126), - ("Exec format error (os error 8)", 126), + ( + "boxlite: internal error: spawn_failed: Failed to spawn 'missing': " + "No such file or directory (os error 2)", + True, + ), + ("Permission denied (os error 13)", False), + ("internal error: database unavailable", False), ], ) -def test_command_start_exit_code(message, expected): - assert _command_start_exit_code(message) == expected +def test_is_command_start_failure(message, expected): + assert _is_command_start_failure(message) is expected class TestBoxliteError: diff --git a/sdks/python/tests/test_simplebox.py b/sdks/python/tests/test_simplebox.py index f6d11c9e1..ab768b24b 100644 --- a/sdks/python/tests/test_simplebox.py +++ b/sdks/python/tests/test_simplebox.py @@ -23,9 +23,9 @@ async def test_simplebox_command_not_found_raises_exec_error(shared_runtime): async with boxlite.SimpleBox(image="alpine:latest", runtime=shared_runtime) as box: with pytest.raises(boxlite.ExecError) as exc: await box.exec("definitely-not-a-boxlite-command-xyz") - assert exc.value.exit_code == 127 + assert exc.value.exit_code == 126 stderr = exc.value.stderr.lower() - assert "not found" in stderr or "no such file or directory" in stderr + assert "failed to spawn" in stderr async def test_simplebox_unexecutable_command_raises_exit_code_126(shared_runtime): diff --git a/sdks/python/tests/test_sync_simplebox.py b/sdks/python/tests/test_sync_simplebox.py index 6f7eaaa78..6ab3bc657 100644 --- a/sdks/python/tests/test_sync_simplebox.py +++ b/sdks/python/tests/test_sync_simplebox.py @@ -87,7 +87,7 @@ def test_command_not_found_raises_exec_error(self, shared_sync_runtime): with SyncSimpleBox(image="alpine:latest", runtime=shared_sync_runtime) as box: with pytest.raises(ExecError) as exc: box.exec("definitely-not-a-boxlite-command-xyz") - assert exc.value.exit_code == 127 + assert exc.value.exit_code == 126 def test_unexecutable_command_raises_exit_code_126(self, shared_sync_runtime): with SyncSimpleBox(image="alpine:latest", runtime=shared_sync_runtime) as box: diff --git a/src/boxlite/src/portal/interfaces/exec.rs b/src/boxlite/src/portal/interfaces/exec.rs index 1f165b8a0..e9c9e0e37 100644 --- a/src/boxlite/src/portal/interfaces/exec.rs +++ b/src/boxlite/src/portal/interfaces/exec.rs @@ -60,10 +60,10 @@ impl ExecutionInterface { // Start execution let exec_response = self.client.exec(request).await?.into_inner(); if let Some(err) = exec_response.error { - return Err(match err.reason.as_str() { - "spawn_failed" => BoxliteError::Execution(err.detail), - _ => BoxliteError::Internal(format!("{}: {}", err.reason, err.detail)), - }); + return Err(BoxliteError::Internal(format!( + "{}: {}", + err.reason, err.detail + ))); } let execution_id = exec_response.execution_id.clone(); From 528bb07505407620c6c61b0476c02ce585cfc0ab Mon Sep 17 00:00:00 2001 From: G4614 <92488762+G4614@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:33:22 +0800 Subject: [PATCH 12/13] fix(python): type exec start failures in binding --- sdks/python/boxlite/errors.py | 6 ------ sdks/python/boxlite/simplebox.py | 17 ++++++++++----- sdks/python/boxlite/sync_api/_simplebox.py | 17 ++++++++++----- sdks/python/src/box_handle.rs | 4 ++-- sdks/python/src/lib.rs | 2 ++ sdks/python/src/util.rs | 21 ++++++++++++++++++- sdks/python/tests/test_errors.py | 24 +--------------------- 7 files changed, 49 insertions(+), 42 deletions(-) diff --git a/sdks/python/boxlite/errors.py b/sdks/python/boxlite/errors.py index ba39f8b7a..482c7301a 100644 --- a/sdks/python/boxlite/errors.py +++ b/sdks/python/boxlite/errors.py @@ -7,12 +7,6 @@ __all__ = ["BoxliteError", "ExecError", "TimeoutError", "ParseError"] -def _is_command_start_failure(message: str) -> bool: - """Return whether a native RuntimeError came from pre-exec spawning.""" - detail = message.casefold() - return "spawn_failed" in detail and "failed to spawn" in detail - - class BoxliteError(Exception): """Base exception for all boxlite errors.""" diff --git a/sdks/python/boxlite/simplebox.py b/sdks/python/boxlite/simplebox.py index 4d9fcd216..0538ebfa1 100644 --- a/sdks/python/boxlite/simplebox.py +++ b/sdks/python/boxlite/simplebox.py @@ -9,9 +9,18 @@ from enum import IntEnum from typing import Optional, TYPE_CHECKING -from .errors import ExecError, _is_command_start_failure +from .errors import ExecError from .exec import ExecResult +try: + from .boxlite import _ExecStartError +except ImportError: + + class _ExecStartError(RuntimeError): + """Import placeholder when the native extension is unavailable.""" + + pass + if TYPE_CHECKING: from .boxlite import Boxlite @@ -275,11 +284,9 @@ async def exec( execution = await self._box.exec( cmd, arg_list, env_list, user=user, timeout_secs=timeout, cwd=cwd ) - except RuntimeError as e: + except _ExecStartError as e: message = str(e) - if _is_command_start_failure(message): - raise ExecError(command_display, 126, message) from e - raise + raise ExecError(command_display, 126, message) from e # Get streams from Rust execution try: diff --git a/sdks/python/boxlite/sync_api/_simplebox.py b/sdks/python/boxlite/sync_api/_simplebox.py index fdb035a72..2383969f7 100644 --- a/sdks/python/boxlite/sync_api/_simplebox.py +++ b/sdks/python/boxlite/sync_api/_simplebox.py @@ -9,9 +9,18 @@ import logging from typing import TYPE_CHECKING, Dict, Optional -from ..errors import ExecError, _is_command_start_failure +from ..errors import ExecError from ..exec import ExecResult +try: + from ..boxlite import _ExecStartError +except ImportError: + + class _ExecStartError(RuntimeError): + """Import placeholder when the native extension is unavailable.""" + + pass + if TYPE_CHECKING: from ._boxlite import SyncBoxlite @@ -199,11 +208,9 @@ async def _exec_and_collect(): execution = await async_box.exec( cmd, arg_list, env_list, user=user, timeout_secs=timeout, cwd=cwd ) - except RuntimeError as e: + except _ExecStartError as e: message = str(e) - if _is_command_start_failure(message): - raise ExecError(command_display, 126, message) from e - raise + raise ExecError(command_display, 126, message) from e stdout_lines = [] stderr_lines = [] diff --git a/sdks/python/src/box_handle.rs b/sdks/python/src/box_handle.rs index 3a49adddf..cafdd061c 100644 --- a/sdks/python/src/box_handle.rs +++ b/sdks/python/src/box_handle.rs @@ -6,7 +6,7 @@ use crate::metrics::PyBoxMetrics; use crate::network::PyNetworkHandle; use crate::snapshot_options::{PyCloneOptions, PyExportOptions}; use crate::snapshots::PySnapshotHandle; -use crate::util::map_err; +use crate::util::{map_err, map_exec_start_err}; use boxlite::{BoxCommand, CloneOptions, ExportOptions, LiteBox}; use pyo3::prelude::*; @@ -87,7 +87,7 @@ impl PyBox { cmd = cmd.working_dir(cwd); } - let execution = handle.exec(cmd).await.map_err(map_err)?; + let execution = handle.exec(cmd).await.map_err(map_exec_start_err)?; Ok(PyExecution { execution: Arc::new(execution), diff --git a/sdks/python/src/lib.rs b/sdks/python/src/lib.rs index 6ff730b17..5faa9937c 100644 --- a/sdks/python/src/lib.rs +++ b/sdks/python/src/lib.rs @@ -28,6 +28,7 @@ use crate::options::{ use crate::runtime::PyBoxlite; use crate::snapshot_options::{PyCloneOptions, PyExportOptions, PySnapshotOptions}; use crate::snapshots::{PySnapshotHandle, PySnapshotInfo}; +use crate::util::ExecStartError; use crate::volumes::{PyVolumeHandle, PyVolumeInfo}; use pyo3::prelude::*; @@ -71,6 +72,7 @@ fn boxlite_python(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add("_ExecStartError", m.py().get_type::())?; Ok(()) } diff --git a/sdks/python/src/util.rs b/sdks/python/src/util.rs index ea544c5b5..45b9d50fb 100644 --- a/sdks/python/src/util.rs +++ b/sdks/python/src/util.rs @@ -1,5 +1,24 @@ -use pyo3::{exceptions::PyRuntimeError, prelude::*}; +use boxlite::BoxliteError; +use pyo3::{create_exception, exceptions::PyRuntimeError, prelude::*}; + +create_exception!(boxlite_python, ExecStartError, PyRuntimeError); pub(crate) fn map_err(err: impl std::fmt::Display) -> PyErr { PyRuntimeError::new_err(err.to_string()) } + +pub(crate) fn map_exec_start_err(err: BoxliteError) -> PyErr { + if is_exec_start_failure(&err) { + ExecStartError::new_err(err.to_string()) + } else { + map_err(err) + } +} + +fn is_exec_start_failure(err: &BoxliteError) -> bool { + let BoxliteError::Internal(message) = err else { + return false; + }; + let detail = message.to_ascii_lowercase(); + detail.contains("spawn_failed") && detail.contains("failed to spawn") +} diff --git a/sdks/python/tests/test_errors.py b/sdks/python/tests/test_errors.py index 977d4ea7a..316c88c65 100644 --- a/sdks/python/tests/test_errors.py +++ b/sdks/python/tests/test_errors.py @@ -5,29 +5,7 @@ """ import pytest -from boxlite.errors import ( - BoxliteError, - ExecError, - ParseError, - TimeoutError, - _is_command_start_failure, -) - - -@pytest.mark.parametrize( - ("message", "expected"), - [ - ( - "boxlite: internal error: spawn_failed: Failed to spawn 'missing': " - "No such file or directory (os error 2)", - True, - ), - ("Permission denied (os error 13)", False), - ("internal error: database unavailable", False), - ], -) -def test_is_command_start_failure(message, expected): - assert _is_command_start_failure(message) is expected +from boxlite.errors import BoxliteError, ExecError, ParseError, TimeoutError class TestBoxliteError: From 09655437f2d32a45ce3402e0fe6219d87ae02c04 Mon Sep 17 00:00:00 2001 From: G4614 <92488762+G4614@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:42:59 +0800 Subject: [PATCH 13/13] fix(python): map exec start exit codes --- sdks/python/boxlite/errors.py | 8 ++++++++ sdks/python/boxlite/simplebox.py | 6 ++++-- sdks/python/boxlite/sync_api/_simplebox.py | 6 ++++-- sdks/python/src/util.rs | 7 +++++-- sdks/python/tests/test_simplebox.py | 2 +- sdks/python/tests/test_sync_simplebox.py | 2 +- 6 files changed, 23 insertions(+), 8 deletions(-) diff --git a/sdks/python/boxlite/errors.py b/sdks/python/boxlite/errors.py index 482c7301a..55ac83803 100644 --- a/sdks/python/boxlite/errors.py +++ b/sdks/python/boxlite/errors.py @@ -7,6 +7,14 @@ __all__ = ["BoxliteError", "ExecError", "TimeoutError", "ParseError"] +def _exec_start_exit_code(stderr: str) -> int: + """Map command-start errors to shell-compatible exit codes.""" + detail = stderr.lower() + if "no such file or directory" in detail: + return 127 + return 126 + + class BoxliteError(Exception): """Base exception for all boxlite errors.""" diff --git a/sdks/python/boxlite/simplebox.py b/sdks/python/boxlite/simplebox.py index 0538ebfa1..be08cd35f 100644 --- a/sdks/python/boxlite/simplebox.py +++ b/sdks/python/boxlite/simplebox.py @@ -9,7 +9,7 @@ from enum import IntEnum from typing import Optional, TYPE_CHECKING -from .errors import ExecError +from .errors import ExecError, _exec_start_exit_code from .exec import ExecResult try: @@ -286,7 +286,9 @@ async def exec( ) except _ExecStartError as e: message = str(e) - raise ExecError(command_display, 126, message) from e + raise ExecError( + command_display, _exec_start_exit_code(message), message + ) from e # Get streams from Rust execution try: diff --git a/sdks/python/boxlite/sync_api/_simplebox.py b/sdks/python/boxlite/sync_api/_simplebox.py index 2383969f7..274a9642e 100644 --- a/sdks/python/boxlite/sync_api/_simplebox.py +++ b/sdks/python/boxlite/sync_api/_simplebox.py @@ -9,7 +9,7 @@ import logging from typing import TYPE_CHECKING, Dict, Optional -from ..errors import ExecError +from ..errors import ExecError, _exec_start_exit_code from ..exec import ExecResult try: @@ -210,7 +210,9 @@ async def _exec_and_collect(): ) except _ExecStartError as e: message = str(e) - raise ExecError(command_display, 126, message) from e + raise ExecError( + command_display, _exec_start_exit_code(message), message + ) from e stdout_lines = [] stderr_lines = [] diff --git a/sdks/python/src/util.rs b/sdks/python/src/util.rs index 45b9d50fb..753d0cff7 100644 --- a/sdks/python/src/util.rs +++ b/sdks/python/src/util.rs @@ -3,6 +3,8 @@ use pyo3::{create_exception, exceptions::PyRuntimeError, prelude::*}; create_exception!(boxlite_python, ExecStartError, PyRuntimeError); +const EXEC_START_REASON: &str = "spawn_failed"; + pub(crate) fn map_err(err: impl std::fmt::Display) -> PyErr { PyRuntimeError::new_err(err.to_string()) } @@ -19,6 +21,7 @@ fn is_exec_start_failure(err: &BoxliteError) -> bool { let BoxliteError::Internal(message) = err else { return false; }; - let detail = message.to_ascii_lowercase(); - detail.contains("spawn_failed") && detail.contains("failed to spawn") + message + .strip_prefix(EXEC_START_REASON) + .is_some_and(|detail| detail.starts_with(": ")) } diff --git a/sdks/python/tests/test_simplebox.py b/sdks/python/tests/test_simplebox.py index ab768b24b..8ac475fd1 100644 --- a/sdks/python/tests/test_simplebox.py +++ b/sdks/python/tests/test_simplebox.py @@ -23,7 +23,7 @@ async def test_simplebox_command_not_found_raises_exec_error(shared_runtime): async with boxlite.SimpleBox(image="alpine:latest", runtime=shared_runtime) as box: with pytest.raises(boxlite.ExecError) as exc: await box.exec("definitely-not-a-boxlite-command-xyz") - assert exc.value.exit_code == 126 + assert exc.value.exit_code == 127 stderr = exc.value.stderr.lower() assert "failed to spawn" in stderr diff --git a/sdks/python/tests/test_sync_simplebox.py b/sdks/python/tests/test_sync_simplebox.py index 6ab3bc657..6f7eaaa78 100644 --- a/sdks/python/tests/test_sync_simplebox.py +++ b/sdks/python/tests/test_sync_simplebox.py @@ -87,7 +87,7 @@ def test_command_not_found_raises_exec_error(self, shared_sync_runtime): with SyncSimpleBox(image="alpine:latest", runtime=shared_sync_runtime) as box: with pytest.raises(ExecError) as exc: box.exec("definitely-not-a-boxlite-command-xyz") - assert exc.value.exit_code == 126 + assert exc.value.exit_code == 127 def test_unexecutable_command_raises_exit_code_126(self, shared_sync_runtime): with SyncSimpleBox(image="alpine:latest", runtime=shared_sync_runtime) as box: