Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions sdks/python/boxlite/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
"""

# Default VM resources
DEFAULT_CPUS = 1
DEFAULT_MEMORY_MIB = 2048
DEFAULT_CPUS = 4
DEFAULT_MEMORY_MIB = 4096

# ComputerBox defaults (higher resources for desktop)
COMPUTERBOX_CPUS = 2
Expand Down
25 changes: 25 additions & 0 deletions sdks/python/boxlite/exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,28 @@ 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
)


def _looks_like_timeout_result(
exit_code: int,
*,
timeout: float | None,
elapsed: float,
) -> bool:
if timeout is None:
return False
if exit_code not in {-15, -9}:
return False
return elapsed >= max(0.0, timeout * 0.8)
34 changes: 30 additions & 4 deletions sdks/python/boxlite/simplebox.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@

import asyncio
import logging
import time
from enum import IntEnum
from typing import Optional, TYPE_CHECKING

from .exec import ExecResult
from .errors import ExecError, TimeoutError
from .exec import ExecResult, _looks_like_command_start_error, _looks_like_timeout_result

if TYPE_CHECKING:
from .boxlite import Boxlite
Expand Down Expand Up @@ -219,10 +221,19 @@ 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])
started_at = time.monotonic()

# 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:
Expand Down Expand Up @@ -285,13 +296,28 @@ async def collect_stderr():

logger.debug(f"exec finish, exit_code: {exit_code}")

elapsed = time.monotonic() - started_at
if _looks_like_timeout_result(exit_code, timeout=timeout, elapsed=elapsed):
raise TimeoutError(
f"Command '{command_display}' timed out after {timeout:g} seconds"
)

return ExecResult(
exit_code=exit_code,
stdout=stdout,
stderr=stderr,
error_message=error_message,
)

async def metrics(self):
"""Get box metrics (CPU, memory usage)."""
if not self._started:
raise RuntimeError(
"Box not started. Use 'async with SimpleBox(...) as box:' "
"or call 'await box.start()' first."
)
return await self._box.metrics()

async def stop(self):
"""
Stop the box and release resources.
Expand Down
26 changes: 22 additions & 4 deletions sdks/python/boxlite/sync_api/_simplebox.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@

import asyncio
import logging
import time
from typing import TYPE_CHECKING, Dict, Optional

from ..exec import ExecResult
from ..errors import ExecError, TimeoutError
from ..exec import ExecResult, _looks_like_command_start_error, _looks_like_timeout_result

if TYPE_CHECKING:
from ._boxlite import SyncBoxlite
Expand Down Expand Up @@ -190,11 +192,20 @@ 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
)
started_at = time.monotonic()

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 = []
Expand Down Expand Up @@ -245,6 +256,13 @@ async def collect_stderr():
except Exception as e:
logger.error(f"failed to wait execution: {e}")
exit_code = -1
error_message = None

elapsed = time.monotonic() - started_at
if _looks_like_timeout_result(exit_code, timeout=timeout, elapsed=elapsed):
raise TimeoutError(
f"Command '{command_display}' timed out after {timeout:g} seconds"
)

return ExecResult(
exit_code=exit_code,
Expand Down
22 changes: 20 additions & 2 deletions sdks/python/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,8 +223,26 @@ impl PyBoxlite {
})
}

fn __enter__(slf: PyRef<'_, Self>) -> PyResult<PyRef<'_, Self>> {
Ok(slf)
fn __aenter__<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
let runtime = Arc::clone(&self.runtime);
pyo3_async_runtimes::tokio::future_into_py(py, async move { Ok(Self { runtime }) })
}

fn __aexit__<'py>(
&self,
py: Python<'py>,
_exc_type: Py<PyAny>,
_exc_val: Py<PyAny>,
_exc_tb: Py<PyAny>,
) -> PyResult<Bound<'py, PyAny>> {
pyo3_async_runtimes::tokio::future_into_py(py, async move { Ok(false) })
}

fn __enter__(&self) -> PyResult<()> {
Err(pyo3::exceptions::PyRuntimeError::new_err(
"Boxlite is an async runtime; use 'async with boxlite.Boxlite(...)' \
or use 'with boxlite.SyncBoxlite(...)' for the synchronous API",
))
}

