Skip to content
Open
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
11 changes: 7 additions & 4 deletions apps/runner/pkg/api/controllers/boxlite_exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,8 @@ func BoxliteGetExecution(ctx *gin.Context) {
// ExecutionInfoResponse describes an execution's current state.
// Field names match the OpenAPI ExecutionInfo schema:
// execution_id, status, exit_code, error_message. Statuses are
// running | completed | killed | timed_out per spec; today we only
// emit running and completed (the latter covers any non-running state
// the kernel surfaced — exit-code semantics distinguish them).
// running | completed | killed | timed_out per spec; today killed falls
// back to completed with signal-shaped exit-code semantics.
// exit_code and error_message are populated only after Done fires so
// callers can distinguish "still running" from "exited cleanly with
// code 0".
Expand All @@ -202,7 +201,11 @@ func executionInfoFromManagedExec(exec *boxlite.ManagedExec) ExecutionInfoRespon
resp := ExecutionInfoResponse{ExecutionID: exec.ID}
select {
case <-exec.Done:
resp.Status = "completed"
if exec.TimedOut {
resp.Status = "timed_out"
} else {
resp.Status = "completed"
}
code := exec.ExitCode
resp.ExitCode = &code
if exec.Err != nil {
Expand Down
3 changes: 3 additions & 0 deletions apps/runner/pkg/api/controllers/boxlite_exec_attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ type attachExec interface {
WriteStdin(data []byte) (int, error)
DoneCh() <-chan struct{}
ExitCodeValue() int
TimedOutValue() bool
IsTTY() bool
Resize(rows, cols int) error
Signal(sig int) error
Expand Down Expand Up @@ -289,6 +290,7 @@ func runAttachLoop(parentCtx context.Context, conn *websocket.Conn, exec attachE
_ = writeJSONFrame(conn, &writeMu, map[string]any{
"type": "exit",
"exit_code": exec.ExitCodeValue(),
"timed_out": exec.TimedOutValue(),
})
closeWS(websocket.CloseNormalClosure, "")
} else {
Expand Down Expand Up @@ -486,6 +488,7 @@ func (m managedExecAttach) Subscribe(bufSize int) (stdout, stderr <-chan []byte,
func (m managedExecAttach) WriteStdin(data []byte) (int, error) { return m.me.AttachWriteStdin(data) }
func (m managedExecAttach) DoneCh() <-chan struct{} { return m.me.Done }
func (m managedExecAttach) ExitCodeValue() int { return m.me.ExitCode }
func (m managedExecAttach) TimedOutValue() bool { return m.me.TimedOut }
func (m managedExecAttach) IsTTY() bool { return m.me.TTY }
func (m managedExecAttach) Resize(rows, cols int) error { return m.me.AttachResize(rows, cols) }
func (m managedExecAttach) Signal(sig int) error { return m.me.AttachSignal(sig) }
Expand Down
6 changes: 6 additions & 0 deletions apps/runner/pkg/api/controllers/boxlite_exec_attach_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type stubAttachExec struct {
stdinW *io.PipeWriter
done chan struct{}
exitCode int
timedOut bool
tty bool
connected atomic.Bool
disconnect atomic.Int32
Expand Down Expand Up @@ -147,6 +148,7 @@ func (s *stubAttachExec) WriteStdin(data []byte) (int, error) {
}
func (s *stubAttachExec) DoneCh() <-chan struct{} { return s.done }
func (s *stubAttachExec) ExitCodeValue() int { return s.exitCode }
func (s *stubAttachExec) TimedOutValue() bool { return s.timedOut }
func (s *stubAttachExec) IsTTY() bool { return s.tty }
func (s *stubAttachExec) Resize(rows, cols int) error {
s.mu.Lock()
Expand Down Expand Up @@ -227,6 +229,7 @@ func readNextDataFrame(t *testing.T, conn *websocket.Conn, deadline time.Duratio
func TestBoxliteExecAttach_StdinAndExit(t *testing.T) {
stub := newStubAttachExec()
stub.exitCode = 42
stub.timedOut = true
cleanup := withStubExec(t, "exec-1", stub)
defer cleanup()

Expand Down Expand Up @@ -302,6 +305,9 @@ func TestBoxliteExecAttach_StdinAndExit(t *testing.T) {
if got, want := int(ev["exit_code"].(float64)), 42; got != want {
t.Fatalf("expected exit_code %d, got %d", want, got)
}
if got, want := ev["timed_out"].(bool), true; got != want {
t.Fatalf("expected timed_out %v, got %v", want, got)
}
gotExit = true
continue
}
Expand Down
27 changes: 27 additions & 0 deletions apps/runner/pkg/api/controllers/boxlite_exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,33 @@ func TestBoxliteGetExecutionReturnsRunningThenExited(t *testing.T) {
}
}

func TestBoxliteGetExecutionReportsTimedOut(t *testing.T) {
mgr := withFreshExecManager(t)
exec := seedManagedExec(mgr, "exec-timeout", &signalCapturingExec{})
exec.ExitCode = -15
exec.TimedOut = true
close(exec.Done)

w := runHandler(http.MethodGet,
"/v1/boxes/:boxId/executions/:execId",
"/v1/boxes/box/executions/exec-timeout",
nil, BoxliteGetExecution)

if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
}
var info ExecutionInfoResponse
if err := json.Unmarshal(w.Body.Bytes(), &info); err != nil {
t.Fatalf("unmarshal failed: %v body=%s", err, w.Body.String())
}
if info.Status != "timed_out" {
t.Fatalf("expected status=timed_out, got %+v", info)
}
if info.ExitCode == nil || *info.ExitCode != -15 {
t.Fatalf("expected exit_code=-15, got %+v", info)
}
}

// Phase 2.1: missing exec id returns 404 instead of 200/empty body.
func TestBoxliteGetExecutionNotFound(t *testing.T) {
withFreshExecManager(t)
Expand Down
11 changes: 9 additions & 2 deletions apps/runner/pkg/boxlite/exec_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ func (s sdkExec) ResizeTTY(ctx context.Context, rows, cols int) error {
}
func (s sdkExec) Close() error { return s.inner.Close() }
func (s sdkExec) Wait(ctx context.Context) (int, error) { return s.inner.Wait(ctx) }
func (s sdkExec) WaitResult(ctx context.Context) (*boxlite.ExecutionWaitResult, error) {
return s.inner.WaitResult(ctx)
}

type ExecManager struct {
mu sync.RWMutex
Expand All @@ -95,6 +98,7 @@ type ManagedExec struct {
execution execHandle
Done chan struct{}
ExitCode int
TimedOut bool
Err error
TTY bool
created time.Time
Expand Down Expand Up @@ -395,8 +399,11 @@ func (m *ExecManager) Start(ctx context.Context, bx *boxlite.Box, boxID string,
exec.handleMu.Unlock()
}()

exitCode, err := handle.Wait(context.Background())
exec.ExitCode = exitCode
result, err := handle.WaitResult(context.Background())
if result != nil {
exec.ExitCode = result.ExitCode
exec.TimedOut = result.TimedOut
}
exec.Err = err
}()

Expand Down
86 changes: 86 additions & 0 deletions apps/runner/pkg/boxlite/exec_manager_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//go:build boxlite_dev

package boxlite

import (
"context"
"errors"
"os"
"testing"
"time"

sdk "github.com/boxlite-ai/boxlite/sdks/go"
)

func TestIntegrationExecManagerRecordsTimedOut(t *testing.T) {
ctx := context.Background()
homeDir, err := os.MkdirTemp("/tmp", "boxlite-runner-timeout-")
if err != nil {
t.Fatalf("MkdirTemp: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(homeDir) })

rt, err := sdk.NewRuntime(sdk.WithHomeDir(homeDir))
if err != nil {
var sdkErr *sdk.Error
if errors.As(err, &sdkErr) && (sdkErr.Code == sdk.ErrUnsupported || sdkErr.Code == sdk.ErrUnsupportedEngine) {
t.Skipf("runtime not available: %v", err)
}
t.Fatalf("NewRuntime: %v", err)
}
t.Cleanup(func() { _ = rt.Close() })

box, err := rt.Create(ctx, "alpine:latest", sdk.WithAutoRemove(false))
if err != nil {
skipInfraError(t, "Create", err)
t.Fatalf("Create: %v", err)
}
t.Cleanup(func() {
_ = box.Stop(ctx)
_ = rt.ForceRemove(ctx, box.ID())
_ = box.Close()
})
if err := box.Start(ctx); err != nil {
skipInfraError(t, "Start", err)
t.Fatalf("Start: %v", err)
}

mgr := NewExecManager()
t.Cleanup(func() { mgr.Stop() })
id, err := mgr.Start(ctx, box, box.ID(), StartOptions{
Command: "sleep",
Args: []string{"20"},
Timeout: 2 * time.Second,
})
if err != nil {
t.Fatalf("ExecManager.Start: %v", err)
}
t.Cleanup(func() { _ = mgr.Kill(id) })

exec, ok := mgr.Get(id)
if !ok {
t.Fatalf("exec %s not registered", id)
}
select {
case <-exec.Done:
case <-time.After(15 * time.Second):
t.Fatalf("timed exec did not complete within 15s")
}
if exec.Err != nil {
t.Fatalf("wait returned error: %v", exec.Err)
}
if !exec.TimedOut {
t.Fatalf("expected TimedOut=true, got exit_code=%d", exec.ExitCode)
}
if exec.ExitCode == 0 {
t.Fatalf("expected non-zero timeout exit code")
}
}

func skipInfraError(t *testing.T, op string, err error) {
t.Helper()
var sdkErr *sdk.Error
if errors.As(err, &sdkErr) && (sdkErr.Code == sdk.ErrStorage || sdkErr.Code == sdk.ErrImage || sdkErr.Code == sdk.ErrNetwork) {
t.Skipf("%s infrastructure prerequisite unavailable (code=%d): %v", op, sdkErr.Code, err)
}
}
18 changes: 18 additions & 0 deletions sdks/c/include/boxlite.h
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,9 @@ typedef void (*CBoxExitCb)(int, void*);
// Execution wait completion (carries exit code on success).
typedef void (*CExecutionWaitCb)(int, CBoxliteError*, void*);

// Execution wait completion with structured result metadata.
typedef void (*CExecutionWaitResultCb)(int, bool, CBoxliteError*, void*);

// Execution kill completion.
typedef void (*CExecutionKillCb)(CBoxliteError*, void*);

Expand Down Expand Up @@ -511,6 +514,21 @@ enum BoxliteErrorCode boxlite_execution_wait(CExecutionHandle *execution,
void *user_data,
CBoxliteError *out_error);

// Schedules an asynchronous wait and invokes `cb` with the exit code and
// timeout status, passing `user_data` through unchanged.
//
// Returns immediately with a validation or scheduling status. When non-null,
// `out_error` receives details for synchronous failures.
//
// # Safety
//
// `execution` must be a valid handle, `cb` must be callable for the duration
// of the asynchronous operation, and `out_error` must be null or writable.
enum BoxliteErrorCode boxlite_execution_wait_result(CExecutionHandle *execution,
CExecutionWaitResultCb cb,
void *user_data,
CBoxliteError *out_error);

Comment thread
G4614 marked this conversation as resolved.
enum BoxliteErrorCode boxlite_execution_kill(CExecutionHandle *execution,
CExecutionKillCb cb,
void *user_data,
Expand Down
11 changes: 11 additions & 0 deletions sdks/c/src/event_queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,12 @@ pub(crate) type CRuntimeShutdownFn = extern "C" fn(*mut crate::CBoxliteError, *m
pub type CExecutionWaitCb = Option<extern "C" fn(c_int, *mut crate::CBoxliteError, *mut c_void)>;
pub(crate) type CExecutionWaitFn = extern "C" fn(c_int, *mut crate::CBoxliteError, *mut c_void);

/// Execution wait completion with structured result metadata.
pub type CExecutionWaitResultCb =
Option<extern "C" fn(c_int, bool, *mut crate::CBoxliteError, *mut c_void)>;
pub(crate) type CExecutionWaitResultFn =
extern "C" fn(c_int, bool, *mut crate::CBoxliteError, *mut c_void);

/// Execution kill completion.
pub type CExecutionKillCb = Option<extern "C" fn(*mut crate::CBoxliteError, *mut c_void)>;
pub(crate) type CExecutionKillFn = extern "C" fn(*mut crate::CBoxliteError, *mut c_void);
Expand Down Expand Up @@ -403,6 +409,11 @@ pub enum RuntimeEvent {
user_data: usize,
result: Result<i32, BoxliteError>,
},
WaitResult {
cb: CExecutionWaitResultFn,
user_data: usize,
result: Result<(i32, bool), BoxliteError>,
},
Kill {
cb: CExecutionKillFn,
user_data: usize,
Expand Down
73 changes: 71 additions & 2 deletions sdks/c/src/exec/execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ use crate::box_handle::BoxHandle;
use crate::error::{BoxliteErrorCode, FFIError, error_to_code, null_pointer_error, write_error};
use crate::event_queue::{
CBoxExitCb, CBoxExitFn, CBoxStderrCb, CBoxStderrFn, CBoxStdoutCb, CBoxStdoutFn,
CExecutionKillCb, CExecutionResizeCb, CExecutionSignalCb, CExecutionWaitCb, EventQueue,
RuntimeEvent, push_event,
CExecutionKillCb, CExecutionResizeCb, CExecutionSignalCb, CExecutionWaitCb,
CExecutionWaitResultCb, EventQueue, RuntimeEvent, push_event,
};
use crate::{CBoxHandle, CBoxliteError, CExecutionHandle};

Expand Down Expand Up @@ -168,6 +168,26 @@ pub unsafe extern "C" fn boxlite_execution_wait(
execution_wait(execution, cb, user_data, out_error)
}

/// Schedules an asynchronous wait and invokes `cb` with the exit code and
/// timeout status, passing `user_data` through unchanged.
///
/// Returns immediately with a validation or scheduling status. When non-null,
/// `out_error` receives details for synchronous failures.
///
/// # Safety
///
/// `execution` must be a valid handle, `cb` must be callable for the duration
/// of the asynchronous operation, and `out_error` must be null or writable.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn boxlite_execution_wait_result(
execution: *mut CExecutionHandle,
cb: CExecutionWaitResultCb,
user_data: *mut c_void,
out_error: *mut CBoxliteError,
) -> BoxliteErrorCode {
execution_wait_result(execution, cb, user_data, out_error)
}

#[unsafe(no_mangle)]
pub unsafe extern "C" fn boxlite_execution_kill(
execution: *mut CExecutionHandle,
Expand Down Expand Up @@ -503,6 +523,45 @@ unsafe fn execution_wait(
}
}

unsafe fn execution_wait_result(
execution: *mut ExecutionHandle,
cb: CExecutionWaitResultCb,
user_data: *mut c_void,
out_error: *mut FFIError,
) -> BoxliteErrorCode {
unsafe {
if execution.is_null() {
write_error(out_error, null_pointer_error("execution"));
return BoxliteErrorCode::InvalidArgument;
}
let cb = crate::unwrap_cb_or_return!(cb, out_error);

let exec_ref = &*execution;
let exec_arc = exec_ref.execution.clone();
let queue = exec_ref.queue.clone();
let user_data_addr = user_data as usize;
let process_completed = exec_ref.process_completed.clone();

exec_ref.tokio_rt.spawn(async move {
let result = wait_result_on_clone(&exec_arc).await;
if result.is_ok() {
process_completed.store(true, Ordering::Release);
}
push_event(
&queue,
RuntimeEvent::WaitResult {
cb,
user_data: user_data_addr,
result,
},
)
.await;
});

BoxliteErrorCode::Ok
}
}

unsafe fn execution_kill(
execution: *mut ExecutionHandle,
cb: CExecutionKillCb,
Expand Down Expand Up @@ -824,6 +883,16 @@ async fn wait_on_clone(slot: &Mutex<Option<Execution>>) -> Result<i32, BoxliteEr
clone.wait().await.map(|status| status.exit_code)
}

async fn wait_result_on_clone(
slot: &Mutex<Option<Execution>>,
) -> Result<(i32, bool), BoxliteError> {
let clone = snapshot_execution(slot)?;
clone
.wait()
.await
.map(|status| (status.exit_code, status.timed_out))
}

async fn kill_on_clone(slot: &Mutex<Option<Execution>>) -> Result<(), BoxliteError> {
let clone = snapshot_execution(slot)?;
clone.kill().await
Expand Down
Loading
Loading