use std::{
fmt::Write as _,
fs::{self, File, OpenOptions},
io::{self, Read as _},
path::{Path, PathBuf},
process::{Command, ExitStatus, Stdio},
sync::{
atomic::{AtomicU64, Ordering},
mpsc::{self, RecvTimeoutError},
},
thread,
time::{Duration, Instant},
};
#[cfg(unix)]
use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _};
use ic_host_process::child::OwnedChild;
use pocket_ic::{PocketIc, PocketIcBuilder};
use super::transport;
const STARTUP_POLL_INTERVAL: Duration = Duration::from_millis(20);
const SERVER_OUTPUT_LIMIT: usize = 16 * 1024;
const SERVER_PORT_FILE_LIMIT: usize = 64;
static STARTUP_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PocketIcStartupConfig {
source: PocketIcStartupSource,
timeout: Duration,
server_hard_ttl: Option<Duration>,
server_output_files: Option<(PathBuf, PathBuf)>,
}
pub struct PocketIcManagedServer {
server: ManagedServer,
url: String,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct PocketIcManagedServerOutput {
stdout: String,
stderr: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum PocketIcStartupSource {
Spawn { server_binary: PathBuf },
Connect { server_url: String },
}
#[non_exhaustive]
#[derive(Debug)]
pub enum PocketIcStartupError {
NotConfigured,
InvalidEnvironment { variable: &'static str },
ServerVersionProbe {
source: ic_host_process::tool::ToolError,
},
ServerVersionMismatch { expected: String, observed: Vec<u8> },
CommandRun {
program: PathBuf,
source: io::Error,
termination_error: Option<String>,
},
InvalidConfiguration { message: String },
InvalidServerUrl { server_url: String, message: String },
Io {
operation: &'static str,
path: PathBuf,
source: io::Error,
},
ServerSpawn {
server_binary: PathBuf,
source: io::Error,
},
ServerExited {
server_binary: PathBuf,
status: ExitStatus,
elapsed: Duration,
stdout: String,
stderr: String,
},
ReadinessTimeout {
server_binary: PathBuf,
timeout: Duration,
stdout: String,
stderr: String,
termination_error: Option<String>,
},
InvalidServerPort {
server_binary: PathBuf,
value: String,
stdout: String,
stderr: String,
termination_error: Option<String>,
},
InstanceCreationTimeout {
timeout: Duration,
stdout: String,
stderr: String,
termination_error: Option<String>,
},
BuilderThreadSpawn {
source: io::Error,
stdout: String,
stderr: String,
termination_error: Option<String>,
},
BuilderPanicked {
message: String,
stdout: String,
stderr: String,
termination_error: Option<String>,
},
BuilderDisconnected {
stdout: String,
stderr: String,
termination_error: Option<String>,
},
}
pub trait PocketIcBuilderExt {
fn try_build(self, config: PocketIcStartupConfig) -> Result<PocketIc, PocketIcStartupError>;
}
impl PocketIcStartupConfig {
pub fn from_env(timeout: Duration) -> Result<Self, PocketIcStartupError> {
Self::from_environment(timeout, |name| std::env::var_os(name))
}
fn from_environment(
timeout: Duration,
mut variable: impl FnMut(&str) -> Option<std::ffi::OsString>,
) -> Result<Self, PocketIcStartupError> {
if let Some(value) = variable("IC_TESTKIT_POCKET_IC_URL") {
let server_url = value
.into_string()
.ok()
.filter(|value| !value.is_empty())
.ok_or(PocketIcStartupError::InvalidEnvironment {
variable: "IC_TESTKIT_POCKET_IC_URL",
})?;
let config = Self::connect(&server_url, timeout);
config.validate()?;
let parsed =
server_url
.parse()
.map_err(|error| PocketIcStartupError::InvalidServerUrl {
server_url: server_url.clone(),
message: format!("{error}"),
})?;
let _ = PocketIcBuilder::new().with_server_url(parsed);
return Ok(config);
}
let value = variable("POCKET_IC_BIN").ok_or(PocketIcStartupError::NotConfigured)?;
if value.is_empty() {
return Err(PocketIcStartupError::InvalidEnvironment {
variable: "POCKET_IC_BIN",
});
}
let path = PathBuf::from(value);
let binary = fs::canonicalize(&path).map_err(|source| PocketIcStartupError::Io {
operation: "resolve configured PocketIC executable",
path,
source,
})?;
let config = Self::spawn(&binary, timeout);
config.validate()?;
let evidence = ic_host_process::tool::capture_group_command(
Command::new(&binary).arg("--version"),
ic_host_process::tool::OutputLimits {
stdout_bytes: SERVER_OUTPUT_LIMIT,
stderr_bytes: SERVER_OUTPUT_LIMIT,
timeout,
},
)
.map_err(|source| PocketIcStartupError::ServerVersionProbe { source })?;
let expected = format!("pocket-ic-server {}", pocket_ic::LATEST_SERVER_VERSION);
if std::str::from_utf8(&evidence.stdout).map(str::trim) != Ok(expected.as_str()) {
return Err(PocketIcStartupError::ServerVersionMismatch {
expected,
observed: evidence.stdout,
});
}
Ok(config)
}
#[must_use]
pub fn spawn(server_binary: impl Into<PathBuf>, timeout: Duration) -> Self {
Self {
source: PocketIcStartupSource::Spawn {
server_binary: server_binary.into(),
},
timeout,
server_hard_ttl: None,
server_output_files: None,
}
}
#[must_use]
pub fn connect(server_url: impl Into<String>, timeout: Duration) -> Self {
Self {
source: PocketIcStartupSource::Connect {
server_url: server_url.into(),
},
timeout,
server_hard_ttl: None,
server_output_files: None,
}
}
#[must_use]
pub const fn with_server_hard_ttl(mut self, hard_ttl: Duration) -> Self {
self.server_hard_ttl = Some(hard_ttl);
self
}
#[must_use]
pub fn with_server_output_files(
mut self,
stdout: impl Into<PathBuf>,
stderr: impl Into<PathBuf>,
) -> Self {
self.server_output_files = Some((stdout.into(), stderr.into()));
self
}
#[must_use]
pub const fn timeout(&self) -> Duration {
self.timeout
}
#[must_use]
pub const fn server_hard_ttl(&self) -> Option<Duration> {
self.server_hard_ttl
}
#[must_use]
pub fn server_binary(&self) -> Option<&Path> {
match &self.source {
PocketIcStartupSource::Spawn { server_binary } => Some(server_binary),
PocketIcStartupSource::Connect { .. } => None,
}
}
#[must_use]
pub fn server_url(&self) -> Option<&str> {
match &self.source {
PocketIcStartupSource::Connect { server_url } => Some(server_url),
PocketIcStartupSource::Spawn { .. } => None,
}
}
pub fn start_managed_server(self) -> Result<PocketIcManagedServer, PocketIcStartupError> {
self.validate()?;
let PocketIcStartupSource::Spawn { server_binary } = self.source else {
return Err(PocketIcStartupError::InvalidConfiguration {
message: "starting a managed PocketIC server requires a spawn configuration"
.to_owned(),
});
};
let started = Instant::now();
let deadline = startup_deadline(started, self.timeout)?;
let (server, url) = ManagedServer::start(
server_binary,
self.server_hard_ttl,
self.server_output_files,
deadline,
self.timeout,
started,
)?;
Ok(PocketIcManagedServer { server, url })
}
pub fn run_command(
self,
command: &mut Command,
mut cancelled: impl FnMut() -> bool,
) -> Result<ExitStatus, PocketIcStartupError> {
self.validate()?;
if cancelled() {
return Err(PocketIcStartupError::Io {
operation: "run command with PocketIC server",
path: PathBuf::from(command.get_program()),
source: io::Error::from(io::ErrorKind::Interrupted),
});
}
let (mut server, url) = if let Some(url) = self.server_url() {
(None, url.to_owned())
} else {
let server = self.start_managed_server()?;
let url = server.url().to_owned();
(Some(server), url)
};
let command_error = |source| PocketIcStartupError::Io {
operation: "run command with PocketIC server",
path: PathBuf::from(command.get_program()),
source,
};
if cancelled() {
return Err(command_error(io::Error::from(io::ErrorKind::Interrupted)));
}
command.env("IC_TESTKIT_POCKET_IC_URL", url);
let mut owned_child =
OwnedChild::spawn(command).map_err(|source| PocketIcStartupError::Io {
operation: "spawn command with PocketIC server",
path: PathBuf::from(command.get_program()),
source,
})?;
let result = loop {
if cancelled() {
break Err(io::Error::from(io::ErrorKind::Interrupted));
}
match owned_child.try_wait() {
Ok(Some(status)) => break Ok(status),
Ok(None) => {
if let Some(managed) = server.as_mut()
&& let Some(status) = managed.server.try_wait()?
{
return Err(server
.take()
.expect("managed server remains owned")
.server
.exited_error(status));
}
thread::sleep(STARTUP_POLL_INTERVAL);
}
Err(source) => break Err(source),
}
};
let termination_error = result
.is_err()
.then(|| owned_child.terminate().err().map(|error| error.to_string()))
.flatten();
drop(server);
result.map_err(|source| PocketIcStartupError::CommandRun {
program: PathBuf::from(command.get_program()),
source,
termination_error,
})
}
fn validate(&self) -> Result<(), PocketIcStartupError> {
if self.server_url().is_some() && self.server_output_files.is_some() {
return Err(PocketIcStartupError::InvalidConfiguration {
message: "server output files require a spawn configuration".to_owned(),
});
}
if self.timeout.is_zero() {
return Err(PocketIcStartupError::InvalidConfiguration {
message: "PocketIC startup timeout must be greater than zero".to_owned(),
});
}
if matches!(&self.source, PocketIcStartupSource::Spawn { .. })
&& self
.server_hard_ttl
.is_some_and(|hard_ttl| hard_ttl.as_secs() == 0)
{
return Err(PocketIcStartupError::InvalidConfiguration {
message: "PocketIC server hard TTL must be at least one second".to_owned(),
});
}
Ok(())
}
}
impl PocketIcManagedServer {
#[must_use]
pub fn process_id(&self) -> u32 {
self.server
.child
.as_ref()
.expect("managed server handle must own its child")
.id()
}
#[must_use]
pub fn url(&self) -> &str {
&self.url
}
#[must_use]
pub fn output(&self) -> PocketIcManagedServerOutput {
self.server.capture().into()
}
}
impl PocketIcManagedServerOutput {
#[must_use]
pub fn stdout(&self) -> &str {
&self.stdout
}
#[must_use]
pub fn stderr(&self) -> &str {
&self.stderr
}
}
impl PocketIcBuilderExt for PocketIcBuilder {
fn try_build(self, config: PocketIcStartupConfig) -> Result<PocketIc, PocketIcStartupError> {
config.validate()?;
let started = Instant::now();
let deadline = startup_deadline(started, config.timeout)?;
match config.source {
PocketIcStartupSource::Connect { server_url } => {
build_bounded(self, &server_url, deadline, config.timeout, None)
}
PocketIcStartupSource::Spawn { server_binary } => {
let (server, server_url) = ManagedServer::start(
server_binary,
config.server_hard_ttl,
config.server_output_files,
deadline,
config.timeout,
started,
)?;
build_bounded(self, &server_url, deadline, config.timeout, Some(server))
}
}
}
}
fn startup_deadline(started: Instant, timeout: Duration) -> Result<Instant, PocketIcStartupError> {
started
.checked_add(timeout)
.ok_or_else(|| PocketIcStartupError::InvalidConfiguration {
message: "PocketIC startup timeout exceeds the platform clock range".to_owned(),
})
}
fn build_bounded(
builder: PocketIcBuilder,
server_url: &str,
deadline: Instant,
timeout: Duration,
mut server: Option<ManagedServer>,
) -> Result<PocketIc, PocketIcStartupError> {
let builder = match server_url.parse() {
Ok(server_url) => builder.with_server_url(server_url),
Err(error) => {
return Err(PocketIcStartupError::InvalidServerUrl {
server_url: server_url.to_owned(),
message: error.to_string(),
});
}
};
let (sender, receiver) = mpsc::sync_channel(1);
if let Err(source) = thread::Builder::new()
.name("ic-testkit-pocket-ic-startup".to_owned())
.spawn(move || {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| builder.build()))
.map_err(|payload| transport::panic_payload_to_string(payload.as_ref()));
let _ = sender.send(result);
})
{
let captured = server.take().map_or_else(
CapturedServer::default,
ManagedServer::terminate_and_capture,
);
return Err(captured.builder_thread_error(source));
}
loop {
let now = Instant::now();
if now >= deadline {
let captured = server.take().map_or_else(
CapturedServer::default,
ManagedServer::terminate_and_capture,
);
return Err(PocketIcStartupError::InstanceCreationTimeout {
timeout,
stdout: captured.stdout,
stderr: captured.stderr,
termination_error: captured.termination_error,
});
}
let remaining = deadline.saturating_duration_since(now);
let wait = if server.is_some() {
remaining.min(STARTUP_POLL_INTERVAL)
} else {
remaining
};
match receiver.recv_timeout(wait) {
Ok(Ok(pocket_ic)) => {
if let Some(mut managed) = server.take() {
if let Some(status) = managed.try_wait()? {
return Err(managed.exited_error(status));
}
managed.reap_in_background();
}
return Ok(pocket_ic);
}
Ok(Err(message)) => {
let captured = server.take().map_or_else(
CapturedServer::default,
ManagedServer::terminate_and_capture,
);
return Err(captured.builder_panic_error(message));
}
Err(RecvTimeoutError::Disconnected) => {
let captured = server.take().map_or_else(
CapturedServer::default,
ManagedServer::terminate_and_capture,
);
return Err(captured.builder_disconnected_error());
}
Err(RecvTimeoutError::Timeout) => {
if let Some(managed) = &mut server
&& let Some(status) = managed.try_wait()?
{
return Err(server
.take()
.expect("managed server must remain present")
.exited_error(status));
}
}
}
}
}
struct ManagedServer {
child: Option<OwnedChild>,
binary: PathBuf,
files: StartupFiles,
started: Instant,
}
enum PortFileState {
Pending,
Ready(u16),
Invalid(String),
}
impl ManagedServer {
fn start(
binary: PathBuf,
hard_ttl: Option<Duration>,
output_files: Option<(PathBuf, PathBuf)>,
deadline: Instant,
timeout: Duration,
started: Instant,
) -> Result<(Self, String), PocketIcStartupError> {
let (files, stdout, stderr) = StartupFiles::create(output_files)?;
let mut command = Command::new(&binary);
if let Some(hard_ttl) = hard_ttl {
command
.arg("--hard-ttl")
.arg(hard_ttl.as_secs().to_string());
}
command
.arg("--port-file")
.arg(&files.port)
.stdout(Stdio::from(stdout))
.stderr(Stdio::from(stderr));
let child = OwnedChild::spawn(&mut command).map_err(|source| {
PocketIcStartupError::ServerSpawn {
server_binary: binary.clone(),
source,
}
})?;
let mut server = Self {
child: Some(child),
binary,
files,
started,
};
loop {
if let Some(status) = server.try_wait()? {
return Err(server.exited_error(status));
}
let now = Instant::now();
if now >= deadline {
let binary = server.binary.clone();
let captured = server.terminate_and_capture();
return Err(PocketIcStartupError::ReadinessTimeout {
server_binary: binary,
timeout,
stdout: captured.stdout,
stderr: captured.stderr,
termination_error: captured.termination_error,
});
}
match server.read_port()? {
PortFileState::Pending => {}
PortFileState::Ready(port) => {
return Ok((server, format!("http://127.0.0.1:{port}/")));
}
PortFileState::Invalid(value) => {
let binary = server.binary.clone();
let captured = server.terminate_and_capture();
return Err(captured.invalid_port_error(binary, value));
}
}
thread::sleep(
deadline
.saturating_duration_since(now)
.min(STARTUP_POLL_INTERVAL),
);
}
}
fn try_wait(&mut self) -> Result<Option<ExitStatus>, PocketIcStartupError> {
let child = self
.child
.as_mut()
.expect("managed server child must remain present");
child.try_wait().map_err(|source| PocketIcStartupError::Io {
operation: "inspect PocketIC server child",
path: self.binary.clone(),
source,
})
}
fn read_port(&self) -> Result<PortFileState, PocketIcStartupError> {
let port_path = &self.files.port;
let mut contents = String::new();
match open_regular_startup_file(port_path).and_then(|file| {
file.take((SERVER_PORT_FILE_LIMIT + 1) as u64)
.read_to_string(&mut contents)
}) {
Ok(_) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {
return Ok(PortFileState::Pending);
}
Err(source) => {
return Err(PocketIcStartupError::Io {
operation: "read PocketIC server port file",
path: port_path.clone(),
source,
});
}
}
if contents.len() > SERVER_PORT_FILE_LIMIT {
return Ok(PortFileState::Invalid(format!(
"{} (port file exceeds {SERVER_PORT_FILE_LIMIT} bytes)",
contents.trim()
)));
}
if !contents.contains('\n') {
return Ok(PortFileState::Pending);
}
let value = contents.trim().to_owned();
match value.parse::<u16>() {
Ok(port) if port != 0 => Ok(PortFileState::Ready(port)),
_ => Ok(PortFileState::Invalid(value)),
}
}
fn exited_error(mut self, status: ExitStatus) -> PocketIcStartupError {
let elapsed = self.started.elapsed();
let binary = self.binary.clone();
self.child.take();
let captured = self.capture();
PocketIcStartupError::ServerExited {
server_binary: binary,
status,
elapsed,
stdout: captured.stdout,
stderr: captured.stderr,
}
}
fn terminate_and_capture(mut self) -> CapturedServer {
let termination_error = match self.child.take() {
Some(mut child) => child.terminate().err().map(|error| error.to_string()),
None => None,
};
let mut captured = self.capture();
captured.termination_error = termination_error;
captured
}
fn capture(&self) -> CapturedServer {
let files = &self.files;
CapturedServer {
stdout: read_bounded_lossy(&files.stdout),
stderr: read_bounded_lossy(&files.stderr),
termination_error: None,
}
}
fn reap_in_background(self) {
let _ = thread::Builder::new()
.name("ic-testkit-pocket-ic-server-reaper".to_owned())
.spawn(move || {
let mut server = self;
if let Some(child) = server.child.as_mut() {
let _ = child.wait();
}
});
}
}
#[derive(Default)]
struct CapturedServer {
stdout: String,
stderr: String,
termination_error: Option<String>,
}
impl CapturedServer {
fn invalid_port_error(self, server_binary: PathBuf, value: String) -> PocketIcStartupError {
PocketIcStartupError::InvalidServerPort {
server_binary,
value,
stdout: self.stdout,
stderr: self.stderr,
termination_error: self.termination_error,
}
}
fn builder_thread_error(self, source: io::Error) -> PocketIcStartupError {
PocketIcStartupError::BuilderThreadSpawn {
source,
stdout: self.stdout,
stderr: self.stderr,
termination_error: self.termination_error,
}
}
fn builder_panic_error(self, message: String) -> PocketIcStartupError {
PocketIcStartupError::BuilderPanicked {
message,
stdout: self.stdout,
stderr: self.stderr,
termination_error: self.termination_error,
}
}
fn builder_disconnected_error(self) -> PocketIcStartupError {
PocketIcStartupError::BuilderDisconnected {
stdout: self.stdout,
stderr: self.stderr,
termination_error: self.termination_error,
}
}
}
impl From<CapturedServer> for PocketIcManagedServerOutput {
fn from(captured: CapturedServer) -> Self {
Self {
stdout: captured.stdout,
stderr: captured.stderr,
}
}
}
struct StartupFiles {
directory: PathBuf,
port: PathBuf,
stdout: PathBuf,
stderr: PathBuf,
}
impl StartupFiles {
fn create(
output_files: Option<(PathBuf, PathBuf)>,
) -> Result<(Self, File, File), PocketIcStartupError> {
let output_files = output_files
.map(|(stdout, stderr)| {
Ok::<_, PocketIcStartupError>((
std::path::absolute(&stdout)
.map_err(|source| startup_file_error("resolve", &stdout, source))?,
std::path::absolute(&stderr)
.map_err(|source| startup_file_error("resolve", &stderr, source))?,
))
})
.transpose()?;
loop {
let sequence = STARTUP_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let base = std::env::temp_dir().join(format!(
"ic-testkit-pocket-ic-startup-{}-{sequence}",
std::process::id()
));
let mut directory = fs::DirBuilder::new();
#[cfg(unix)]
{
directory.mode(0o700);
}
match directory.create(&base) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(source) => return Err(startup_file_error("create", &base, source)),
}
let (stdout_path, stderr_path) =
output_files.unwrap_or_else(|| (base.join("stdout"), base.join("stderr")));
let files = Self {
port: base.join("port"),
stdout: stdout_path,
stderr: stderr_path,
directory: base,
};
let stdout = create_new_file(&files.stdout)
.map_err(|source| startup_file_error("create", &files.stdout, source))?;
let stderr = create_new_file(&files.stderr)
.map_err(|source| startup_file_error("create", &files.stderr, source))?;
return Ok((files, stdout, stderr));
}
}
}
impl Drop for StartupFiles {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.directory);
}
}
fn create_new_file(path: &Path) -> io::Result<File> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
options.mode(0o600);
options.open(path)
}
fn startup_file_error(
operation: &'static str,
path: &Path,
source: io::Error,
) -> PocketIcStartupError {
PocketIcStartupError::Io {
operation,
path: path.to_owned(),
source,
}
}
fn read_bounded_lossy(path: &Path) -> String {
let Ok(file) = open_regular_startup_file(path) else {
return String::new();
};
let length = file.metadata().map_or(0, |metadata| metadata.len());
let Ok(bytes) = ic_host_artifacts::artifact::read_reader(
file.take(SERVER_OUTPUT_LIMIT as u64),
SERVER_OUTPUT_LIMIT,
) else {
return String::new();
};
let mut output = String::from_utf8_lossy(&bytes).into_owned();
let omitted = length.saturating_sub(bytes.len() as u64);
if omitted > 0 {
let _ = write!(output, "\n<truncated {omitted} bytes>");
}
output
}
fn open_regular_startup_file(path: &Path) -> io::Result<File> {
let mut options = OpenOptions::new();
options.read(true);
#[cfg(unix)]
options.custom_flags(libc::O_NONBLOCK);
let file = options.open(path)?;
if !file.metadata()?.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"PocketIC startup reader requires a regular file",
));
}
Ok(file)
}
impl PocketIcStartupError {
fn termination_error(&self) -> Option<&str> {
match self {
Self::CommandRun {
termination_error, ..
}
| Self::ReadinessTimeout {
termination_error, ..
}
| Self::InvalidServerPort {
termination_error, ..
}
| Self::InstanceCreationTimeout {
termination_error, ..
}
| Self::BuilderThreadSpawn {
termination_error, ..
}
| Self::BuilderPanicked {
termination_error, ..
}
| Self::BuilderDisconnected {
termination_error, ..
} => termination_error.as_deref(),
_ => None,
}
}
}
impl std::fmt::Display for PocketIcStartupError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotConfigured => formatter.write_str("configure IC_TESTKIT_POCKET_IC_URL or POCKET_IC_BIN; prepare a verified binary with make install-tools"),
Self::InvalidEnvironment { variable } => write!(formatter, "invalid selected environment value: {variable}"),
Self::ServerVersionProbe { source } => write!(formatter, "PocketIC version probe failed: {source}"),
Self::ServerVersionMismatch { expected, observed } => write!(formatter, "PocketIC version mismatch: expected {expected:?}, observed {:?}", String::from_utf8_lossy(observed)),
Self::CommandRun { program, source, .. } => write!(formatter, "command {} failed: {source}", program.display()),
Self::InvalidConfiguration { message } => formatter.write_str(message),
Self::InvalidServerUrl {
server_url,
message,
} => write!(
formatter,
"invalid PocketIC server URL {server_url:?}: {message}"
),
Self::Io {
operation,
path,
source,
} => write!(
formatter,
"failed to {operation} at {}: {source}",
path.display()
),
Self::ServerSpawn {
server_binary,
source,
} => write!(
formatter,
"failed to spawn PocketIC server {}: {source}",
server_binary.display()
),
Self::ServerExited {
server_binary,
status,
elapsed,
stderr,
..
} => write!(
formatter,
"PocketIC server {} exited with {status} after {elapsed:?}: {stderr}",
server_binary.display()
),
Self::ReadinessTimeout {
server_binary,
timeout,
..
} => write!(
formatter,
"PocketIC server {} was not ready within {timeout:?}",
server_binary.display()
),
Self::InvalidServerPort {
server_binary,
value,
..
} => write!(
formatter,
"PocketIC server {} published invalid port {value:?}",
server_binary.display()
),
Self::InstanceCreationTimeout { timeout, .. } => {
write!(formatter, "PocketIC instance creation exceeded {timeout:?}")
}
Self::BuilderThreadSpawn { source, .. } => {
write!(
formatter,
"failed to spawn PocketIC builder worker: {source}"
)
}
Self::BuilderPanicked { message, .. } => {
write!(formatter, "PocketIC startup panicked: {message}")
}
Self::BuilderDisconnected { .. } => {
formatter.write_str("PocketIC builder worker disconnected without a result")
}
}?;
if let Some(error) = self.termination_error() {
write!(formatter, "; cleanup also failed: {error}")?;
}
Ok(())
}
}
impl std::error::Error for PocketIcStartupError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::ServerVersionProbe { source } => Some(source),
Self::Io { source, .. }
| Self::CommandRun { source, .. }
| Self::ServerSpawn { source, .. }
| Self::BuilderThreadSpawn { source, .. } => Some(source),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use std::{
fs,
path::PathBuf,
time::{Duration, Instant},
};
use super::{
PocketIcBuilderExt as _, PocketIcStartupConfig, PocketIcStartupError, StartupFiles,
};
use pocket_ic::PocketIcBuilder;
#[cfg(unix)]
use crate::test_executable::write_executable_script;
#[cfg(unix)]
use std::{
io::Write as _,
os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _},
process::Command,
sync::mpsc,
};
#[cfg(unix)]
#[test]
fn command_cancellation_before_startup_has_no_spawn_effects() {
let error = PocketIcStartupConfig::spawn("/missing/server", Duration::from_secs(1))
.run_command(&mut Command::new("/missing/command"), || true)
.unwrap_err();
assert!(
matches!(error, PocketIcStartupError::Io { source, .. } if source.kind() == std::io::ErrorKind::Interrupted)
);
}
#[cfg(unix)]
#[test]
fn cancellation_callback_panic_still_reaps_the_owned_command() {
let script = TestServerScript::new(
"cancel-panic",
"#!/bin/sh\nprintf '%s' \"$$\" > \"$1\"\nexec sleep 30\n",
);
let pid_file = script.path().with_extension("pid");
let result = std::panic::catch_unwind(|| {
PocketIcStartupConfig::connect("http://127.0.0.1:12345/", Duration::from_secs(1))
.run_command(Command::new(script.path()).arg(&pid_file), || {
assert!(
!fs::read_to_string(&pid_file).is_ok_and(|value| !value.is_empty()),
"caller cancellation failed"
);
false
})
});
assert!(result.is_err());
let pid = fs::read_to_string(&pid_file).unwrap().parse().unwrap();
assert!(process_state(pid).is_none_or(|state| state == 'Z'));
fs::remove_file(pid_file).unwrap();
}
#[cfg(unix)]
#[test]
fn environment_selection_prefers_urls_and_fails_closed() {
use std::os::unix::ffi::OsStringExt as _;
let timeout = Duration::from_secs(1);
let config = PocketIcStartupConfig::from_environment(timeout, |name| {
Some(
if name == "IC_TESTKIT_POCKET_IC_URL" {
"http://127.0.0.1:12345/"
} else {
"/missing/server"
}
.into(),
)
})
.unwrap();
assert_eq!(config.server_url(), Some("http://127.0.0.1:12345/"));
assert!(config.server_binary().is_none());
assert!(matches!(
PocketIcStartupConfig::from_environment(timeout, |_| None),
Err(PocketIcStartupError::NotConfigured)
));
assert!(matches!(
PocketIcStartupConfig::from_environment(timeout, |_| Some("".into())),
Err(PocketIcStartupError::InvalidEnvironment {
variable: "IC_TESTKIT_POCKET_IC_URL"
})
));
assert!(matches!(
PocketIcStartupConfig::from_environment(timeout, |_| Some("bad URL".into())),
Err(PocketIcStartupError::InvalidServerUrl { .. })
));
assert!(matches!(
PocketIcStartupConfig::from_environment(timeout, |_| Some(
std::ffi::OsString::from_vec(vec![0xff])
)),
Err(PocketIcStartupError::InvalidEnvironment { .. })
));
}
#[test]
fn startup_failure_projections_preserve_cleanup_and_bounded_output() {
for cleanup in [None, Some("secondary cleanup")] {
let capture = || super::CapturedServer {
stdout: "captured stdout".to_owned(),
stderr: "captured stderr".to_owned(),
termination_error: cleanup.map(str::to_owned),
};
let errors = [
capture().invalid_port_error(PathBuf::from("server"), "bad-port".to_owned()),
capture().builder_thread_error(std::io::Error::other("worker source")),
capture().builder_panic_error("builder cause".to_owned()),
capture().builder_disconnected_error(),
PocketIcStartupError::ReadinessTimeout {
server_binary: PathBuf::from("server"),
timeout: Duration::from_secs(1),
stdout: "captured stdout".to_owned(),
stderr: "captured stderr".to_owned(),
termination_error: cleanup.map(str::to_owned),
},
PocketIcStartupError::InstanceCreationTimeout {
timeout: Duration::from_secs(1),
stdout: "captured stdout".to_owned(),
stderr: "captured stderr".to_owned(),
termination_error: cleanup.map(str::to_owned),
},
];
let primary = [
"bad-port",
"worker source",
"builder cause",
"disconnected",
"not ready",
"creation exceeded",
];
for (error, primary) in errors.into_iter().zip(primary) {
let (PocketIcStartupError::InvalidServerPort {
stdout,
stderr,
termination_error,
..
}
| PocketIcStartupError::BuilderThreadSpawn {
stdout,
stderr,
termination_error,
..
}
| PocketIcStartupError::BuilderPanicked {
stdout,
stderr,
termination_error,
..
}
| PocketIcStartupError::BuilderDisconnected {
stdout,
stderr,
termination_error,
}
| PocketIcStartupError::ReadinessTimeout {
stdout,
stderr,
termination_error,
..
}
| PocketIcStartupError::InstanceCreationTimeout {
stdout,
stderr,
termination_error,
..
}) = &error
else {
unreachable!()
};
assert_eq!(stdout, "captured stdout");
assert_eq!(stderr, "captured stderr");
assert_eq!(termination_error.as_deref(), cleanup);
let display = error.to_string();
assert!(display.contains(primary));
if let Some(cleanup) = cleanup {
assert!(display.ends_with(&format!("; cleanup also failed: {cleanup}")));
} else {
assert!(!display.contains("cleanup also failed"));
}
}
}
}
#[cfg(unix)]
#[test]
fn version_capture_cleans_wrapper_descendants_on_exit_and_timeout() {
for timeout in [false, true] {
let qualified = format!(
"printf 'pocket-ic-server {}\\n'",
pocket_ic::LATEST_SERVER_VERSION
);
let ending = if timeout { "wait" } else { &qualified };
let script = TestServerScript::new(
"version-group",
&format!(
"#!/bin/sh\nsleep 30 >/dev/null 2>&1 &\nprintf '%s' \"$!\" > \"$0.pid\"\n{ending}\n"
),
);
let result = PocketIcStartupConfig::from_environment(Duration::from_secs(1), |name| {
(name == "POCKET_IC_BIN").then(|| script.path().into_os_string())
});
if timeout {
assert!(matches!(
result,
Err(PocketIcStartupError::ServerVersionProbe { .. })
));
} else {
assert!(result.is_ok());
}
let pid_file = script.path().with_extension("pid");
let pid = fs::read_to_string(&pid_file).unwrap().parse().unwrap();
let deadline = Instant::now() + Duration::from_secs(3);
while process_state(pid).is_some_and(|state| state != 'Z') {
assert!(
Instant::now() < deadline,
"version wrapper descendant remained alive"
);
std::thread::sleep(Duration::from_millis(20));
}
fs::remove_file(pid_file).unwrap();
}
}
#[cfg(unix)]
#[test]
fn owned_builder_panic_retains_server_output_and_reaps_child() {
let script = TestServerScript::new(
"builder-panic",
"#!/bin/sh\nprintf '%s' \"$$\" > \"$0.pid\"\nprintf 'builder stdout'\nprintf 'builder stderr' >&2\nprintf '34567\\n' > \"$2\"\nexec sleep 30\n",
);
let error = PocketIcBuilder::new()
.try_build(PocketIcStartupConfig::spawn(
script.path(),
Duration::from_secs(2),
))
.err()
.unwrap();
let PocketIcStartupError::BuilderPanicked {
message,
stdout,
stderr,
termination_error,
} = error
else {
panic!("expected builder failure, got {error:?}");
};
assert_ne!(message, "");
assert_eq!(stdout, "builder stdout");
assert_eq!(stderr, "builder stderr");
assert!(termination_error.is_none());
let pid_file = script.path().with_extension("pid");
let pid = fs::read_to_string(&pid_file).unwrap().parse().unwrap();
assert_eq!(process_state(pid), None);
fs::remove_file(pid_file).unwrap();
}
#[cfg(unix)]
#[test]
fn environment_binary_selection_uses_bounded_shared_version_capture() {
let qualified = format!(
"printf 'pocket-ic-server {}\\n'",
pocket_ic::LATEST_SERVER_VERSION
);
let failed = format!("{qualified}; exit 23");
for (label, body, expected) in [
("qualified", qualified.as_str(), 0),
("wrong-version", "printf 'pocket-ic-server 15.0.0\\n'", 1),
("failed-version", failed.as_str(), 2),
("invalid-utf8", "printf '\\377'", 1),
("version-timeout", "exec sleep 30", 2),
] {
let script = TestServerScript::new(
label,
&format!("#!/bin/sh\n[ \"$1\" = --version ] || exit 99\n{body}\n"),
);
let result =
PocketIcStartupConfig::from_environment(Duration::from_millis(200), |name| {
(name == "POCKET_IC_BIN").then(|| script.path().into_os_string())
});
match (expected, result) {
(0, Ok(config)) => {
let binary = script.path().canonicalize().unwrap();
assert_eq!(config.server_binary(), Some(binary.as_path()));
}
(1, Err(PocketIcStartupError::ServerVersionMismatch { .. }))
| (2, Err(PocketIcStartupError::ServerVersionProbe { .. })) => {}
(_, result) => panic!("unexpected {label} result: {result:?}"),
}
}
}
#[cfg(unix)]
fn process_state(pid: u32) -> Option<char> {
let output = Command::new("/bin/ps")
.args(["-p", &pid.to_string(), "-o", "stat="])
.output()
.expect("inspect managed test process state");
assert!(
output.status.success()
|| (output.status.code() == Some(1)
&& output.stdout.is_empty()
&& output.stderr.is_empty()),
"process-state inspection failed: {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr),
);
String::from_utf8(output.stdout)
.expect("process state is ASCII")
.trim()
.chars()
.next()
}
#[cfg(unix)]
#[test]
fn reading_large_sparse_server_output_is_bounded() {
let (files, _, _) = StartupFiles::create(None).expect("allocate startup files");
let mut file = fs::File::create(&files.stdout).expect("create sparse log");
file.write_all(b"server started\n").expect("write prefix");
let size = 8_u64 * 1024 * 1024 * 1024;
file.set_len(size).expect("extend sparse log");
let output = super::read_bounded_lossy(&files.stdout);
assert!(output.starts_with("server started\n"));
assert!(output.ends_with(&format!(
"<truncated {} bytes>",
size - super::SERVER_OUTPUT_LIMIT as u64
)));
assert!(output.len() < super::SERVER_OUTPUT_LIMIT + 100);
}
#[cfg(unix)]
#[test]
fn startup_readers_reject_fifos_without_waiting_for_a_writer() {
let (files, stdout, stderr) = StartupFiles::create(None).expect("allocate startup files");
drop((stdout, stderr));
fs::remove_file(&files.stdout).unwrap();
fs::remove_file(&files.stderr).unwrap();
assert!(
Command::new("mkfifo")
.args([&files.port, &files.stdout, &files.stderr])
.status()
.expect("create FIFO startup files")
.success()
);
let server = super::ManagedServer {
child: None,
binary: PathBuf::from("unused-server"),
files,
started: Instant::now(),
};
for path in [
&server.files.port,
&server.files.stdout,
&server.files.stderr,
] {
let fifo = path.clone();
let (stop_writer, stopped) = mpsc::channel();
let writer = std::thread::spawn(move || {
loop {
match stopped.recv_timeout(Duration::from_millis(200)) {
Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return false,
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
if fs::OpenOptions::new()
.write(true)
.custom_flags(libc::O_NONBLOCK)
.open(&fifo)
.is_ok()
{
return true;
}
}
});
let result = if path == &server.files.port {
Some(server.read_port())
} else {
assert_eq!(super::read_bounded_lossy(path), "");
None
};
let _ = stop_writer.send(());
assert!(
!writer.join().expect("join delayed FIFO writer"),
"startup reader waited for a writer: {}",
path.display(),
);
if let Some(result) = result {
assert!(matches!(
result,
Err(PocketIcStartupError::Io { source, .. })
if source.kind() == std::io::ErrorKind::InvalidData
));
}
}
}
#[test]
fn port_file_readiness_preserves_partial_writes_and_rejects_oversized_contents() {
let (files, _, _) = StartupFiles::create(None).expect("allocate startup files");
let server = super::ManagedServer {
child: None,
binary: PathBuf::from("unused-server"),
files,
started: Instant::now(),
};
assert!(matches!(
server.read_port().unwrap(),
super::PortFileState::Pending
));
for contents in ["", "34567"] {
fs::write(&server.files.port, contents).unwrap();
assert!(matches!(
server.read_port().unwrap(),
super::PortFileState::Pending
));
}
for (contents, expected) in [("1\n", 1), ("65535\n", 65535), (" 34567\r\n", 34567)] {
fs::write(&server.files.port, contents).unwrap();
assert!(matches!(
server.read_port().unwrap(),
super::PortFileState::Ready(port) if port == expected
));
}
for contents in ["0\n", "65536\n", "invalid\n", "1\n2\n"] {
fs::write(&server.files.port, contents).unwrap();
assert!(matches!(
server.read_port().unwrap(),
super::PortFileState::Invalid(_)
));
}
fs::write(&server.files.port, [0xff, b'\n']).unwrap();
assert!(matches!(
server.read_port(),
Err(PocketIcStartupError::Io { source, .. })
if source.kind() == std::io::ErrorKind::InvalidData
));
for contents in ["1\n".to_owned() + &" ".repeat(128), "0".repeat(128)] {
fs::write(&server.files.port, contents).unwrap();
assert!(
matches!(
server.read_port().unwrap(),
super::PortFileState::Invalid(_)
),
"oversized port contents must fail even without a newline",
);
}
fs::File::options()
.write(true)
.open(&server.files.port)
.unwrap()
.set_len(1024 * 1024)
.unwrap();
assert!(matches!(
server.read_port().unwrap(),
super::PortFileState::Invalid(value) if value.len() < 256
));
}
#[test]
fn startup_config_requires_positive_bounds() {
let error = PocketIcStartupConfig::connect("http://127.0.0.1:1/", Duration::ZERO)
.validate()
.expect_err("zero startup timeout must fail");
assert!(matches!(
error,
PocketIcStartupError::InvalidConfiguration { .. }
));
let error = PocketIcStartupConfig::spawn("pocket-ic", Duration::from_secs(1))
.with_server_hard_ttl(Duration::from_millis(1))
.validate()
.expect_err("subsecond server hard TTL must fail");
assert!(matches!(
error,
PocketIcStartupError::InvalidConfiguration { .. }
));
}
#[test]
fn managed_server_hard_ttl_is_opt_in() {
let default = PocketIcStartupConfig::spawn("pocket-ic", Duration::from_secs(1));
assert_eq!(default.server_hard_ttl(), None);
let explicit = default.with_server_hard_ttl(Duration::from_secs(17));
assert_eq!(explicit.server_hard_ttl(), Some(Duration::from_secs(17)));
}
#[test]
fn startup_files_leave_the_server_owned_port_path_absent() {
let (files, stdout, stderr) = StartupFiles::create(None).expect("allocate startup files");
let directory = files.directory.clone();
assert!(directory.is_dir());
assert!(!files.port.exists());
assert!(files.stdout.is_file());
assert!(files.stderr.is_file());
#[cfg(unix)]
{
let mode = fs::metadata(&directory)
.expect("inspect private startup directory")
.permissions()
.mode();
assert_eq!(mode & 0o077, 0);
}
drop(stdout);
drop(stderr);
drop(files);
assert!(!directory.exists());
}
#[cfg(unix)]
#[test]
fn caller_output_files_survive_startup_command_and_cancellation_cleanup() {
for outcome in [
"timeout",
"startup-exit",
"server-exit",
"command-exit",
"cancel",
"success",
] {
let (owner, _, _) = StartupFiles::create(None).unwrap();
let stdout = owner.directory.join("retained-stdout");
let stderr = owner.directory.join("retained-stderr");
let ending = match outcome {
"timeout" => "exec sleep 30",
"startup-exit" => "exit 41",
"server-exit" => {
"printf '34567\\n' > \"$2\"; while [ ! -s \"$0.command\" ]; do sleep 0.02; done; exit 42"
}
_ => "printf '34567\\n' > \"$2\"; exec sleep 30",
};
let script = TestServerScript::new(
outcome,
&format!(
"#!/bin/sh\nprintf '%s\\n%s\\n' \"$$\" \"$2\" > \"$0.pid\"\ndd if=/dev/zero bs=1024 count=20 2>/dev/null\nprintf raw-stdout-end\ndd if=/dev/zero bs=1024 count=20 >&2 2>/dev/null\nprintf raw-stderr-end >&2\n{ending}\n"
),
);
let pid_file = script.path().with_extension("pid");
let command_file = script.path().with_extension("command");
let config = PocketIcStartupConfig::spawn(
script.path(),
Duration::from_millis(if outcome == "timeout" { 1000 } else { 2000 }),
)
.with_server_output_files(&stdout, &stderr);
if outcome == "timeout" || outcome == "startup-exit" {
let error = config.start_managed_server().err().unwrap();
match (outcome, error) {
("timeout", PocketIcStartupError::ReadinessTimeout { stdout, stderr, .. }) => {
assert!(stdout.contains("truncated"));
assert!(!stderr.contains("raw-stderr-end"));
}
("startup-exit", PocketIcStartupError::ServerExited { status, .. }) => {
assert_eq!(status.code(), Some(41));
}
(_, error) => panic!("unexpected startup result: {error:?}"),
}
} else {
let command_end = match outcome {
"command-exit" => "exit 37",
"success" => "exit 0",
_ => "exec sleep 30",
};
let result = config.run_command(
Command::new("/bin/sh")
.args([
"-c",
&format!("printf '%s' \"$$\" > \"$1\"; {command_end}"),
"fixture",
])
.arg(&command_file),
|| {
outcome == "cancel"
&& fs::metadata(&command_file).is_ok_and(|m| m.len() > 0)
},
);
match (outcome, result) {
("command-exit", Ok(status)) => assert_eq!(status.code(), Some(37)),
("success", Ok(status)) => assert!(status.success()),
("server-exit", Err(PocketIcStartupError::ServerExited { status, .. })) => {
assert_eq!(status.code(), Some(42));
}
("cancel", Err(PocketIcStartupError::CommandRun { source, .. })) => {
assert_eq!(source.kind(), std::io::ErrorKind::Interrupted);
}
(_, result) => panic!("unexpected command result: {result:?}"),
}
let pid = fs::read_to_string(&command_file).unwrap().parse().unwrap();
assert!(process_state(pid).is_none_or(|state| state == 'Z'));
fs::remove_file(command_file).unwrap();
}
for (path, suffix) in [(&stdout, b"raw-stdout-end"), (&stderr, b"raw-stderr-end")] {
let bytes = fs::read(path).unwrap();
assert_eq!(bytes.len(), 20 * 1024 + suffix.len());
assert!(bytes.ends_with(suffix));
assert_eq!(
fs::metadata(path).unwrap().permissions().mode() & 0o777,
0o600
);
}
let report = fs::read_to_string(&pid_file).unwrap();
let mut lines = report.lines();
assert_eq!(process_state(lines.next().unwrap().parse().unwrap()), None);
assert!(
!PathBuf::from(lines.next().unwrap())
.parent()
.unwrap()
.exists()
);
fs::remove_file(pid_file).unwrap();
}
}
#[cfg(unix)]
#[test]
fn caller_output_files_refuse_existing_entries_and_keep_partial_preparation() {
use std::os::unix::fs::symlink;
let (owner, _, _) = StartupFiles::create(None).unwrap();
let stdout = owner.directory.join("retained-stdout");
let stderr = owner.directory.join("retained-stderr");
let unrelated = owner.directory.join("unrelated");
fs::write(&unrelated, b"original").unwrap();
symlink(&unrelated, &stderr).unwrap();
let error = StartupFiles::create(Some((stdout.clone(), stderr.clone())))
.err()
.unwrap();
assert!(
matches!(error, PocketIcStartupError::Io { source, .. } if source.kind() == std::io::ErrorKind::AlreadyExists)
);
assert!(stdout.is_file());
assert_eq!(fs::read(&unrelated).unwrap(), b"original");
fs::write(&stdout, b"retained attempt").unwrap();
assert!(StartupFiles::create(Some((stdout.clone(), stderr.clone()))).is_err());
assert_eq!(fs::read(&stdout).unwrap(), b"retained attempt");
fs::remove_file(stderr.clone()).unwrap();
assert!(
Command::new("mkfifo")
.arg(&stderr)
.status()
.unwrap()
.success()
);
fs::remove_file(stdout.clone()).unwrap();
assert!(StartupFiles::create(Some((stdout.clone(), stderr.clone()))).is_err());
assert!(stdout.is_file());
let error =
PocketIcStartupConfig::connect("http://127.0.0.1:12345/", Duration::from_secs(1))
.with_server_output_files(&stdout, &stderr)
.run_command(&mut Command::new("/missing/command"), || false)
.unwrap_err();
assert!(matches!(
error,
PocketIcStartupError::InvalidConfiguration { .. }
));
}
#[cfg(unix)]
#[test]
fn managed_startup_reports_an_exited_server_with_bounded_output() {
let script = TestServerScript::new(
"exit",
"#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nprintf 'synthetic server stdout'\nprintf 'synthetic bind failure' >&2\nexit 23\n",
);
let result = PocketIcBuilder::new().with_application_subnet().try_build(
PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2)),
);
let Err(PocketIcStartupError::ServerExited {
server_binary,
status,
stdout,
stderr,
..
}) = result
else {
panic!(
"an exited managed server must return a structured exit error; got {:?}",
result.err(),
);
};
assert_eq!(server_binary, script.path());
assert_eq!(status.code(), Some(23));
assert_eq!(stdout, "synthetic server stdout");
assert_eq!(stderr, "synthetic bind failure");
}
#[cfg(unix)]
#[test]
fn managed_startup_rejects_oversized_port_files_and_cleans_up() {
let script = TestServerScript::new(
"oversized-port",
"#!/bin/sh\nprintf '%s\\n%s\\n' \"$$\" \"$2\"\nprintf '34567\\n%064s' '' > \"$2.pending\"\nmv \"$2.pending\" \"$2\"\nexec sleep 30\n",
);
let result = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
.start_managed_server();
let Err(PocketIcStartupError::InvalidServerPort { value, stdout, .. }) = result else {
panic!("oversized port publication must fail readiness");
};
assert!(value.contains("port file exceeds"));
assert!(value.len() < 256);
let mut lines = stdout.lines();
let pid = lines.next().unwrap().parse::<u32>().unwrap();
let port_path = PathBuf::from(lines.next().unwrap());
assert!(!port_path.parent().unwrap().exists());
assert_eq!(process_state(pid), None, "failed server must be reaped");
}
#[cfg(unix)]
#[test]
fn managed_startup_terminates_a_server_that_never_becomes_ready() {
let script = TestServerScript::new(
"timeout",
"#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nexec sleep 30\n",
);
let timeout = Duration::from_millis(100);
let started = Instant::now();
let result = PocketIcBuilder::new()
.with_application_subnet()
.try_build(PocketIcStartupConfig::spawn(script.path(), timeout));
assert!(
started.elapsed() < Duration::from_secs(2),
"bounded startup should not wait for the sleeping child"
);
assert!(matches!(
result,
Err(PocketIcStartupError::ReadinessTimeout {
server_binary,
timeout: actual_timeout,
termination_error: None,
..
}) if server_binary == script.path() && actual_timeout == timeout
));
}
#[cfg(unix)]
#[test]
fn managed_server_handle_exposes_process_id_url_output_and_raii_ownership() {
let script = TestServerScript::new(
"handle",
"#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then echo 'unexpected managed server arguments' >&2; exit 97; fi\nprintf 'managed server ready: %s' \"$$\"\nprintf '34567\\n' > \"$2\"\nexec sleep 30\n",
);
let server = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
.start_managed_server()
.expect("start caller-owned managed server");
assert_eq!(server.url(), "http://127.0.0.1:34567/");
assert_eq!(
server.output().stdout(),
format!("managed server ready: {}", server.process_id())
);
assert_eq!(server.output().stderr(), "");
let pid = server.process_id();
assert!(process_state(pid).is_some_and(|state| state != 'Z'));
drop(server);
assert_eq!(process_state(pid), None, "owned server must be reaped");
}
#[cfg(unix)]
#[test]
fn managed_server_cleans_descendants_on_drop_timeout_exit_and_background_reap() {
for mode in ["drop", "timeout", "exit", "background"] {
let publish = if matches!(mode, "drop" | "background") {
"printf '34567\\n' > \"$2\"\n"
} else {
""
};
let finish = if mode == "background" {
"while [ -e \"$2\" ]; do sleep 0.01; done\nexit 23\n"
} else if mode == "exit" {
"sleep 0.03\nexit 23\n"
} else {
"exec sleep 30\n"
};
let script = TestServerScript::new(
mode,
&format!("#!/bin/sh\nsleep 30 &\nprintf '%s' \"$!\"\n{publish}{finish}"),
);
let result = PocketIcStartupConfig::spawn(script.path(), Duration::from_millis(300))
.start_managed_server();
let output = match result {
Ok(server) => {
let output = server.output().stdout().to_owned();
if mode == "background" {
let port = server.server.files.port.clone();
server.server.reap_in_background();
fs::remove_file(port).expect("release the background server after handoff");
} else {
drop(server);
}
output
}
Err(PocketIcStartupError::ReadinessTimeout {
stdout,
termination_error,
..
}) => {
assert_eq!(mode, "timeout");
assert_eq!(termination_error, None);
stdout
}
Err(PocketIcStartupError::ServerExited { stdout, status, .. }) => {
assert_eq!(mode, "exit");
assert_eq!(status.code(), Some(23));
stdout
}
other => panic!(
"unexpected {mode} startup result: {}",
match other {
Err(error) => error.to_string(),
Ok(_) => unreachable!(),
}
),
};
let pid = output
.parse::<u32>()
.expect("server published its descendant PID");
let deadline = Instant::now() + Duration::from_secs(2);
while process_state(pid).is_some_and(|state| state != 'Z') {
assert!(
Instant::now() < deadline,
"{mode} left its descendant running"
);
std::thread::sleep(Duration::from_millis(10));
}
}
}
#[cfg(unix)]
#[test]
fn managed_server_passes_an_explicit_hard_ttl() {
let script = TestServerScript::new(
"hard-ttl",
"#!/bin/sh\nif [ \"$1\" != \"--hard-ttl\" ] || [ \"$2\" != \"17\" ] || [ \"$3\" != \"--port-file\" ] || [ -e \"$4\" ]; then exit 97; fi\nprintf '34567\\n' > \"$4\"\nexec sleep 30\n",
);
let server = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
.with_server_hard_ttl(Duration::from_secs(17))
.start_managed_server()
.expect("start managed server with an explicit hard TTL");
assert_eq!(server.url(), "http://127.0.0.1:34567/");
}
#[test]
#[ignore = "requires POCKET_IC_BIN=<caller-provided PocketIC server binary>"]
fn caller_provided_server_publishes_port_constructs_instance_and_cleans_up() {
let binary = std::env::var_os("POCKET_IC_BIN")
.map(PathBuf::from)
.expect("set POCKET_IC_BIN to the exact server binary");
let one_shot_sequence = super::STARTUP_FILE_SEQUENCE.load(super::Ordering::Relaxed);
let one_shot_directory = std::env::temp_dir().join(format!(
"ic-testkit-pocket-ic-startup-{}-{one_shot_sequence}",
std::process::id()
));
let one_shot = PocketIcBuilder::new()
.with_application_subnet()
.try_build(
PocketIcStartupConfig::spawn(&binary, Duration::from_secs(30))
.with_server_hard_ttl(Duration::from_secs(1)),
)
.expect("one-shot managed spawn must construct an instance");
assert!(one_shot_directory.is_dir());
drop(one_shot);
let cleanup_deadline = Instant::now() + Duration::from_secs(3);
while one_shot_directory.exists() && Instant::now() < cleanup_deadline {
std::thread::sleep(Duration::from_millis(20));
}
assert!(!one_shot_directory.exists());
let server = PocketIcStartupConfig::spawn(&binary, Duration::from_secs(30))
.with_server_hard_ttl(Duration::from_secs(60))
.start_managed_server()
.expect("caller-provided PocketIC server must publish its port");
let files = &server.server.files;
let startup_directory = files.directory.clone();
assert!(files.port.is_file());
let pocket_ic = PocketIcBuilder::new()
.with_application_subnet()
.try_build(PocketIcStartupConfig::connect(
server.url(),
Duration::from_secs(30),
))
.expect("construct instance through caller-provided server");
drop(pocket_ic);
drop(server);
assert!(!startup_directory.exists());
}
#[cfg(unix)]
struct TestServerScript {
path: PathBuf,
}
#[cfg(unix)]
impl TestServerScript {
fn new(label: &str, contents: &str) -> Self {
let path = std::env::temp_dir().join(format!(
"ic-testkit-pocket-ic-{label}-{}-{}",
std::process::id(),
super::STARTUP_FILE_SEQUENCE.fetch_add(1, super::Ordering::Relaxed),
));
write_executable_script(&path, contents);
Self { path }
}
fn path(&self) -> PathBuf {
self.path.clone()
}
}
#[cfg(unix)]
impl Drop for TestServerScript {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
}