fn __exit__(
Expand Down
11 changes: 11 additions & 0 deletions sdks/python/tests/test_box_management_mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,17 @@ def cls(self):
def test_method_exists(self, cls, method):
assert hasattr(cls, method), f"Boxlite missing method: {method}"

def test_sync_context_manager_points_to_sync_api(self, tmp_path):
runtime = boxlite.Boxlite(boxlite.Options(home_dir=str(tmp_path)))
with pytest.raises(RuntimeError, match="SyncBoxlite"):
with runtime:
pass

@pytest.mark.asyncio
async def test_async_context_manager(self, tmp_path):
async with boxlite.Boxlite(boxlite.Options(home_dir=str(tmp_path))) as runtime:
assert isinstance(runtime, boxlite.Boxlite)

def test_rest_runtime_images_unsupported(self):
runtime = boxlite.Boxlite.rest(
boxlite.BoxliteRestOptions(url="http://localhost:1")
Expand Down
59 changes: 23 additions & 36 deletions sdks/python/tests/test_exec_timeout_sigalrm.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,9 @@
completion, and ``ExecResult.exit_code`` comes back as 0 — the deadline is
bypassed.

The Python SDK does NOT raise ``boxlite.TimeoutError`` on exec timeout; it
returns an ``ExecResult`` whose ``exit_code`` reflects how the process died
(non-zero / signal). The PoC at ``boxlite_poc/poc_sigalrm_bypass.py``
confirms this: it inspects ``exit_code`` and elapsed wall-time, never an
exception.
The Python convenience SDK raises ``boxlite.TimeoutError`` on exec timeout.
This test still checks elapsed wall-time so timeout signaling regressions do
not hide behind the typed exception.

Requirements:
- make dev:python (build the Python SDK with native extension)
Expand Down Expand Up @@ -66,9 +64,9 @@ async def test_exec_timeout_kills_sigalrm_ignoring_process(shared_runtime):

Two-pronged assertion — both must hold:

1) ``exit_code != 0`` — the workload must NOT have completed normally.
With the SIGALRM bug, SIG_IGN absorbs the timeout signal, the loop
finishes, and Python exits cleanly with 0.
1) ``TimeoutError`` is raised — the workload must NOT have completed
normally. With the SIGALRM bug, SIG_IGN absorbs the timeout signal,
the loop finishes, and Python exits cleanly with 0.

2) ``elapsed < TIMEOUT_S + 2.0`` — termination must happen near the
configured deadline, not at the workload's natural end. Even if
Expand All @@ -82,23 +80,18 @@ async def test_exec_timeout_kills_sigalrm_ignoring_process(shared_runtime):
image="python:3-alpine", runtime=shared_runtime
) as box:
t0 = time.time()
result = await box.exec(
"python3",
"-c",
IGNORE_SIGALRM,
str(WORKLOAD_S),
timeout=TIMEOUT_S,
)
with pytest.raises(boxlite.TimeoutError):
await box.exec(
"python3",
"-c",
IGNORE_SIGALRM,
str(WORKLOAD_S),
timeout=TIMEOUT_S,
)
elapsed = time.time() - t0

assert result.exit_code != 0, (
f"exec returned exit_code=0 after {elapsed:.2f}s — the {WORKLOAD_S}s "
f"workload completed normally despite timeout={TIMEOUT_S}s. The "
f"guest's timeout watcher is sending a catchable signal that the "
f"workload absorbs via SIG_IGN; the kill must use SIGKILL."
)
assert elapsed < TIMEOUT_S + 2.0, (
f"exec returned after {elapsed:.2f}s with exit_code={result.exit_code} "
f"exec returned after {elapsed:.2f}s "
f"— expected termination near {TIMEOUT_S}s. The timeout watcher "
f"is not killing the process promptly."
)
Expand All @@ -125,22 +118,16 @@ async def test_exec_timeout_sigkill_fallback_when_sigterm_ignored(shared_runtime
image="python:3-alpine", runtime=shared_runtime
) as box:
t0 = time.time()
result = await box.exec(
"python3",
"-c",
IGNORE_TERM_AND_ALRM,
str(WORKLOAD_S),
timeout=TIMEOUT_S,
)
with pytest.raises(boxlite.TimeoutError):
await box.exec(
"python3",
"-c",
IGNORE_TERM_AND_ALRM,
str(WORKLOAD_S),
timeout=TIMEOUT_S,
)
elapsed = time.time() - t0

