Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/pocket-ic/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ rust_library(
"@crate_index//:ic-certification",
"@crate_index//:ic-management-canister-types",
"@crate_index//:ic-transport-types",
"@crate_index//:libc",
"@crate_index//:reqwest",
"@crate_index//:schemars",
"@crate_index//:semver",
Expand Down
10 changes: 10 additions & 0 deletions packages/pocket-ic/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Changed
- `start_server` now returns a `ServerHandle` (instead of a `std::process::Child`). The
spawned PocketIC server is registered in a process-global registry and, on unix, is
automatically killed (its whole process group, so canister-sandbox children are
terminated too) and reaped when the test process exits — instead of being detached and
left to shut down on its TTL. This ensures the server no longer keeps the test action's
inherited stdout/stderr pipes open past process exit, which caused issues on some CI
runners. Callers that previously kept the `Child` to terminate the server themselves
should call `ServerHandle::kill_and_wait` instead.



## 15.0.0 - 2026-06-26
Expand Down
3 changes: 3 additions & 0 deletions packages/pocket-ic/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ tracing = { workspace = true }
tracing-appender = { workspace = true }
tracing-subscriber = { workspace = true }

[target.'cfg(unix)'.dependencies]
libc = { workspace = true }

[target.'cfg(windows)'.dependencies]
wslpath = "0.0.2"

Expand Down
22 changes: 17 additions & 5 deletions packages/pocket-ic/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ use std::{
fs::OpenOptions,
net::{IpAddr, SocketAddr},
path::PathBuf,
process::{Child, Command},
process::Command,
sync::{Arc, mpsc::channel},
thread,
thread::JoinHandle,
Expand All @@ -101,6 +101,9 @@ use wslpath::windows_to_wsl;

pub mod common;
pub mod nonblocking;
mod server_process;

pub use server_process::ServerHandle;

const POCKET_IC_SERVER_NAME: &str = "pocket-ic-server";

Expand Down Expand Up @@ -2280,7 +2283,14 @@ pub struct StartServerParams {
}

/// Attempt to start a new PocketIC server.
pub async fn start_server(params: StartServerParams) -> (Child, Url) {
///
/// The returned [`ServerHandle`] owns the spawned server process. On unix it is
Comment thread
basvandijk marked this conversation as resolved.
Outdated
/// registered in a process-global registry and, once the test process exits,
/// automatically killed (its whole process group, so the canister-sandbox children are
/// terminated too) and reaped; on other platforms the server is not killed automatically
/// and shuts down on its TTL. Callers that want to terminate the server earlier can call
/// [`ServerHandle::kill_and_wait`].
pub async fn start_server(params: StartServerParams) -> (ServerHandle, Url) {
let default_bin_dir =
std::env::temp_dir().join(format!("{POCKET_IC_SERVER_NAME}-{LATEST_SERVER_VERSION}"));
let default_bin_path = default_bin_dir.join("pocket-ic");
Expand Down Expand Up @@ -2389,11 +2399,13 @@ pub async fn start_server(params: StartServerParams) -> (Child, Url) {
cmd.process_group(0);
}

// TODO: SDK-1936
#[allow(clippy::zombie_processes)]
let child = cmd
.spawn()
.unwrap_or_else(|_| panic!("Failed to start PocketIC binary ({})", bin_path.display()));
// Transfer ownership of the server process to the process-global registry, which
// kills and reaps it (and its sandbox children) once all subtests have finished.
// See `server_process`.
let server_handle = server_process::register(child);

loop {
if let Ok(port_string) = std::fs::read_to_string(port_file_path.clone())
Expand All @@ -2404,7 +2416,7 @@ pub async fn start_server(params: StartServerParams) -> (Child, Url) {
.parse()
.expect("Failed to parse port to number");
break (
child,
server_handle,
Url::parse(&format!("http://{LOCALHOST}:{port}/")).unwrap(),
);
}
Expand Down
212 changes: 212 additions & 0 deletions packages/pocket-ic/src/server_process.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
//! Process-lifetime management for the spawned `pocket-ic-server`.
//!
//! Historically [`crate::start_server`] spawned the server, detached it into its own
//! process group (`process_group(0)`) and then discarded the [`Child`] handle, so the
//! server — and the canister-sandbox processes it re-execs — outlived the test process.
//! Under a remote-execution worker that only completes once the test action's
//! stdout/stderr pipes reach EOF, the lingering server (and its sandbox children, which
//! inherit those pipes) kept the pipe write-ends open, and the action failed with
//! `WaitDelay expired before I/O complete`.
//!
//! To fix this, every spawned server is transferred to a process-global registry. On
//! unix a `libc::atexit` callback — which runs once all `#[test]`s in the binary have
//! finished and the harness is exiting — kills each server's whole process *group* (so
//! the sandbox children die too and release the inherited pipes) and reaps it. This also
//! closes `SDK-1936` (properly reap the spawned child instead of leaking a zombie).

use std::process::Child;
use std::sync::{Mutex, MutexGuard, OnceLock};
#[cfg(unix)]
use std::time::{Duration, Instant};

/// A spawned `pocket-ic-server` candidate, owned exclusively by the [`registry`].
struct ServerProc {
child: Child,
/// The server's process-group id. The client spawns the server with
/// `process_group(0)`, so the server is a group *leader* and `pgid == child.id()`.
/// The launcher/sandbox processes it re-execs inherit this group (they set no
/// `setpgid`/`setsid`), so signalling the group reaches all of them.
#[cfg(unix)]
pgid: libc::pid_t,
}

/// The process-global set of spawned servers, indexed by the [`ServerHandle`] returned
/// to callers. A slot becomes `None` once its server has been reaped (by
/// [`ServerHandle::kill_and_wait`] or the exit-time reaper) so no server is signalled or
/// waited on twice. Slots are never removed, so a [`ServerHandle`]'s index stays valid.
fn registry() -> &'static Mutex<Vec<Option<ServerProc>>> {
static SERVERS: OnceLock<Mutex<Vec<Option<ServerProc>>>> = OnceLock::new();
SERVERS.get_or_init(|| {
// Install the exit-time reaper exactly once, when the first server is registered.
// It runs from `libc::exit()` after the libtest harness returns or calls
// `std::process::exit` — i.e. once every subtest has finished. This is *not* a
// signal-handler context, so locking a `Mutex`, sleeping and `waitpid` are all
// legal here; it only needs to be panic-free (see `reap_all`).
#[cfg(unix)]
// SAFETY: `reap_all` is an `extern "C"`, panic-free function and is registered
// exactly once (guarded by `OnceLock`).
unsafe {
libc::atexit(reap_all);
}
Comment thread
basvandijk marked this conversation as resolved.
Outdated
Mutex::new(Vec::new())
})
}

fn lock_registry() -> MutexGuard<'static, Vec<Option<ServerProc>>> {
// A poisoned registry is fine to keep using: the data is a plain list of child
// handles and recovering it lets us still kill/reap the servers.
registry().lock().unwrap_or_else(|e| e.into_inner())
}

/// Transfers ownership of a freshly spawned `pocket-ic-server` [`Child`] to the
/// process-global registry and returns a [`ServerHandle`] referring to it.
///
/// The registry keeps the child alive for the remainder of the process (so the server
/// stays up for the whole test run) and guarantees it is killed and reaped at process
/// exit, replacing the previous "spawn and leak" behaviour.
Comment thread
basvandijk marked this conversation as resolved.
Outdated
pub(crate) fn register(child: Child) -> ServerHandle {
#[cfg(unix)]
let pgid = child.id() as libc::pid_t;
let mut servers = lock_registry();
servers.push(Some(ServerProc {
child,
#[cfg(unix)]
pgid,
}));
ServerHandle {
idx: servers.len() - 1,
}
}

/// A handle to a `pocket-ic-server` spawned by [`crate::start_server`].
///
/// The server is owned by a process-global registry and is automatically killed and
/// reaped when the test process exits (on unix), so most callers can simply drop this
/// handle. Callers that want to terminate the server earlier — e.g. to test crash
/// recovery — can call [`ServerHandle::kill_and_wait`].
pub struct ServerHandle {
idx: usize,
}

impl ServerHandle {
/// Kills the server — its whole process group on unix, so the canister-sandbox
/// children are terminated too — and waits for it to be reaped, immediately.
///
/// This is a *hard* kill (`SIGKILL` on unix): it does not give the server a chance
/// to shut down gracefully or checkpoint state. Idempotent with respect to the
/// exit-time reaper: the server is removed from the registry here, so it is not
/// signalled or waited on again at process exit.
Comment thread
basvandijk marked this conversation as resolved.
Outdated
pub fn kill_and_wait(&self) {
let taken = lock_registry().get_mut(self.idx).and_then(Option::take);
if let Some(mut proc) = taken {
#[cfg(unix)]
reap_one(&mut proc, Signal::Kill);
#[cfg(windows)]
{
let _ = proc.child.kill();
let _ = proc.child.wait();
}
}
}
}

/// How aggressively [`reap_one`] terminates a server.
#[cfg(unix)]
#[derive(Clone, Copy)]
enum Signal {
/// Request a graceful shutdown first (`SIGTERM` drives the server's `ctrlc` handler,
/// which is built with the `termination` feature), escalating to `SIGKILL` if the
/// server outlives a short grace period.
TermThenKill,
/// Immediate, uncatchable `SIGKILL`.
Kill,
}

/// `libc::atexit` callback: kill and reap every server still registered.
///
/// Must be panic-free — unwinding across an `extern "C"` boundary is undefined
/// behaviour — hence the `catch_unwind`.
#[cfg(unix)]
extern "C" fn reap_all() {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let taken: Vec<ServerProc> = {
let mut servers = lock_registry();
servers.iter_mut().filter_map(Option::take).collect()
};
for mut proc in taken {
reap_one(&mut proc, Signal::TermThenKill);
}
}));
}

