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
2 changes: 1 addition & 1 deletion docs/reference/nodejs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ Metadata about a box.
|-------|------|-------------|
| `id` | `string` | Unique box identifier (ULID) |
| `name` | `string \| undefined` | User-defined name |
| `state` | `JsBoxStateInfo` | Runtime state with `status`, `running`, and optional `pid` fields |
| `state` | `JsBoxStateInfo` | Runtime state with `status`, `running`, and optional `pid` and `exitCode` fields. `exitCode` holds the init command's exit code once the box has stopped because that command exited, and is absent otherwise |
| `createdAt` | `string` | Creation timestamp (ISO 8601) |
| `startedAt` | `string \| undefined` | Time when the box most recently entered `Running` (RFC 3339); absent if not recorded or unavailable over REST |
| `image` | `string` | OCI image reference or rootfs path |
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ Metadata about a box.
|-------|------|-------------|
| `id` | `str` | Unique box identifier (ULID) |
| `name` | `str \| None` | Optional user-assigned name |
| `state` | `BoxStateInfo` | Runtime state with `status`, `running`, and nullable `pid` fields |
| `state` | `BoxStateInfo` | Runtime state with `status`, `running`, and nullable `pid` and `exit_code` fields. `exit_code` holds the init command's exit code once the box has stopped because that command exited, and is `None` otherwise |
| `created_at` | `str` | ISO 8601 creation timestamp |
| `started_at` | `str \| None` | Time when the box most recently entered `Running` (RFC 3339); `None` if not recorded or unavailable over REST |
| `image` | `str` | OCI image used |
Expand Down
6 changes: 6 additions & 0 deletions sdks/c/include/boxlite.h
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,12 @@ typedef struct CBoxInfo {
// Milliseconds — not `created_at`'s seconds — preserve sub-second ordering
// against a job's timeline.
int64_t started_at;
// Init exit code, when the box stopped because its command exited. Read it
// only when [`Self::has_exit_code`] is nonzero — `0` is a valid exit code,
// so it cannot double as "none recorded" the way [`Self::pid`] does.
int exit_code;
// Nonzero when [`Self::exit_code`] holds a recorded exit code.
int has_exit_code;
} CBoxInfo;

// Box info completion. On success the callback owns the non-null metadata and
Expand Down
4 changes: 4 additions & 0 deletions sdks/c/src/event_queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1300,6 +1300,8 @@ mod owned_ffi_ptr_nested_leak_tests {
}]),
))),
started_at: 0,
exit_code: 0,
has_exit_code: 0,
});

let owned = OwnedFfiPtr::new_with(payload, crate::info::free_box_info_ptr);
Expand Down Expand Up @@ -1372,6 +1374,8 @@ mod owned_ffi_ptr_nested_leak_tests {
created_at: 0,
network: std::ptr::null_mut(),
started_at: 0,
exit_code: 0,
has_exit_code: 0,
}];
let items_ptr = items_vec.as_mut_ptr();
let items_len = items_vec.len();
Expand Down
68 changes: 64 additions & 4 deletions sdks/c/src/info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ pub struct CBoxInfo {
/// Milliseconds — not `created_at`'s seconds — preserve sub-second ordering
/// against a job's timeline.
pub started_at: i64,
/// Init exit code, when the box stopped because its command exited. Read it
/// only when [`Self::has_exit_code`] is nonzero — `0` is a valid exit code,
/// so it cannot double as "none recorded" the way [`Self::pid`] does.
pub exit_code: c_int,
/// Nonzero when [`Self::exit_code`] holds a recorded exit code.
pub has_exit_code: c_int,
}

#[repr(C)]
Expand Down Expand Up @@ -271,6 +277,12 @@ fn status_to_str(status: BoxStatus) -> &'static str {
}

