From f726ca60399568f531d45a772ddcb8dfc968af73 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Wed, 13 May 2026 18:32:24 -0700 Subject: [PATCH 1/4] sandbox: split spawn() into create()/start() for OCI-style lifecycle Signed-off-by: Cong Wang --- crates/sandlock-cli/src/main.rs | 3 +- crates/sandlock-core/src/pipeline.rs | 9 +- crates/sandlock-core/src/sandbox.rs | 102 ++++++++++++------ .../tests/integration/test_checkpoint.rs | 15 ++- .../tests/integration/test_resource.rs | 6 +- .../tests/integration/test_sandbox.rs | 3 +- crates/sandlock-ffi/src/lib.rs | 39 ++++--- python/src/sandlock/_sdk.py | 9 +- python/src/sandlock/sandbox.py | 18 +++- python/tests/test_checkpoint.py | 5 +- 10 files changed, 143 insertions(+), 66 deletions(-) diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index cfaab05..623a84c 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -579,7 +579,8 @@ async fn run_command(args: RunArgs) -> Result<()> { let _ = network_registry::update_ports(®_name, ports.clone()); }); - policy.spawn(&cmd_strs).await?; + policy.create_interactive(&cmd_strs).await?; + policy.start()?; let pid = policy.pid().unwrap_or(0); let registered_hosts: Vec = policy diff --git a/crates/sandlock-core/src/pipeline.rs b/crates/sandlock-core/src/pipeline.rs index 6ea5f4a..0f30fc2 100644 --- a/crates/sandlock-core/src/pipeline.rs +++ b/crates/sandlock-core/src/pipeline.rs @@ -179,8 +179,9 @@ async fn run_pipeline(stages: Vec) -> Result { }; let cmd_refs: Vec<&str> = stage.args.iter().map(|s| s.as_str()).collect(); - sb.spawn_with_io(&cmd_refs, stdin_fd, stdout_fd, stderr_fd) + sb.create_with_io(&cmd_refs, stdin_fd, stdout_fd, stderr_fd) .await?; + sb.start()?; sandboxes.push(sb); } @@ -339,7 +340,8 @@ async fn run_gather( let mut sb = ns.stage.sandbox.clone().with_name(name); let stdout_fd = source_pipes[i].1.as_raw_fd(); let cmd_refs: Vec<&str> = ns.stage.args.iter().map(|s| s.as_str()).collect(); - sb.spawn_with_io(&cmd_refs, None, Some(stdout_fd), None).await?; + sb.create_with_io(&cmd_refs, None, Some(stdout_fd), None).await?; + sb.start()?; sandboxes.push(sb); } @@ -366,13 +368,14 @@ async fn run_gather( } let cmd_refs: Vec<&str> = consumer.args.iter().map(|s| s.as_str()).collect(); - consumer_sb.spawn_with_gather_io( + consumer_sb.create_with_gather_io( &cmd_refs, Some(stdin_fd), Some(cap_stdout_w.as_raw_fd()), Some(cap_stderr_w.as_raw_fd()), extra_fds, ).await?; + consumer_sb.start()?; sandboxes.push(consumer_sb); // Close pipe ends in parent diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index f8e7ee2..54c464e 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -538,6 +538,7 @@ struct Runtime { #[allow(clippy::type_complexity)] on_bind: Option) + Send + Sync>>, extra_handlers: Vec<(i64, Arc)>, + ready_w: Option, } /// Lifecycle state for the runtime. @@ -949,22 +950,31 @@ impl Sandbox { Ok(RunResult { exit_status, stdout, stderr }) } - /// Spawn a sandboxed process without waiting for it to exit. - /// Use `wait()` to collect the exit status when done. - #[doc(hidden)] - pub async fn spawn(&mut self, cmd: &[&str]) -> Result<(), crate::error::SandlockError> { - self.do_spawn(cmd, false).await + /// Fork the sandboxed child and install policy (seccomp + notif + /// supervisor + rlimits + landlock + COW + network/HTTP proxies). + /// The child is parked between policy install and `execve`; call + /// `start()` to release it. Stdout/stderr are captured for later + /// retrieval via `wait()`. + pub async fn create(&mut self, cmd: &[&str]) -> Result<(), crate::error::SandlockError> { + self.do_create(cmd, true).await } - /// Like `spawn` but captures stdout and stderr (available via `wait()`). - #[doc(hidden)] - pub async fn spawn_captured(&mut self, cmd: &[&str]) -> Result<(), crate::error::SandlockError> { - self.do_spawn(cmd, true).await + /// Like `create` but inherits stdio (no capture). + pub async fn create_interactive(&mut self, cmd: &[&str]) -> Result<(), crate::error::SandlockError> { + self.do_create(cmd, false).await } - /// Spawn with explicit stdin/stdout/stderr fd redirection. + /// Release a previously `create()`d child to `execve` the configured + /// command. Returns immediately; use `wait()` to collect the exit + /// status when the child finishes. + pub fn start(&mut self) -> Result<(), crate::error::SandlockError> { + self.do_start() + } + + /// Create with explicit stdin/stdout/stderr fd redirection. Child is + /// parked after policy install; call `start()` to release. #[doc(hidden)] - pub async fn spawn_with_io( + pub async fn create_with_io( &mut self, cmd: &[&str], stdin_fd: Option, @@ -973,12 +983,12 @@ impl Sandbox { ) -> Result<(), crate::error::SandlockError> { self.ensure_runtime()?; self.rt_mut().io_overrides = Some((stdin_fd, stdout_fd, stderr_fd)); - self.do_spawn(cmd, false).await + self.do_create(cmd, false).await } - /// Like `spawn_with_io` but also maps extra fds into the child. + /// Like `create_with_io` but also maps extra fds into the child. #[doc(hidden)] - pub async fn spawn_with_gather_io( + pub async fn create_with_gather_io( &mut self, cmd: &[&str], stdin_fd: Option, @@ -989,7 +999,7 @@ impl Sandbox { self.ensure_runtime()?; self.rt_mut().io_overrides = Some((stdin_fd, stdout_fd, stderr_fd)); self.rt_mut().extra_fds = extra_fds; - self.do_spawn(cmd, false).await + self.do_create(cmd, false).await } /// Commit COW writes to the original directory. @@ -1073,7 +1083,8 @@ impl Sandbox { &mut self, cmd: &[&str], ) -> Result { - self.do_spawn(cmd, true).await?; + self.do_create(cmd, true).await?; + self.do_start()?; self.wait().await } @@ -1082,7 +1093,8 @@ impl Sandbox { &mut self, cmd: &[&str], ) -> Result { - self.do_spawn(cmd, false).await?; + self.do_create(cmd, false).await?; + self.do_start()?; self.wait().await } @@ -1100,7 +1112,8 @@ impl Sandbox { let pending = sandbox_collect_extra_handlers(extra_handlers, self)?; self.ensure_runtime()?; self.rt_mut().extra_handlers = pending; - self.do_spawn(cmd, true).await?; + self.do_create(cmd, true).await?; + self.do_start()?; self.wait().await } @@ -1118,18 +1131,20 @@ impl Sandbox { let pending = sandbox_collect_extra_handlers(extra_handlers, self)?; self.ensure_runtime()?; self.rt_mut().extra_handlers = pending; - self.do_spawn(cmd, false).await?; + self.do_create(cmd, false).await?; + self.do_start()?; self.wait().await } - /// Dry-run: spawn, wait, collect filesystem changes, then abort. + /// Dry-run: create, start, wait, collect filesystem changes, then abort. pub async fn dry_run( &mut self, cmd: &[&str], ) -> Result { self.on_exit = BranchAction::Keep; self.on_error = BranchAction::Keep; - self.do_spawn(cmd, true).await?; + self.do_create(cmd, true).await?; + self.do_start()?; let run_result = self.wait().await?; let changes = self.collect_changes().await; self.do_abort().await; @@ -1143,7 +1158,8 @@ impl Sandbox { ) -> Result { self.on_exit = BranchAction::Keep; self.on_error = BranchAction::Keep; - self.do_spawn(cmd, false).await?; + self.do_create(cmd, false).await?; + self.do_start()?; let run_result = self.wait().await?; let changes = self.collect_changes().await; self.do_abort().await; @@ -1288,6 +1304,7 @@ impl Sandbox { http_acl_handle: None, on_bind: None, extra_handlers: Vec::new(), + ready_w: None, })); clones.push(clone_sb); } @@ -1332,7 +1349,8 @@ impl Sandbox { let mut reducer = self.clone().with_name(reducer_name); reducer.ensure_runtime()?; reducer.rt_mut().io_overrides = Some((Some(stdin_fds[0]), None, None)); - reducer.do_spawn(cmd, true).await?; + reducer.do_create(cmd, true).await?; + reducer.do_start()?; unsafe { libc::close(stdin_fds[0]) }; let _ = write_handle.await; @@ -1371,6 +1389,7 @@ impl Sandbox { http_acl_handle: None, on_bind: None, extra_handlers: Vec::new(), + ready_w: None, })); Ok(()) } @@ -1403,14 +1422,15 @@ impl Sandbox { } // ================================================================ - // Internal: do_spawn (the main fork/confinement entry point) + // Internal: do_create (fork + policy install; child parks at the + // ready_r read, awaiting do_start to release it to execve). // ================================================================ - async fn do_spawn(&mut self, cmd: &[&str], capture: bool) -> Result<(), crate::error::SandlockError> { + async fn do_create(&mut self, cmd: &[&str], capture: bool) -> Result<(), crate::error::SandlockError> { use std::ffi::CString; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; use crate::error::SandboxRuntimeError; - use crate::context::{PipePair, read_u32_fd, write_u32_fd}; + use crate::context::{PipePair, read_u32_fd}; use crate::cow::{CowBranch, overlayfs::OverlayBranch, branchfs::BranchFsBranch}; use crate::network; use crate::seccomp::ctx::SupervisorCtx; @@ -1563,7 +1583,8 @@ impl Sandbox { self.rt_mut()._stderr_read = stderr_r.map(|(r, _w)| r); self.rt_mut().child_pid = Some(pid); - self.rt_mut().state = RuntimeState::Running; + // State remains `Created` until `do_start` writes ready_w to release + // the child to execve. let pidfd = match syscall::pidfd_open(pid as u32, 0) { Ok(fd) => Some(fd), @@ -1788,11 +1809,30 @@ impl Sandbox { } } - write_u32_fd(pipes.ready_w.as_raw_fd(), 1) - .map_err(|e| SandboxRuntimeError::Child(format!("write ready signal: {}", e)))?; - self.rt_mut().pidfd = pidfd; + self.rt_mut().ready_w = Some(pipes.ready_w); + + Ok(()) + } + // ================================================================ + // Internal: do_start (release the parked child to execve) + // ================================================================ + + fn do_start(&mut self) -> Result<(), crate::error::SandlockError> { + use std::os::fd::AsRawFd; + use crate::context::write_u32_fd; + use crate::error::SandboxRuntimeError; + + if !matches!(self.rt().state, RuntimeState::Created) { + return Err(SandboxRuntimeError::Child("start() requires a created sandbox".into()).into()); + } + let ready_w = self.rt_mut().ready_w.take() + .ok_or_else(|| SandboxRuntimeError::Child("start() called without a prior create()".into()))?; + write_u32_fd(ready_w.as_raw_fd(), 1) + .map_err(|e| SandboxRuntimeError::Child(format!("write ready signal: {}", e)))?; + drop(ready_w); + self.rt_mut().state = RuntimeState::Running; Ok(()) } } @@ -1805,7 +1845,7 @@ impl Drop for Sandbox { fn drop(&mut self) { if let Some(ref mut rt) = self.runtime { if let Some(pid) = rt.child_pid { - if matches!(rt.state, RuntimeState::Running | RuntimeState::Paused) { + if matches!(rt.state, RuntimeState::Created | RuntimeState::Running | RuntimeState::Paused) { unsafe { libc::killpg(pid, libc::SIGKILL) }; let mut status: i32 = 0; unsafe { libc::waitpid(pid, &mut status, 0) }; diff --git a/crates/sandlock-core/tests/integration/test_checkpoint.rs b/crates/sandlock-core/tests/integration/test_checkpoint.rs index 52439a5..70f72ad 100644 --- a/crates/sandlock-core/tests/integration/test_checkpoint.rs +++ b/crates/sandlock-core/tests/integration/test_checkpoint.rs @@ -12,7 +12,8 @@ async fn test_checkpoint_save_load() { let mut sb = policy.clone().with_name("test"); // We need to spawn something that stays alive long enough to checkpoint // Use "sleep 60" — we'll kill it after checkpoint - sb.spawn(&["sleep", "60"]).await.unwrap(); + sb.create_interactive(&["sleep", "60"]).await.unwrap(); + sb.start().unwrap(); // Give it a moment to start tokio::time::sleep(std::time::Duration::from_millis(100)).await; @@ -58,7 +59,8 @@ async fn test_checkpoint_memory_maps() { .build().unwrap(); let mut sb = policy.clone().with_name("test"); - sb.spawn(&["sleep", "60"]).await.unwrap(); + sb.create_interactive(&["sleep", "60"]).await.unwrap(); + sb.start().unwrap(); tokio::time::sleep(std::time::Duration::from_millis(100)).await; let cp = sb.checkpoint().await.unwrap(); @@ -85,7 +87,8 @@ async fn test_checkpoint_app_state_roundtrip() { .build().unwrap(); let mut sb = policy.clone().with_name("test"); - sb.spawn(&["sleep", "60"]).await.unwrap(); + sb.create_interactive(&["sleep", "60"]).await.unwrap(); + sb.start().unwrap(); tokio::time::sleep(std::time::Duration::from_millis(100)).await; let mut cp = sb.checkpoint().await.unwrap(); @@ -118,7 +121,8 @@ async fn test_checkpoint_no_app_state_file() { .build().unwrap(); let mut sb = policy.clone().with_name("test"); - sb.spawn(&["sleep", "60"]).await.unwrap(); + sb.create_interactive(&["sleep", "60"]).await.unwrap(); + sb.start().unwrap(); tokio::time::sleep(std::time::Duration::from_millis(100)).await; let cp = sb.checkpoint().await.unwrap(); @@ -145,7 +149,8 @@ async fn test_checkpoint_process_info() { .build().unwrap(); let mut sb = policy.clone().with_name("test"); - sb.spawn(&["sleep", "60"]).await.unwrap(); + sb.create_interactive(&["sleep", "60"]).await.unwrap(); + sb.start().unwrap(); tokio::time::sleep(std::time::Duration::from_millis(100)).await; let expected_pid = sb.pid().unwrap(); diff --git a/crates/sandlock-core/tests/integration/test_resource.rs b/crates/sandlock-core/tests/integration/test_resource.rs index 9cadb04..835207e 100644 --- a/crates/sandlock-core/tests/integration/test_resource.rs +++ b/crates/sandlock-core/tests/integration/test_resource.rs @@ -286,7 +286,8 @@ async fn test_spawn_and_kill() { let policy = base_policy().build().unwrap(); let mut sb = policy.clone().with_name("test"); - sb.spawn(&["sleep", "300"]).await.unwrap(); + sb.create_interactive(&["sleep", "300"]).await.unwrap(); + sb.start().unwrap(); sb.kill().unwrap(); let result = sb.wait().await.unwrap(); @@ -326,7 +327,8 @@ async fn test_pause_resume() { let policy = base_policy().build().unwrap(); let mut sb = policy.clone().with_name("test"); - sb.spawn(&["sleep", "300"]).await.unwrap(); + sb.create_interactive(&["sleep", "300"]).await.unwrap(); + sb.start().unwrap(); sb.pause().expect("pause should succeed"); sb.resume().expect("resume should succeed"); diff --git a/crates/sandlock-core/tests/integration/test_sandbox.rs b/crates/sandlock-core/tests/integration/test_sandbox.rs index 01b3bae..febd2f6 100644 --- a/crates/sandlock-core/tests/integration/test_sandbox.rs +++ b/crates/sandlock-core/tests/integration/test_sandbox.rs @@ -152,7 +152,8 @@ async fn test_nested_sandbox() { // Spawn outer, then nest inner inside it let mut outer_sb = outer.clone().with_name("test"); - outer_sb.spawn(&["sleep", "10"]).await.unwrap(); + outer_sb.create_interactive(&["sleep", "10"]).await.unwrap(); + outer_sb.start().unwrap(); // The inner sandbox runs in the same parent process context — // Landlock from the outer is NOT applied to the parent, only to diff --git a/crates/sandlock-ffi/src/lib.rs b/crates/sandlock-ffi/src/lib.rs index edf2917..2132228 100644 --- a/crates/sandlock-ffi/src/lib.rs +++ b/crates/sandlock-ffi/src/lib.rs @@ -685,10 +685,10 @@ pub unsafe extern "C" fn sandlock_run( } // ---------------------------------------------------------------- -// Sandbox handle (spawn / wait — for pause/resume via PID) +// Sandbox handle (create / start / wait — for pause/resume via PID) // ---------------------------------------------------------------- -/// Opaque handle for a live (spawned) sandbox. +/// Opaque handle for a live sandbox. /// Owns both the Sandbox and the tokio Runtime that drives its supervisor. #[allow(non_camel_case_types)] pub struct sandlock_handle_t { @@ -696,16 +696,16 @@ pub struct sandlock_handle_t { runtime: tokio::runtime::Runtime, } -/// Spawn a sandboxed process without waiting. Returns a live handle. -/// Use `sandlock_handle_pid` to get the PID, then `sandlock_handle_wait` -/// to collect the result when done. +/// Fork the child and install policy; the child is parked between policy +/// install and execve. Returns a live handle. Call `sandlock_start` to +/// release the child to execve. /// /// # Safety /// `policy` must be a valid policy pointer. `name` may be NULL to /// auto-generate a sandbox name, or a valid NUL-terminated string. /// `argv` must point to `argc` C strings. #[no_mangle] -pub unsafe extern "C" fn sandlock_spawn( +pub unsafe extern "C" fn sandlock_create( policy: *const sandlock_sandbox_t, name: *const c_char, argv: *const *const c_char, @@ -730,17 +730,30 @@ pub unsafe extern "C" fn sandlock_spawn( None => policy.clone(), }; - if rt.block_on(sb.spawn_captured(&arg_refs)).is_err() { + if rt.block_on(sb.create(&arg_refs)).is_err() { return ptr::null_mut(); } Box::into_raw(Box::new(sandlock_handle_t { sandbox: sb, runtime: rt })) } +/// Release a previously `sandlock_create`d child to execve. Returns 0 on +/// success, -1 on error. +/// +/// # Safety +/// `h` must be a valid handle from `sandlock_create`. +#[no_mangle] +pub unsafe extern "C" fn sandlock_start(h: *mut sandlock_handle_t) -> c_int { + if h.is_null() { return -1; } + let h = &mut *h; + if h.sandbox.start().is_err() { return -1; } + 0 +} + /// Get the child PID. Returns 0 if not available. /// /// # Safety -/// `h` must be a valid handle from `sandlock_spawn`. +/// `h` must be a valid handle from `sandlock_create`. #[no_mangle] pub unsafe extern "C" fn sandlock_handle_pid(h: *const sandlock_handle_t) -> i32 { if h.is_null() { return 0; } @@ -750,7 +763,7 @@ pub unsafe extern "C" fn sandlock_handle_pid(h: *const sandlock_handle_t) -> i32 /// Wait for the sandbox to exit. Returns a result handle with stdout/stderr. /// /// # Safety -/// `h` must be a valid handle from `sandlock_spawn`. +/// `h` must be a valid handle from `sandlock_create`. #[no_mangle] pub unsafe extern "C" fn sandlock_handle_wait(h: *mut sandlock_handle_t) -> *mut sandlock_result_t { if h.is_null() { return ptr::null_mut(); } @@ -766,7 +779,7 @@ pub unsafe extern "C" fn sandlock_handle_wait(h: *mut sandlock_handle_t) -> *mut /// killed and a result with `ExitStatus::Timeout` is returned. /// /// # Safety -/// `h` must be a valid handle from `sandlock_spawn`. +/// `h` must be a valid handle from `sandlock_create`. #[no_mangle] pub unsafe extern "C" fn sandlock_handle_wait_timeout( h: *mut sandlock_handle_t, @@ -803,7 +816,7 @@ pub unsafe extern "C" fn sandlock_handle_wait_timeout( /// Returns null if port_remap is not active or no ports are mapped. /// /// # Safety -/// `h` must be a valid handle from `sandlock_spawn`. +/// `h` must be a valid handle from `sandlock_create`. #[no_mangle] pub unsafe extern "C" fn sandlock_handle_port_mappings( h: *const sandlock_handle_t, @@ -822,7 +835,7 @@ pub unsafe extern "C" fn sandlock_handle_port_mappings( /// Free a sandbox handle. Kills the process if still running. /// /// # Safety -/// `h` must be null or a valid handle from `sandlock_spawn`. +/// `h` must be null or a valid handle from `sandlock_create`. #[no_mangle] pub unsafe extern "C" fn sandlock_handle_free(h: *mut sandlock_handle_t) { if !h.is_null() { @@ -1708,7 +1721,7 @@ pub struct sandlock_checkpoint_t { /// then thawed. Returns NULL on error. /// /// # Safety -/// `h` must be a valid handle from `sandlock_spawn`. +/// `h` must be a valid handle from `sandlock_create`. #[no_mangle] pub unsafe extern "C" fn sandlock_handle_checkpoint( h: *mut sandlock_handle_t, diff --git a/python/src/sandlock/_sdk.py b/python/src/sandlock/_sdk.py index 37626e9..cd57321 100644 --- a/python/src/sandlock/_sdk.py +++ b/python/src/sandlock/_sdk.py @@ -226,11 +226,14 @@ def confine(policy: "PolicyDataclass") -> None: _lib.sandlock_run_interactive.restype = ctypes.c_int _lib.sandlock_run_interactive.argtypes = [_c_policy_p, ctypes.c_char_p, ctypes.POINTER(ctypes.c_char_p), ctypes.c_uint] -# Spawn handle +# Sandbox handle (create / start / wait) _c_handle_p = ctypes.c_void_p -_lib.sandlock_spawn.restype = _c_handle_p -_lib.sandlock_spawn.argtypes = [_c_policy_p, ctypes.c_char_p, ctypes.POINTER(ctypes.c_char_p), ctypes.c_uint] +_lib.sandlock_create.restype = _c_handle_p +_lib.sandlock_create.argtypes = [_c_policy_p, ctypes.c_char_p, ctypes.POINTER(ctypes.c_char_p), ctypes.c_uint] + +_lib.sandlock_start.restype = ctypes.c_int +_lib.sandlock_start.argtypes = [_c_handle_p] _lib.sandlock_handle_pid.restype = ctypes.c_int _lib.sandlock_handle_pid.argtypes = [_c_handle_p] diff --git a/python/src/sandlock/sandbox.py b/python/src/sandlock/sandbox.py index 1f7e6c3..6ebbbcc 100644 --- a/python/src/sandlock/sandbox.py +++ b/python/src/sandlock/sandbox.py @@ -484,12 +484,16 @@ def run(self, cmd: Sequence[str], timeout: float | None = None): argv, argc = _make_argv(list(cmd)) resolved_name = self._resolve_name() - # Spawn (non-blocking) so PID is available for pause/resume - self._handle = _lib.sandlock_spawn( + # Create (parked) so PID is available for pause/resume, then start. + self._handle = _lib.sandlock_create( native.ptr, _encode(resolved_name), argv, argc, ) if not self._handle: - return Result(success=False, exit_code=-1, error="sandlock_spawn failed") + return Result(success=False, exit_code=-1, error="sandlock_create failed") + if _lib.sandlock_start(self._handle) != 0: + _lib.sandlock_handle_free(self._handle) + self._handle = None + return Result(success=False, exit_code=-1, error="sandlock_start failed") try: timeout_ms = int(timeout * 1000) if timeout else 0 @@ -532,11 +536,15 @@ def start(self, cmd: Sequence[str]) -> None: argv, argc = _make_argv(list(cmd)) resolved_name = self._resolve_name() - self._handle = _lib.sandlock_spawn( + self._handle = _lib.sandlock_create( native.ptr, _encode(resolved_name), argv, argc, ) if not self._handle: - raise RuntimeError("sandlock_spawn failed") + raise RuntimeError("sandlock_create failed") + if _lib.sandlock_start(self._handle) != 0: + _lib.sandlock_handle_free(self._handle) + self._handle = None + raise RuntimeError("sandlock_start failed") def wait(self): """Wait for the running process to finish and return its Result. diff --git a/python/tests/test_checkpoint.py b/python/tests/test_checkpoint.py index 7979b8e..3ed129b 100644 --- a/python/tests/test_checkpoint.py +++ b/python/tests/test_checkpoint.py @@ -28,13 +28,14 @@ def running_sandbox(): sb = _policy() argv, argc = _make_argv(["sleep", "60"]) native = sb._ensure_native() - sb._handle = _lib.sandlock_spawn( + sb._handle = _lib.sandlock_create( native.ptr, _encode(sb._resolve_name()), argv, argc, ) - assert sb._handle, "spawn failed" + assert sb._handle, "create failed" + assert _lib.sandlock_start(sb._handle) == 0, "start failed" yield sb if sb._handle: _lib.sandlock_handle_free(sb._handle) From 1e2ccae76a659b2d2b97277bf4c6098e20a24895 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Wed, 13 May 2026 19:36:08 -0700 Subject: [PATCH 2/4] python: rename Sandbox.start() to Sandbox.spawn() Signed-off-by: Cong Wang --- python/README.md | 4 ++-- python/src/sandlock/_sdk.py | 2 +- python/src/sandlock/sandbox.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/python/README.md b/python/README.md index e89d71a..2064b4a 100644 --- a/python/README.md +++ b/python/README.md @@ -70,7 +70,7 @@ sandlock.Sandbox(**kwargs) Sandbox configuration and runtime handle. Holds both the policy (filesystem, network, resource limits, etc.) and runtime state. Construct once, then call -`run()`, `start()` + lifecycle methods, or use as a context manager. +`run()` (blocking) or `spawn()` + lifecycle methods, or use as a context manager. All config fields are optional. Unset fields mean "no restriction" unless noted otherwise. Runtime kwargs (`name`, `policy_fn`, `init_fn`, `work_fn`) @@ -246,7 +246,7 @@ Run a command, capturing stdout and stderr. result = sandbox.run(["python3", "-c", "print(42)"], timeout=10.0) ``` -#### `sandbox.start(cmd) -> None` +#### `sandbox.spawn(cmd) -> None` Spawn `cmd` without waiting. Use `pid`, `pause()`, `resume()`, `kill()`, and `wait()` to manage the process lifecycle. diff --git a/python/src/sandlock/_sdk.py b/python/src/sandlock/_sdk.py index cd57321..2659080 100644 --- a/python/src/sandlock/_sdk.py +++ b/python/src/sandlock/_sdk.py @@ -528,7 +528,7 @@ class Checkpoint: Usage:: sb = Sandbox(fs_readable=["/usr", "/lib"]) - sb.start(["sleep", "60"]) + sb.spawn(["sleep", "60"]) cp = sb.checkpoint() cp.save("my-checkpoint") diff --git a/python/src/sandlock/sandbox.py b/python/src/sandlock/sandbox.py index 6ebbbcc..159ac1f 100644 --- a/python/src/sandlock/sandbox.py +++ b/python/src/sandlock/sandbox.py @@ -518,10 +518,10 @@ def run(self, cmd: Sequence[str], timeout: float | None = None): stderr=stderr, ) - def start(self, cmd: Sequence[str]) -> None: + def spawn(self, cmd: Sequence[str]) -> None: """Spawn ``cmd`` in the sandbox without waiting for it to finish. - After calling ``start()``, use ``pid``, ``pause()``, ``resume()``, + After calling ``spawn()``, use ``pid``, ``pause()``, ``resume()``, ``kill()``, and ``wait()`` to manage the process lifecycle. Raises: From 3e7a42b7ebf9e2f8672f4630adf3346a4e6f819f Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Wed, 13 May 2026 19:39:30 -0700 Subject: [PATCH 3/4] python: expose create()/start() primitives behind spawn() for API parity with Rust Signed-off-by: Cong Wang --- python/README.md | 18 +++++++++++++++++ python/src/sandlock/sandbox.py | 36 ++++++++++++++++++++++++++++++---- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/python/README.md b/python/README.md index 2064b4a..ad9ecfe 100644 --- a/python/README.md +++ b/python/README.md @@ -253,6 +253,24 @@ and `wait()` to manage the process lifecycle. Raises `RuntimeError` if a process is already running. +Sugar for `create(cmd) + start()`; use those directly when you need the +fork-park-exec split (e.g. starting several sandboxes in lockstep, or +attaching external tracing to the parked PID before the child execs). + +#### `sandbox.create(cmd) -> None` + +Fork the sandboxed child and install policy. The child is parked between +policy install and `execve`; call `start()` to release it. `pid` is +available after this call but the child is not yet running user code. + +Raises `RuntimeError` if a process is already running. + +#### `sandbox.start() -> None` + +Release a previously `create()`d child to `execve`. + +Raises `RuntimeError` if no child has been created. + #### `sandbox.wait() -> Result` Wait for the running process to finish and return its `Result`. diff --git a/python/src/sandlock/sandbox.py b/python/src/sandlock/sandbox.py index 159ac1f..6efb578 100644 --- a/python/src/sandlock/sandbox.py +++ b/python/src/sandlock/sandbox.py @@ -518,11 +518,14 @@ def run(self, cmd: Sequence[str], timeout: float | None = None): stderr=stderr, ) - def spawn(self, cmd: Sequence[str]) -> None: - """Spawn ``cmd`` in the sandbox without waiting for it to finish. + def create(self, cmd: Sequence[str]) -> None: + """Fork the sandboxed child and install policy. The child is + parked between policy install and ``execve``; call ``start()`` + to release it. - After calling ``spawn()``, use ``pid``, ``pause()``, ``resume()``, - ``kill()``, and ``wait()`` to manage the process lifecycle. + ``pid`` is available after this call. The child is not running + user code yet -- it is blocked inside the sandlock supervisor + waiting for ``start()``. Raises: RuntimeError: If a process is already running. @@ -541,11 +544,36 @@ def spawn(self, cmd: Sequence[str]) -> None: ) if not self._handle: raise RuntimeError("sandlock_create failed") + + def start(self) -> None: + """Release a previously ``create()``d child to ``execve`` the + configured command. + + Raises: + RuntimeError: If no child has been created. + """ + from ._sdk import _lib + + if self._handle is None: + raise RuntimeError("sandbox has not been created") if _lib.sandlock_start(self._handle) != 0: _lib.sandlock_handle_free(self._handle) self._handle = None raise RuntimeError("sandlock_start failed") + def spawn(self, cmd: Sequence[str]) -> None: + """Spawn ``cmd`` in the sandbox without waiting for it to finish. + + Sugar for ``create(cmd) + start()``. After calling ``spawn()``, + use ``pid``, ``pause()``, ``resume()``, ``kill()``, and ``wait()`` + to manage the process lifecycle. + + Raises: + RuntimeError: If a process is already running. + """ + self.create(cmd) + self.start() + def wait(self): """Wait for the running process to finish and return its Result. From 51c9f1c70985673e802c849d0f5a56accebef8e7 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Wed, 13 May 2026 19:42:02 -0700 Subject: [PATCH 4/4] python: add lifecycle tests for spawn/create/start and Drop reap Signed-off-by: Cong Wang --- python/tests/test_lifecycle.py | 98 ++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 python/tests/test_lifecycle.py diff --git a/python/tests/test_lifecycle.py b/python/tests/test_lifecycle.py new file mode 100644 index 0000000..310e01f --- /dev/null +++ b/python/tests/test_lifecycle.py @@ -0,0 +1,98 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Tests for the Python Sandbox lifecycle methods: spawn, create, start, wait.""" + +import os +import time + +import pytest + +from sandlock import Sandbox +from sandlock._sdk import _lib + + +_BIN_READABLE = ["/usr", "/lib", "/lib64", "/bin", "/etc", "/proc"] + + +def _policy(**overrides): + defaults = {"fs_readable": _BIN_READABLE} + defaults.update(overrides) + return Sandbox(**defaults) + + +def _proc_state(pid: int) -> str | None: + """Return the single-letter process state from /proc//status, or + None if the entry no longer exists.""" + try: + with open(f"/proc/{pid}/status") as f: + for line in f: + if line.startswith("State:"): + return line.split()[1] + except FileNotFoundError: + return None + return None + + +class TestSpawn: + def test_spawn_then_wait_returns_result(self): + with _policy() as sb: + sb.spawn(["sh", "-c", "echo hello; exit 0"]) + result = sb.wait() + assert result.exit_code == 0 + assert b"hello" in result.stdout + + def test_spawn_raises_when_already_running(self): + with _policy() as sb: + sb.spawn(["sleep", "60"]) + try: + with pytest.raises(RuntimeError, match="already running"): + sb.spawn(["sleep", "60"]) + finally: + sb.kill() + sb.wait() + + +class TestCreateStart: + def test_create_sets_pid_and_parks_child(self): + with _policy() as sb: + sb.create(["sh", "-c", "echo from-child"]) + pid = sb.pid + assert pid is not None and pid > 0 + # The child is blocked inside the sandlock supervisor (read on + # the ready pipe) before execve — kernel reports interruptible + # sleep ('S'). + assert _proc_state(pid) == "S" + sb.start() + sb.wait() + + def test_create_then_start_runs_command(self): + with _policy() as sb: + sb.create(["sh", "-c", "echo two-step"]) + sb.start() + result = sb.wait() + assert result.exit_code == 0 + assert b"two-step" in result.stdout + + def test_start_raises_without_create(self): + with _policy() as sb: + with pytest.raises(RuntimeError, match="has not been created"): + sb.start() + + +class TestDropReapsParkedChild: + def test_create_then_discard_reaps_child(self): + """Created-but-not-started child must be reaped on handle_free, + not left as a zombie.""" + sb = _policy() + sb.create(["sleep", "60"]) + pid = sb.pid + assert pid is not None and pid > 0 + assert _proc_state(pid) == "S" + + # Free the handle without start() — Rust Drop should SIGKILL + waitpid. + _lib.sandlock_handle_free(sb._handle) + sb._handle = None + + # After Drop, /proc/ must be gone. If it's still there as 'Z', + # the reap is missing. + state = _proc_state(pid) + assert state is None, f"child {pid} not reaped: state={state!r}"