/// Signals a server's process group and reaps the direct server child.
///
/// The child's launcher/sandbox descendants are terminated by the process-group signal
/// (their pipe write-ends close on *termination*, not on reaping) and are reaped by
/// init, since the test process cannot `waitpid` them (they are not its direct
/// children). The wait is bounded so the atexit handler can never hang process exit.
#[cfg(unix)]
fn reap_one(proc: &mut ServerProc, signal: Signal) {
let pgid = proc.pgid;
let pid = proc.child.id() as libc::pid_t;

let first = match signal {
Signal::TermThenKill => libc::SIGTERM,
Signal::Kill => libc::SIGKILL,
};
// A negative target signals the whole process group (server + launcher + sandboxes).
// `ESRCH` — e.g. a loser candidate whose group is already empty — is expected and
// harmless: the following `waitpid` still reaps the zombie.
unsafe { libc::kill(-pgid, first) };

let deadline = Instant::now() + Duration::from_millis(2_000);
loop {
let mut status: libc::c_int = 0;
// SAFETY: reaping our own direct child by pid.
match unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) } {
// Reaped.
r if r == pid => return,
-1 => {
// `EINTR` (interrupted by a signal): retry — returning early here could
// let the server outlive process exit still holding the pipes, the exact
// failure this reaper prevents. Any other error is terminal; the expected
// one is `ECHILD` (the child was already reaped / is not ours).
if last_errno() == libc::EINTR {
continue;
}
return;
}
// Still running (`0`): wait, escalating to `SIGKILL` at the deadline.
_ => {
if Instant::now() >= deadline {
unsafe { libc::kill(-pgid, libc::SIGKILL) };
// Final blocking wait so the zombie is reaped before we return.
blocking_reap(pid);
return;
}
Comment thread
basvandijk marked this conversation as resolved.
Outdated
std::thread::sleep(Duration::from_millis(10));
}
}
}
}