impl CBoxInfo {
/// Build the C view of a box, allocating the owned strings and network
/// metadata the caller must release with `boxlite_free_box_info`.
///
/// The optional init exit code becomes a value/flag pair: `None` yields
/// `exit_code = 0` with `has_exit_code = 0`, while `Some(code)` — `0`
/// included — yields that code with `has_exit_code = 1`.
pub fn from_box_info(info: &boxlite::runtime::types::BoxInfo) -> Self {
CBoxInfo {
id: to_c_str(info.id.as_ref()),
Expand All @@ -291,6 +303,8 @@ impl CBoxInfo {
created_at: info.created_at.timestamp(),
network: network_to_c_ptr(&info.network),
started_at: info.started_at.map(|at| at.timestamp_millis()).unwrap_or(0),
exit_code: info.exit_code.unwrap_or(0) as c_int,
has_exit_code: if info.exit_code.is_some() { 1 } else { 0 },
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
}
Expand Down Expand Up @@ -537,18 +551,64 @@ unsafe fn box_list(

#[cfg(test)]
mod tests {
use std::ffi::CStr;
use std::collections::HashMap;
use std::ffi::{CStr, c_char};
use std::ptr::NonNull;
use std::time::SystemTime;

use boxlite::runtime::options::PortProtocol;
use boxlite::runtime::types::NetworkDirectionInfo;
use boxlite::{NetworkInfo, NetworkMode, PublishedPort};
use boxlite::{
BoxID, BoxInfo, BoxStatus, HealthStatus, NetworkInfo, NetworkMode, PublishedPort,
};

use crate::options::BoxlitePortProtocol;
use crate::{FREE_STR_CALLS, FREE_STR_LOCK};

use super::{BoxliteNetworkMode, CNetworkInfo, free_network_info, network_to_c_ptr};
use std::ffi::c_char;
use super::{
BoxliteNetworkMode, CBoxInfo, CNetworkInfo, free_box_info, free_network_info,
network_to_c_ptr,
};

/// A stopped box carrying the given init exit code, with every other field
/// held constant so the exit-code mapping is what varies.
fn stopped_box(exit_code: Option<i32>) -> BoxInfo {
BoxInfo {
id: BoxID::parse("box-c-info").unwrap(),
name: Some("c-info".to_string()),
status: BoxStatus::Stopped,
created_at: SystemTime::UNIX_EPOCH.into(),
last_updated: SystemTime::UNIX_EPOCH.into(),
pid: None,
image: "alpine:latest".to_string(),
cpus: 2,
memory_mib: 512,
network: None,
labels: HashMap::new(),
auto_stop: 0,
auto_delete: 0,
auto_resume: true,
health_status: HealthStatus::default(),
exit_code,
started_at: None,
}
}

/// The flag is what separates "no code recorded" from a recorded `0`, which
/// `exit_code` alone cannot express.
#[test]
fn box_info_maps_exit_code_through_the_has_exit_code_flag() {
let _guard = FREE_STR_LOCK.lock().unwrap();

for (recorded, expected_code, expected_flag) in
[(None, 0, 0), (Some(0), 0, 1), (Some(3), 3, 1)]
{
let mut c_info = CBoxInfo::from_box_info(&stopped_box(recorded));
assert_eq!(c_info.exit_code, expected_code, "code for {recorded:?}");
assert_eq!(c_info.has_exit_code, expected_flag, "flag for {recorded:?}");
unsafe { free_box_info(&mut c_info) };
}
}

/// Callers compiled against the pre-split header read `mode`,
/// `allow_net`, `allow_net_count` and `published_ports` at the offsets
Expand Down
10 changes: 10 additions & 0 deletions sdks/go/info.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ type BoxInfo struct {
// the configured user task becomes ready, exits, or completes; those are
// workload lifecycle outcomes.
StartedAt time.Time
// ExitCode is the init's exit code, when the box stopped because its command
// exited; nil when no code has been recorded. A pointer, not a plain int,
// because 0 is the most common valid exit code.
ExitCode *int
}

// Info returns information about the box.
Expand Down Expand Up @@ -175,6 +179,11 @@ func cBoxInfoToGo(info *C.CBoxInfo) BoxInfo {
if ms := int64(info.started_at); ms > 0 {
boxStartedAt = time.UnixMilli(ms)
}
var exitCode *int
if info.has_exit_code != 0 {
code := int(info.exit_code)
exitCode = &code
}
return BoxInfo{
ID: cString(info.id),
Name: cString(info.name),
Expand All @@ -191,6 +200,7 @@ func cBoxInfoToGo(info *C.CBoxInfo) BoxInfo {
CreatedAt: time.Unix(int64(info.created_at), 0),

StartedAt: boxStartedAt,
ExitCode: exitCode,
}
}

Expand Down
25 changes: 25 additions & 0 deletions sdks/go/info_cgo_dev_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,28 @@ func TestCNetworkInfoToGoTraversesNativeStruct(t *testing.T) {
})
}
}

// TestCBoxInfoToGoTraversesExitCode checks that the has_exit_code flag, not the
// code's value, decides whether ExitCode is set — a recorded 0 is a successful
// workload, not an absent result.
func TestCBoxInfoToGoTraversesExitCode(t *testing.T) {
fixtures := cBoxInfoExitCodeTestFixtures()
zero, three := 0, 3
tests := []struct {
name string
got *int
want *int
}{
{name: "no code recorded", got: fixtures[0]},
{name: "nonzero exit code", got: fixtures[1], want: &three},
{name: "zero exit code is not absence", got: fixtures[2], want: &zero},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if !reflect.DeepEqual(test.got, test.want) {
t.Fatalf("cBoxInfoToGo().ExitCode = %v, want %v", test.got, test.want)
}
})
}
}
14 changes: 14 additions & 0 deletions sdks/go/info_cgo_test_support_dev.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,17 @@ func cNetworkInfoTraversalTestFixtures() [4]*NetworkInfo {
cNetworkInfoToGo(&populated),
}
}

