From 10a4e29ea3e03df8056fd77ba40a9efb641f659f Mon Sep 17 00:00:00 2001 From: Navid Mitchell Date: Thu, 13 Aug 2026 09:14:48 +0000 Subject: [PATCH 1/2] feat(sdks): expose init exit code on box info The core records the init's exit code when a box stops because its command exited, but every SDK binding dropped the field on the way out, leaving callers with no way to read a completed workload's status. Node and Python surface it as a nullable field. C pairs `exit_code` with a `has_exit_code` flag because 0 is a valid exit code and cannot double as "none recorded" the way `pid` does; Go maps that pair to a `*int`. --- docs/reference/nodejs/README.md | 2 +- docs/reference/python/README.md | 2 +- sdks/c/include/boxlite.h | 6 ++++++ sdks/c/src/event_queue.rs | 4 ++++ sdks/c/src/info.rs | 8 ++++++++ sdks/go/info.go | 10 ++++++++++ sdks/go/info_cgo_dev_test.go | 22 ++++++++++++++++++++++ sdks/go/info_cgo_test_support_dev.go | 14 ++++++++++++++ sdks/node/lib/native-contracts.ts | 2 ++ sdks/node/src/info.rs | 21 +++++++++++++++++++++ sdks/python/src/info.rs | 21 +++++++++++++++++++++ 11 files changed, 110 insertions(+), 2 deletions(-) diff --git a/docs/reference/nodejs/README.md b/docs/reference/nodejs/README.md index f0aee026c..58a0ce0ab 100644 --- a/docs/reference/nodejs/README.md +++ b/docs/reference/nodejs/README.md @@ -217,7 +217,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 | | `createdAt` | `string` | Creation timestamp (ISO 8601) | | `startedAt` | `string \| undefined` | Most recent successful container start timestamp (RFC 3339); absent if not recorded or unavailable over REST | | `image` | `string` | OCI image reference or rootfs path | diff --git a/docs/reference/python/README.md b/docs/reference/python/README.md index 9e0d01160..d302e01f4 100644 --- a/docs/reference/python/README.md +++ b/docs/reference/python/README.md @@ -244,7 +244,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 | | `created_at` | `str` | ISO 8601 creation timestamp | | `started_at` | `str \| None` | Most recent successful container start timestamp (RFC 3339); `None` if not recorded or unavailable over REST | | `image` | `str` | OCI image used | diff --git a/sdks/c/include/boxlite.h b/sdks/c/include/boxlite.h index f5293dda4..2db7a760f 100644 --- a/sdks/c/include/boxlite.h +++ b/sdks/c/include/boxlite.h @@ -324,6 +324,12 @@ typedef struct CBoxInfo { // PID. 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 diff --git a/sdks/c/src/event_queue.rs b/sdks/c/src/event_queue.rs index c7b9c2e03..0ddc303aa 100644 --- a/sdks/c/src/event_queue.rs +++ b/sdks/c/src/event_queue.rs @@ -1275,6 +1275,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); @@ -1347,6 +1349,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(); diff --git a/sdks/c/src/info.rs b/sdks/c/src/info.rs index b9f502cab..2a73d5638 100644 --- a/sdks/c/src/info.rs +++ b/sdks/c/src/info.rs @@ -84,6 +84,12 @@ pub struct CBoxInfo { /// PID. 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)] @@ -250,6 +256,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 }, } } } diff --git a/sdks/go/info.go b/sdks/go/info.go index 05cc07c4f..7d5d942cd 100644 --- a/sdks/go/info.go +++ b/sdks/go/info.go @@ -67,6 +67,10 @@ type BoxInfo struct { // State alone cannot answer whether the current init launched — a box reports // Running from the moment its VM is up, before its init is started. 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. @@ -159,6 +163,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), @@ -175,6 +184,7 @@ func cBoxInfoToGo(info *C.CBoxInfo) BoxInfo { CreatedAt: time.Unix(int64(info.created_at), 0), StartedAt: boxStartedAt, + ExitCode: exitCode, } } diff --git a/sdks/go/info_cgo_dev_test.go b/sdks/go/info_cgo_dev_test.go index b2ece0576..ca7225979 100644 --- a/sdks/go/info_cgo_dev_test.go +++ b/sdks/go/info_cgo_dev_test.go @@ -65,3 +65,25 @@ func TestCNetworkInfoToGoTraversesNativeStruct(t *testing.T) { }) } } + +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) + } + }) + } +} diff --git a/sdks/go/info_cgo_test_support_dev.go b/sdks/go/info_cgo_test_support_dev.go index 6f0d9ced8..8d6fb7fa9 100644 --- a/sdks/go/info_cgo_test_support_dev.go +++ b/sdks/go/info_cgo_test_support_dev.go @@ -65,3 +65,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, + } +} diff --git a/sdks/node/lib/native-contracts.ts b/sdks/node/lib/native-contracts.ts index c956d3bb9..6f9e7a402 100644 --- a/sdks/node/lib/native-contracts.ts +++ b/sdks/node/lib/native-contracts.ts @@ -248,6 +248,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 { diff --git a/sdks/node/src/info.rs b/sdks/node/src/info.rs index 012282de6..dcd6893aa 100644 --- a/sdks/node/src/info.rs +++ b/sdks/node/src/info.rs @@ -136,6 +136,10 @@ pub struct JsBoxStateInfo { /// Process ID of the VMM subprocess (undefined if not running) pub pid: Option, + + /// Init exit code, when the box stopped because its command exited + #[napi(js_name = "exitCode")] + pub exit_code: Option, } fn status_to_string(status: BoxStatus) -> String { @@ -157,6 +161,7 @@ impl From for JsBoxStateInfo { status: status_to_string(state_info.status), running: state_info.running, pid: state_info.pid, + exit_code: state_info.exit_code, } } } @@ -281,6 +286,22 @@ mod tests { } } + #[test] + fn box_state_conversion_preserves_recorded_exit_code() { + let mut exited = core_info(None); + exited.status = BoxStatus::Stopped; + exited.pid = None; + exited.exit_code = Some(3); + + let resolved = JsBoxInfo::from(exited); + assert_eq!(resolved.state.status, "stopped"); + assert!(!resolved.state.running); + assert_eq!(resolved.state.exit_code, Some(3)); + + let running = JsBoxInfo::from(core_info(None)); + assert_eq!(running.state.exit_code, None); + } + #[test] fn box_info_conversion_preserves_network_and_publication_state() { let resolved = JsBoxInfo::from(core_info(Some(NetworkInfo { diff --git a/sdks/python/src/info.rs b/sdks/python/src/info.rs index 32f5d372a..557a4623d 100644 --- a/sdks/python/src/info.rs +++ b/sdks/python/src/info.rs @@ -221,6 +221,9 @@ pub struct PyBoxStateInfo { pub(crate) running: bool, #[pyo3(get)] pub(crate) pid: Option, + /// Init exit code, when the box stopped because its command exited. + #[pyo3(get)] + pub(crate) exit_code: Option, } #[pymethods] @@ -230,6 +233,7 @@ impl PyBoxStateInfo { "status": self.status, "running": self.running, "pid": self.pid, + "exit_code": self.exit_code, })) .unwrap_or_default() } @@ -265,6 +269,7 @@ impl From for PyBoxStateInfo { status: status_to_string(state_info.status), running: state_info.running, pid: state_info.pid, + exit_code: state_info.exit_code, } } } @@ -396,6 +401,22 @@ mod tests { } } + #[test] + fn box_state_conversion_preserves_recorded_exit_code() { + let mut exited = core_info(None); + exited.status = BoxStatus::Stopped; + exited.pid = None; + exited.exit_code = Some(3); + + let resolved = PyBoxInfo::from(exited); + assert_eq!(resolved.state.status, "stopped"); + assert!(!resolved.state.running); + assert_eq!(resolved.state.exit_code, Some(3)); + + 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 { From b0e9d10f738aa0a336cf685d0d02e889a89dac62 Mon Sep 17 00:00:00 2001 From: Navid Mitchell Date: Thu, 13 Aug 2026 21:08:17 +0000 Subject: [PATCH 2/2] docs(sdks): document and test the exit-code value/flag pair Document on `CBoxInfo::from_box_info` how `None` and `Some(0)` differ in the `exit_code` / `has_exit_code` pair, and cover that mapping directly. The Node and Python conversions now assert `Some(0)` survives, since collapsing it to `None` would report a successful workload as one that never ran, and the Python case asserts `__repr__` carries the field users print. --- docs/reference/nodejs/README.md | 2 +- docs/reference/python/README.md | 2 +- sdks/c/src/info.rs | 53 ++++++++++++++++++++++++++++++++- sdks/go/info_cgo_dev_test.go | 3 ++ sdks/node/src/info.rs | 22 ++++++++------ sdks/python/src/info.rs | 30 +++++++++++++------ 6 files changed, 91 insertions(+), 21 deletions(-) diff --git a/docs/reference/nodejs/README.md b/docs/reference/nodejs/README.md index 58a0ce0ab..ec203f65b 100644 --- a/docs/reference/nodejs/README.md +++ b/docs/reference/nodejs/README.md @@ -217,7 +217,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` and `exitCode` 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` | Most recent successful container start timestamp (RFC 3339); absent if not recorded or unavailable over REST | | `image` | `string` | OCI image reference or rootfs path | diff --git a/docs/reference/python/README.md b/docs/reference/python/README.md index d302e01f4..2ed9a41c0 100644 --- a/docs/reference/python/README.md +++ b/docs/reference/python/README.md @@ -244,7 +244,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` and `exit_code` 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` | Most recent successful container start timestamp (RFC 3339); `None` if not recorded or unavailable over REST | | `image` | `str` | OCI image used | diff --git a/sdks/c/src/info.rs b/sdks/c/src/info.rs index 2a73d5638..12e361ce4 100644 --- a/sdks/c/src/info.rs +++ b/sdks/c/src/info.rs @@ -236,6 +236,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()), @@ -510,10 +516,55 @@ mod tests { use boxlite::runtime::options::PortProtocol; use boxlite::{NetworkInfo, NetworkMode, PublishedPort}; + use std::collections::HashMap; + use std::time::SystemTime; + + use boxlite::{BoxID, BoxInfo, BoxStatus, HealthStatus}; + use crate::options::BoxlitePortProtocol; use crate::{FREE_STR_CALLS, FREE_STR_LOCK}; - use super::{BoxliteNetworkMode, free_network_info, network_to_c_ptr}; + use super::{BoxliteNetworkMode, CBoxInfo, 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) -> 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) }; + } + } #[test] fn typed_network_info_preserves_network_and_publication_state() { diff --git a/sdks/go/info_cgo_dev_test.go b/sdks/go/info_cgo_dev_test.go index ca7225979..c3309b411 100644 --- a/sdks/go/info_cgo_dev_test.go +++ b/sdks/go/info_cgo_dev_test.go @@ -66,6 +66,9 @@ 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 diff --git a/sdks/node/src/info.rs b/sdks/node/src/info.rs index dcd6893aa..c376abf1a 100644 --- a/sdks/node/src/info.rs +++ b/sdks/node/src/info.rs @@ -286,17 +286,21 @@ 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() { - let mut exited = core_info(None); - exited.status = BoxStatus::Stopped; - exited.pid = None; - exited.exit_code = Some(3); - - let resolved = JsBoxInfo::from(exited); - assert_eq!(resolved.state.status, "stopped"); - assert!(!resolved.state.running); - assert_eq!(resolved.state.exit_code, Some(3)); + 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); diff --git a/sdks/python/src/info.rs b/sdks/python/src/info.rs index 557a4623d..18e34f5f1 100644 --- a/sdks/python/src/info.rs +++ b/sdks/python/src/info.rs @@ -401,17 +401,29 @@ 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() { - let mut exited = core_info(None); - exited.status = BoxStatus::Stopped; - exited.pid = None; - exited.exit_code = Some(3); - - let resolved = PyBoxInfo::from(exited); - assert_eq!(resolved.state.status, "stopped"); - assert!(!resolved.state.running); - assert_eq!(resolved.state.exit_code, Some(3)); + 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);