/// The calling thread's `errno`. Only meaningful immediately after a failed libc call.
#[cfg(unix)]
fn last_errno() -> libc::c_int {
std::io::Error::last_os_error().raw_os_error().unwrap_or(0)
}

/// Blocking `waitpid` on a direct child, retrying on `EINTR` so a signal cannot leave the
/// child unreaped.
#[cfg(unix)]
fn blocking_reap(pid: libc::pid_t) {
let mut status: libc::c_int = 0;
loop {
// SAFETY: reaping our own direct child by pid.
let r = unsafe { libc::waitpid(pid, &mut status, 0) };
if r == -1 && last_errno() == libc::EINTR {
continue;
}
return;
}
}
Comment thread
basvandijk marked this conversation as resolved.
Outdated
4 changes: 2 additions & 2 deletions packages/pocket-ic/tests/unix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ fn test_canister_wasm() -> Vec<u8> {
async fn resume_killed_instance_impl(
incomplete_state: Option<IncompleteStateFlag>,
) -> Result<(), String> {
let (mut server, server_url) = start_server(StartServerParams::default()).await;
let (server, server_url) = start_server(StartServerParams::default()).await;
let temp_dir = TempDir::new().unwrap();

let state = PocketIcState::new_from_path(temp_dir.path().to_path_buf());
Expand Down Expand Up @@ -125,7 +125,7 @@ async fn resume_killed_instance_impl(
let time = pic.get_time().await;
assert!(time >= now.into());

server.kill().unwrap();
server.kill_and_wait();

let (_, server_url) = start_server(StartServerParams::default()).await;
let client = reqwest::Client::new();
Expand Down
Loading