// A zero CBoxInfo traverses safely — cString and cNetworkInfoToGo both tolerate
// NULL — so these fixtures vary only the exit-code fields.
func cBoxInfoExitCodeTestFixtures() [3]*int {
unrecorded := C.CBoxInfo{}
exitedNonZero := C.CBoxInfo{exit_code: 3, has_exit_code: 1}
exitedZero := C.CBoxInfo{has_exit_code: 1}

return [3]*int{
cBoxInfoToGo(&unrecorded).ExitCode,
cBoxInfoToGo(&exitedNonZero).ExitCode,
cBoxInfoToGo(&exitedZero).ExitCode,
}
}
2 changes: 2 additions & 0 deletions sdks/node/lib/native-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,8 @@ export interface JsBoxStateInfo {
status: string;
running: boolean;
pid?: number;
/** Init exit code, when the box stopped because its command exited. */
exitCode?: number;
}

export interface JsPublishedPort {
Expand Down
25 changes: 25 additions & 0 deletions sdks/node/src/info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@ pub struct JsBoxStateInfo {

/// Process ID of the VMM subprocess (undefined if not running)
pub pid: Option<u32>,

/// Init exit code, when the box stopped because its command exited
#[napi(js_name = "exitCode")]
pub exit_code: Option<i32>,
}

fn status_to_string(status: BoxStatus) -> String {
Expand All @@ -187,6 +191,7 @@ impl From<BoxStateInfo> for JsBoxStateInfo {
status: status_to_string(state_info.status),
running: state_info.running,
pid: state_info.pid,
exit_code: state_info.exit_code,
}
}
}
Expand Down Expand Up @@ -312,6 +317,26 @@ mod tests {
}
}

/// `Some(0)` must survive as `Some(0)`: a successful workload is the common
/// case, and collapsing it to `None` would report success as "never ran".
#[test]
fn box_state_conversion_preserves_recorded_exit_code() {
for recorded in [Some(0), Some(3)] {
let mut exited = core_info(None);
exited.status = BoxStatus::Stopped;
exited.pid = None;
exited.exit_code = recorded;

let resolved = JsBoxInfo::from(exited);
assert_eq!(resolved.state.status, "stopped");
assert!(!resolved.state.running);
assert_eq!(resolved.state.exit_code, recorded);
}

let running = JsBoxInfo::from(core_info(None));
assert_eq!(running.state.exit_code, None);
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
#[test]
fn box_info_conversion_preserves_network_and_publication_state() {
let resolved = JsBoxInfo::from(core_info(Some(NetworkInfo::new(
Expand Down
33 changes: 33 additions & 0 deletions sdks/python/src/info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,9 @@ pub struct PyBoxStateInfo {
pub(crate) running: bool,
#[pyo3(get)]
pub(crate) pid: Option<u32>,
/// Init exit code, when the box stopped because its command exited.
#[pyo3(get)]
pub(crate) exit_code: Option<i32>,
}

#[pymethods]
Expand All @@ -280,6 +283,7 @@ impl PyBoxStateInfo {
"status": self.status,
"running": self.running,
"pid": self.pid,
"exit_code": self.exit_code,
}))
.unwrap_or_default()
}
Expand Down Expand Up @@ -315,6 +319,7 @@ impl From<BoxStateInfo> for PyBoxStateInfo {
status: status_to_string(state_info.status),
running: state_info.running,
pid: state_info.pid,
exit_code: state_info.exit_code,
}
}
}
Expand Down Expand Up @@ -447,6 +452,34 @@ mod tests {
}
}

/// `Some(0)` must survive as `Some(0)`: a successful workload is the common
/// case, and collapsing it to `None` would report success as "never ran".
/// `__repr__` carries the field too, since it is what users print.
#[test]
fn box_state_conversion_preserves_recorded_exit_code() {
for recorded in [Some(0), Some(3)] {
let mut exited = core_info(None);
exited.status = BoxStatus::Stopped;
exited.pid = None;
exited.exit_code = recorded;

let resolved = PyBoxInfo::from(exited);
assert_eq!(resolved.state.status, "stopped");
assert!(!resolved.state.running);
assert_eq!(resolved.state.exit_code, recorded);

let repr = resolved.state.__repr__();
let code = recorded.expect("loop yields Some");
assert!(
repr.contains(&format!("\"exit_code\": {code}")),
"repr must carry the exit code, got: {repr}"
);
}

let running = PyBoxInfo::from(core_info(None));
assert_eq!(running.state.exit_code, None);
}

#[test]
fn box_info_conversion_preserves_network_and_publication_state() {
let resolved = PyBoxInfo::from(core_info(Some(NetworkInfo::new(
Expand Down