assert result.exit_code != 0, (
f"stage-3 SIGKILL fallback broken: exec returned exit_code=0 "
f"after {elapsed:.2f}s. The workload ignores SIGTERM AND was not "
f"SIGKILL'd by the watcher — either grace expired without sending "
f"SIGKILL, or stage-3 escalation was removed from "
f"src/guest/src/service/exec/timeout.rs."
)
# Expected ~ TIMEOUT_S + GRACE_S = 5.0s. Allow +2.0s headroom for VM
# / SDK overhead. If elapsed approaches WORKLOAD_S, the watcher is
# not enforcing the deadline at all.
Expand Down
31 changes: 31 additions & 0 deletions sdks/python/tests/test_simplebox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Integration tests for the async SimpleBox convenience wrapper."""

from __future__ import annotations

import pytest

import boxlite

pytestmark = [pytest.mark.integration, pytest.mark.asyncio]


async def test_simplebox_metrics(shared_runtime):
"""Async SimpleBox exposes box metrics like SyncSimpleBox."""
async with boxlite.SimpleBox(
image="alpine:latest", runtime=shared_runtime
) as box:
await box.exec("echo", "test")
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()
4 changes: 2 additions & 2 deletions sdks/python/tests/test_skillbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ def test_default_values(self, shared_sync_runtime, oauth_token):
info = box.info()
# Default image is node:20-alpine
assert "node" in info.image.lower()
# Default memory is 2048 MiB
assert info.memory_mib == 2048
# Default memory is 4096 MiB
assert info.memory_mib == 4096

def test_custom_name(self, shared_sync_runtime, oauth_token):
"""Test SkillBox with custom name."""
Expand Down
15 changes: 12 additions & 3 deletions sdks/python/tests/test_sync_simplebox.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

# Try to import sync API - skip if greenlet not installed
try:
import boxlite
from boxlite import SyncSimpleBox

SYNC_AVAILABLE = True
Expand Down Expand Up @@ -82,6 +83,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(
Expand Down Expand Up @@ -115,10 +124,10 @@ def test_exec_with_user(self, shared_sync_runtime):
assert "nobody" in result.stdout

def test_exec_with_timeout(self, shared_sync_runtime):
"""Per-exec timeout kills long-running commands."""
"""Per-exec timeout raises a typed TimeoutError."""
with SyncSimpleBox(image="alpine:latest", runtime=shared_sync_runtime) as box:
result = box.exec("sleep", "60", timeout=2)
assert result.exit_code != 0
with pytest.raises(boxlite.TimeoutError):
box.exec("sleep", "60", timeout=2)

def test_exec_combined_options(self, shared_sync_runtime):
"""Per-exec cwd and user can be combined."""
Expand Down
4 changes: 2 additions & 2 deletions sdks/python/tests/test_sync_skillbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ def test_default_values(self, shared_sync_runtime, oauth_token):
info = box.info()
# Default image is node:20-alpine
assert "node" in info.image.lower()
# Default memory is 2048 MiB
assert info.memory_mib == 2048
# Default memory is 4096 MiB
assert info.memory_mib == 4096

def test_custom_name(self, shared_sync_runtime, oauth_token):
"""Test SyncSkillBox with custom name."""
Expand Down
4 changes: 2 additions & 2 deletions src/boxlite/src/runtime/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,10 @@ pub mod fs_options {
/// Virtual machine resource defaults
pub mod vm_defaults {
/// Default number of CPUs allocated to a Box
pub const DEFAULT_CPUS: u8 = 1;
pub const DEFAULT_CPUS: u8 = 4;

/// Default memory in MiB allocated to a Box
pub const DEFAULT_MEMORY_MIB: u32 = 2048;
pub const DEFAULT_MEMORY_MIB: u32 = 4096;

/// Default disk size in GB for the container rootfs (sparse, grows as needed)
pub const DEFAULT_DISK_SIZE_GB: u64 = 10;
Expand Down
Loading
Loading