use std::{
fmt::Write as _,
fs::{self, File, OpenOptions},
io,
path::{Path, PathBuf},
process::{Command, ExitStatus, Stdio},
sync::mpsc::{self, RecvTimeoutError},
thread,
time::{Duration, Instant},
};
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt as _;
use crate::pocket_ic::{ClientError, PocketIc, PocketIcBuilder};
use ic_host_fs::{directory::create_unique_directory, owned_directory::OwnedDirectory};
use ic_host_process::child::{CleanupError, CleanupPolicy, OwnedChild};
const STARTUP_POLL_INTERVAL: Duration = Duration::from_millis(20);
const SERVER_OUTPUT_LIMIT: usize = 16 * 1024;
const SERVER_PORT_FILE_LIMIT: usize = 64;
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PocketIcStartupConfig {
Spawn {
server_binary: PathBuf,
timeout: Duration,
server_hard_ttl: Option<Duration>,
server_idle_ttl: Option<Duration>,
server_output_files: Option<(PathBuf, PathBuf)>,
},
Connect {
server_url: String,
timeout: Duration,
},
}
pub struct PocketIcManagedServer {
server: ManagedServer,
url: String,
}
#[must_use]
pub struct PocketIcInstance<T> {
instance: T,
server: Option<PocketIcManagedServer>,
}
impl<T> PocketIcInstance<T> {
pub const fn from_borrowed_server(instance: T) -> Self {
Self {
instance,
server: None,
}
}
#[must_use]
pub const fn instance(&self) -> &T {
&self.instance
}
pub const fn instance_mut(&mut self) -> &mut T {
&mut self.instance
}
#[must_use]
pub const fn server(&self) -> Option<&PocketIcManagedServer> {
self.server.as_ref()
}
#[must_use]
pub fn into_parts(self) -> (T, Option<PocketIcManagedServer>) {
(self.instance, self.server)
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct PocketIcManagedServerOutput {
stdout: String,
stderr: String,
}
#[derive(Debug)]
pub struct PocketIcStartupError {
failure: Box<PocketIcStartupFailure>,
output: PocketIcManagedServerOutput,
command_cleanup: Option<Box<CleanupError>>,
server_cleanup: Option<Box<CleanupError>>,
}
impl PocketIcStartupError {
fn new(failure: PocketIcStartupFailure) -> Self {
Self {
failure: Box::new(failure),
output: PocketIcManagedServerOutput::default(),
command_cleanup: None,
server_cleanup: None,
}
}
#[must_use]
pub fn failure(&self) -> &PocketIcStartupFailure {
&self.failure
}
#[must_use]
pub const fn output(&self) -> &PocketIcManagedServerOutput {
&self.output
}
#[must_use]
pub fn command_cleanup(&self) -> Option<&CleanupError> {
self.command_cleanup.as_deref()
}
#[must_use]
pub fn server_cleanup(&self) -> Option<&CleanupError> {
self.server_cleanup.as_deref()
}
fn with_cleanup(
mut self,
command: Option<CleanupError>,
server: Option<(PocketIcManagedServerOutput, Option<CleanupError>)>,
) -> Self {
self.command_cleanup = command.map(Box::new);
if let Some((output, cleanup)) = server {
self.output = output;
self.server_cleanup = cleanup.map(Box::new);
}
self
}
}
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum PocketIcStartupFailure {
#[error(
"configure IC_TESTKIT_POCKET_IC_URL or POCKET_IC_BIN; prepare a verified binary with make install-server"
)]
NotConfigured,
#[error("invalid selected environment value: {variable}")]
InvalidEnvironment {
variable: &'static str,
},
#[error("PocketIC version probe failed: {source}")]
ServerVersionProbe {
source: ic_host_process::tool::ToolError,
},
#[error("PocketIC version mismatch: expected {expected:?}, observed {:?}", String::from_utf8_lossy(.observed))]
ServerVersionMismatch {
expected: String,
observed: Vec<u8>,
},
#[error("command {} failed: {source}", .program.display())]
CommandRun {
program: PathBuf,
source: io::Error,
},
#[error("{message}")]
InvalidConfiguration {
message: String,
},
#[error("invalid PocketIC server URL {server_url:?}: {message}")]
InvalidServerUrl {
server_url: String,
message: String,
},
#[error("failed to {operation} at {}: {source}", .path.display())]
Io {
operation: &'static str,
path: PathBuf,
source: io::Error,
},
#[error("failed to spawn PocketIC server {}: {source}", .server_binary.display())]
ServerSpawn {
server_binary: PathBuf,
source: io::Error,
},
#[error("PocketIC server {} exited with {status} after {elapsed:?}", .server_binary.display())]
ServerExited {
server_binary: PathBuf,
status: ExitStatus,
elapsed: Duration,
},
#[error("PocketIC server {} was not ready within {timeout:?}", .server_binary.display())]
ReadinessTimeout {
server_binary: PathBuf,
timeout: Duration,
},
#[error("PocketIC server {} published invalid port {value:?}", .server_binary.display())]
InvalidServerPort {
server_binary: PathBuf,
value: String,
},
#[error("PocketIC instance creation exceeded {timeout:?}")]
InstanceCreationTimeout {
timeout: Duration,
},
#[error("failed to spawn PocketIC builder worker: {source}")]
BuilderThreadSpawn {
source: io::Error,
},
#[error("PocketIC startup failed: {source}")]
BuilderFailed {
source: ClientError,
},
#[error("PocketIC builder worker disconnected without a result")]
BuilderDisconnected,
#[error("PocketIC server cleanup failed after command exited with {command_status}")]
ServerCleanup {
command_status: ExitStatus,
},
}
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_else(|| {
PocketIcStartupError::new(PocketIcStartupFailure::InvalidEnvironment {
variable: "IC_TESTKIT_POCKET_IC_URL",
})
})?;
let config = Self::Connect {
server_url: (&server_url).into(),
timeout,
};
config.validate()?;
let _ = configured_builder(PocketIcBuilder::new(), &server_url)?;
return Ok(config);
}
let value = variable("POCKET_IC_BIN")
.ok_or_else(|| PocketIcStartupError::new(PocketIcStartupFailure::NotConfigured))?;
if value.is_empty() {
return Err(PocketIcStartupError::new(
PocketIcStartupFailure::InvalidEnvironment {
variable: "POCKET_IC_BIN",
},
));
}
let path = PathBuf::from(value);
let binary = fs::canonicalize(&path).map_err(|source| {
PocketIcStartupError::new(PocketIcStartupFailure::Io {
operation: "resolve configured PocketIC executable",
path,
source,
})
})?;
let config = Self::Spawn {
server_binary: (&binary).into(),
timeout,
server_hard_ttl: None,
server_idle_ttl: None,
server_output_files: None,
};
config.validate()?;
let evidence = ic_host_process::tool::capture_group_command(
Command::new(&binary).arg("--version"),
ic_host_process::tool::OutputLimits {
stdout: ic_host_process::tool::OutputLimit::Terminate(SERVER_OUTPUT_LIMIT),
stderr: ic_host_process::tool::OutputLimit::Terminate(SERVER_OUTPUT_LIMIT),
timeout: Some(timeout),
},
)
.and_then(ic_host_process::tool::CompletedExecution::require_success)
.map_err(|source| {
PocketIcStartupError::new(PocketIcStartupFailure::ServerVersionProbe { source })
})?;
let expected = super::server::pocket_ic_server_requirement();
if !super::supports_pocket_ic_server(&evidence.stdout) {
return Err(PocketIcStartupError::new(
PocketIcStartupFailure::ServerVersionMismatch {
expected,
observed: evidence.stdout,
},
));
}
Ok(config)
}
#[must_use]
pub const fn timeout(&self) -> Duration {
match self {
Self::Spawn { timeout, .. } | Self::Connect { timeout, .. } => *timeout,
}
}
pub fn build_instance(
self,
builder: PocketIcBuilder,
) -> Result<PocketIcInstance<PocketIc>, PocketIcStartupError> {
let startup = StartupDeadline::new(&self)?;
let (server, url) = match self {
Self::Connect { server_url, .. } => (None, server_url),
spawn @ Self::Spawn { .. } => {
let (server, url) = spawn.spawn_server(startup.started)?.wait_ready(&startup)?;
(Some(server), url)
}
};
build_bounded(builder, &url, &startup, server)
}
pub async fn build_instance_async(
self,
builder: PocketIcBuilder,
) -> Result<PocketIcInstance<crate::pocket_ic::nonblocking::PocketIc>, PocketIcStartupError>
{
let startup = StartupDeadline::new(&self)?;
let (server, server_url) = match self {
Self::Connect { server_url, .. } => (None, server_url),
spawn @ Self::Spawn { .. } => {
let mut server = spawn.spawn_server(startup.started)?;
let server_url = loop {
match server.poll_ready(&startup) {
Ok(Some(url)) => break url,
Ok(None) => {
tokio::time::sleep(startup.wait_interval(Instant::now(), true)).await;
}
Err(error) => return Err(finalize_failure(error, None, Some(server))),
}
};
(Some(server), server_url)
}
};
build_async_bounded(builder, &server_url, &startup, server).await
}
pub fn start_managed_server(self) -> Result<PocketIcManagedServer, PocketIcStartupError> {
if matches!(self, Self::Connect { .. }) {
self.validate()?;
return Err(PocketIcStartupError::new(
PocketIcStartupFailure::InvalidConfiguration {
message: "starting a managed PocketIC server requires a spawn configuration"
.to_owned(),
},
));
}
let startup = StartupDeadline::new(&self)?;
let (server, url) = self.spawn_server(startup.started)?.wait_ready(&startup)?;
Ok(PocketIcManagedServer { server, url })
}
fn spawn_server(self, started: Instant) -> Result<ManagedServer, PocketIcStartupError> {
let Self::Spawn {
server_binary,
server_hard_ttl,
server_idle_ttl,
server_output_files,
..
} = self
else {
unreachable!("connect configurations do not spawn a server")
};
ManagedServer::spawn(
server_binary,
server_hard_ttl,
server_idle_ttl,
server_output_files,
started,
)
}
pub fn run_command(
self,
command: &mut Command,
mut cancelled: impl FnMut() -> bool,
) -> Result<ExitStatus, PocketIcStartupError> {
self.validate()?;
let program = PathBuf::from(command.get_program());
let command_error = |source| {
PocketIcStartupError::new(PocketIcStartupFailure::CommandRun {
program: program.clone(),
source,
})
};
if cancelled() {
return Err(command_error(io::Error::from(io::ErrorKind::Interrupted)));
}
let (server, url) = match self {
Self::Connect { server_url, .. } => (None, server_url),
spawn @ Self::Spawn { .. } => {
let server = spawn.start_managed_server()?;
let url = server.url().to_owned();
(Some(server.server), url)
}
};
let mut server = server;
if cancelled() {
return Err(finalize_failure(
command_error(io::Error::from(io::ErrorKind::Interrupted)),
None,
server,
));
}
command.env("IC_TESTKIT_POCKET_IC_URL", url);
let mut owned_child = match OwnedChild::spawn(command, CleanupPolicy::KillAndWait) {
Ok(child) => child,
Err(source) => {
return Err(finalize_failure(
PocketIcStartupError::new(PocketIcStartupFailure::Io {
operation: "spawn command with PocketIC server",
path: PathBuf::from(command.get_program()),
source,
}),
None,
server,
));
}
};
let result = loop {
if cancelled() {
break Err(command_error(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 Err(error) = managed.check_running()
{
break Err(error);
}
thread::sleep(STARTUP_POLL_INTERVAL);
}
Err(source) => break Err(command_error(source)),
}
};
match result {
Err(error) => Err(finalize_failure(error, Some(&mut owned_child), server)),
Ok(status) => {
if let Some(server) = server {
let (output, cleanup) = server.terminate_and_capture();
if let Some(cleanup) = cleanup {
return Err(PocketIcStartupError::new(
PocketIcStartupFailure::ServerCleanup {
command_status: status,
},
)
.with_cleanup(None, Some((output, Some(cleanup)))));
}
}
Ok(status)
}
}
}
fn validate(&self) -> Result<(), PocketIcStartupError> {
if self.timeout().is_zero() {
return Err(PocketIcStartupError::new(
PocketIcStartupFailure::InvalidConfiguration {
message: "PocketIC startup timeout must be greater than zero".to_owned(),
},
));
}
if let Self::Spawn {
server_hard_ttl,
server_idle_ttl,
..
} = self
{
for (ttl, message) in [
(
server_hard_ttl,
"PocketIC server hard TTL must be at least one second",
),
(
server_idle_ttl,
"PocketIC server idle TTL must be at least one second",
),
] {
if ttl.is_some_and(|ttl| ttl.as_secs() == 0) {
return Err(PocketIcStartupError::new(
PocketIcStartupFailure::InvalidConfiguration {
message: message.to_owned(),
},
));
}
}
}
Ok(())
}
}
impl PocketIcManagedServer {
#[must_use]
pub fn process_id(&self) -> u32 {
self.server.child.id()
}
#[must_use]
pub fn url(&self) -> &str {
&self.url
}
#[must_use]
pub fn output(&self) -> PocketIcManagedServerOutput {
self.server.files.capture()
}
}
impl PocketIcManagedServerOutput {
#[must_use]
pub fn stdout(&self) -> &str {
&self.stdout
}
#[must_use]
pub fn stderr(&self) -> &str {
&self.stderr
}
}
struct StartupDeadline {
started: Instant,
timeout: Duration,
deadline: Instant,
}
impl StartupDeadline {
fn new(config: &PocketIcStartupConfig) -> Result<Self, PocketIcStartupError> {
config.validate()?;
let started = Instant::now();
let timeout = config.timeout();
Ok(Self {
started,
timeout,
deadline: startup_deadline(started, timeout)?,
})
}
fn check_instance_deadline(
&self,
server: &mut Option<ManagedServer>,
) -> Result<Instant, PocketIcStartupError> {
let now = Instant::now();
if now >= self.deadline {
return Err(finalize_failure(
PocketIcStartupError::new(PocketIcStartupFailure::InstanceCreationTimeout {
timeout: self.timeout,
}),
None,
server.take(),
));
}
Ok(now)
}
fn wait_interval(&self, now: Instant, monitor_server: bool) -> Duration {
let remaining = self.deadline.saturating_duration_since(now);
if monitor_server {
remaining.min(STARTUP_POLL_INTERVAL)
} else {
remaining
}
}
fn admit_instance<T>(
&self,
instance: T,
server_url: &str,
server: &mut Option<ManagedServer>,
) -> Result<PocketIcInstance<T>, PocketIcStartupError> {
self.check_instance_deadline(server)?;
check_server(server)?;
Ok(PocketIcInstance {
instance,
server: server.take().map(|server| PocketIcManagedServer {
server,
url: server_url.to_owned(),
}),
})
}
}
fn startup_deadline(started: Instant, timeout: Duration) -> Result<Instant, PocketIcStartupError> {
started.checked_add(timeout).ok_or_else(|| {
PocketIcStartupError::new(PocketIcStartupFailure::InvalidConfiguration {
message: "PocketIC startup timeout exceeds the platform clock range".to_owned(),
})
})
}
struct InstanceStartup<'a> {
deadline: &'a StartupDeadline,
server_url: &'a str,
server: Option<ManagedServer>,
}
impl InstanceStartup<'_> {
fn configure_builder(
&mut self,
builder: PocketIcBuilder,
) -> Result<PocketIcBuilder, PocketIcStartupError> {
configured_builder(builder, self.server_url).map_err(|error| self.fail(error))
}
fn check_deadline(&mut self) -> Result<Instant, PocketIcStartupError> {
self.deadline.check_instance_deadline(&mut self.server)
}
fn wait_interval(&self, now: Instant) -> Duration {
self.deadline.wait_interval(now, self.server.is_some())
}
fn check_server(&mut self) -> Result<(), PocketIcStartupError> {
check_server(&mut self.server)
}
fn admit<T>(&mut self, instance: T) -> Result<PocketIcInstance<T>, PocketIcStartupError> {
self.deadline
.admit_instance(instance, self.server_url, &mut self.server)
}
fn fail(&mut self, error: PocketIcStartupError) -> PocketIcStartupError {
finalize_failure(error, None, self.server.take())
}
}
fn build_bounded(
builder: PocketIcBuilder,
server_url: &str,
startup: &StartupDeadline,
server: Option<ManagedServer>,
) -> Result<PocketIcInstance<PocketIc>, PocketIcStartupError> {
let mut startup = InstanceStartup {
deadline: startup,
server_url,
server,
};
let builder = startup.configure_builder(builder)?;
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 = builder.build();
let _ = sender.send(result);
})
{
return Err(startup.fail(PocketIcStartupError::new(
PocketIcStartupFailure::BuilderThreadSpawn { source },
)));
}
await_builder(receiver, &mut startup)
}
#[expect(
clippy::needless_pass_by_value,
reason = "consume the receiver so failed startup disconnects late builders"
)]
fn await_builder(
receiver: mpsc::Receiver<Result<PocketIc, ClientError>>,
startup: &mut InstanceStartup<'_>,
) -> Result<PocketIcInstance<PocketIc>, PocketIcStartupError> {
loop {
let now = startup.check_deadline()?;
let wait = startup.wait_interval(now);
match receiver.recv_timeout(wait) {
Ok(Ok(pocket_ic)) => {
return startup.admit(pocket_ic);
}
Ok(Err(source)) => {
return Err(startup.fail(PocketIcStartupError::new(
PocketIcStartupFailure::BuilderFailed { source },
)));
}
Err(RecvTimeoutError::Disconnected) => {
return Err(startup.fail(PocketIcStartupError::new(
PocketIcStartupFailure::BuilderDisconnected,
)));
}
Err(RecvTimeoutError::Timeout) => {
startup.check_server()?;
}
}
}
}
fn configured_builder(
builder: PocketIcBuilder,
server_url: &str,
) -> Result<PocketIcBuilder, PocketIcStartupError> {
server_url
.parse()
.map(|url| builder.with_server_url(url))
.map_err(|error| {
PocketIcStartupError::new(PocketIcStartupFailure::InvalidServerUrl {
server_url: server_url.to_owned(),
message: error.to_string(),
})
})
}
fn check_server(server: &mut Option<ManagedServer>) -> Result<(), PocketIcStartupError> {
if let Some(managed) = server.as_mut()
&& let Err(error) = managed.check_running()
{
return Err(finalize_failure(error, None, server.take()));
}
Ok(())
}
async fn build_async_bounded(
builder: PocketIcBuilder,
server_url: &str,
startup: &StartupDeadline,
server: Option<ManagedServer>,
) -> Result<PocketIcInstance<crate::pocket_ic::nonblocking::PocketIc>, PocketIcStartupError> {
let mut startup = InstanceStartup {
deadline: startup,
server_url,
server,
};
let builder = startup.configure_builder(builder)?;
let mut construction = std::pin::pin!(builder.build_async());
let mut startup = startup;
loop {
let now = startup.check_deadline()?;
startup.check_server()?;
let next_check = now + startup.wait_interval(now);
match tokio::time::timeout_at(next_check.into(), construction.as_mut()).await {
Ok(Ok(instance)) => {
return startup.admit(instance);
}
Ok(Err(source)) => {
return Err(startup.fail(PocketIcStartupError::new(
PocketIcStartupFailure::BuilderFailed { source },
)));
}
Err(_) => {}
}
}
}
fn finalize_failure(
error: PocketIcStartupError,
command: Option<&mut OwnedChild>,
server: Option<ManagedServer>,
) -> PocketIcStartupError {
error.with_cleanup(
command.and_then(|child| child.terminate().err()),
server.map(ManagedServer::terminate_and_capture),
)
}
struct ManagedServer {
child: OwnedChild,
binary: PathBuf,
files: StartupFiles,
started: Instant,
}
enum PortFileState {
Pending,
Ready(u16),
Invalid(String),
}
impl ManagedServer {
fn wait_ready(
mut self,
startup: &StartupDeadline,
) -> Result<(Self, String), PocketIcStartupError> {
loop {
match self.poll_ready(startup) {
Ok(Some(url)) => return Ok((self, url)),
Ok(None) => thread::sleep(startup.wait_interval(Instant::now(), true)),
Err(error) => return Err(finalize_failure(error, None, Some(self))),
}
}
}
fn spawn(
binary: PathBuf,
hard_ttl: Option<Duration>,
idle_ttl: Option<Duration>,
output_files: Option<(PathBuf, PathBuf)>,
started: Instant,
) -> Result<Self, 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());
}
if let Some(idle_ttl) = idle_ttl {
command.arg("--ttl").arg(idle_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, CleanupPolicy::KillAndWait).map_err(|source| {
PocketIcStartupError::new(PocketIcStartupFailure::ServerSpawn {
server_binary: binary.clone(),
source,
})
})?;
Ok(Self {
child,
binary,
files,
started,
})
}
fn poll_ready(
&mut self,
startup: &StartupDeadline,
) -> Result<Option<String>, PocketIcStartupError> {
self.check_running()?;
if Instant::now() >= startup.deadline {
return Err(PocketIcStartupError::new(
PocketIcStartupFailure::ReadinessTimeout {
server_binary: self.binary.clone(),
timeout: startup.timeout,
},
));
}
match self.files.read_port()? {
PortFileState::Pending => Ok(None),
PortFileState::Ready(port) => Ok(Some(format!("http://127.0.0.1:{port}/"))),
PortFileState::Invalid(value) => Err(PocketIcStartupError::new(
PocketIcStartupFailure::InvalidServerPort {
server_binary: self.binary.clone(),
value,
},
)),
}
}
fn check_running(&mut self) -> Result<(), PocketIcStartupError> {
if let Some(status) = self.try_wait()? {
return Err(self.exit_failure(status));
}
Ok(())
}
fn try_wait(&mut self) -> Result<Option<ExitStatus>, PocketIcStartupError> {
self.child.try_wait().map_err(|source| {
PocketIcStartupError::new(PocketIcStartupFailure::Io {
operation: "inspect PocketIC server child",
path: self.binary.clone(),
source,
})
})
}
fn exit_failure(&self, status: ExitStatus) -> PocketIcStartupError {
PocketIcStartupError::new(PocketIcStartupFailure::ServerExited {
server_binary: self.binary.clone(),
status,
elapsed: self.started.elapsed(),
})
}
fn terminate_and_capture(self) -> (PocketIcManagedServerOutput, Option<CleanupError>) {
let Self {
mut child, files, ..
} = self;
let cleanup = child.terminate().err();
drop(child);
(files.capture(), cleanup)
}
}
struct StartupFiles {
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "retains directory ownership until cleanup or drop"
)
)]
directory: OwnedDirectory,
port: PathBuf,
stdout: PathBuf,
stderr: PathBuf,
}
impl StartupFiles {
fn read_port(&self) -> Result<PortFileState, PocketIcStartupError> {
let port_path = &self.port;
let contents =
match ic_host_fs::read::read_file_prefix(port_path, SERVER_PORT_FILE_LIMIT + 1)
.map_err(|error| match error {
ic_host_artifacts::artifact::ArtifactError::Io(source) => source,
error => io::Error::new(io::ErrorKind::InvalidData, error),
})
.and_then(|(bytes, _)| {
String::from_utf8(bytes)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
}) {
Ok(contents) => contents,
Err(error) if error.kind() == io::ErrorKind::NotFound => {
return Ok(PortFileState::Pending);
}
Err(source) => {
return Err(PocketIcStartupError::new(PocketIcStartupFailure::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();
match value.parse::<u16>() {
Ok(port) if port != 0 => Ok(PortFileState::Ready(port)),
_ => Ok(PortFileState::Invalid(value.to_owned())),
}
}
fn capture(&self) -> PocketIcManagedServerOutput {
PocketIcManagedServerOutput {
stdout: read_bounded_lossy(&self.stdout),
stderr: read_bounded_lossy(&self.stderr),
}
}
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()?;
let directory = OwnedDirectory::new(
create_unique_directory(
&std::env::temp_dir(),
"ic-testkit-pocket-ic-startup",
0o700,
None,
)
.map_err(|source| startup_file_error("create", &std::env::temp_dir(), source))?,
);
let base = directory.path();
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,
};
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))?;
Ok((files, stdout, stderr))
}
}
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::new(PocketIcStartupFailure::Io {
operation,
path: path.to_owned(),
source,
})
}
fn read_bounded_lossy(path: &Path) -> String {
let Ok((bytes, length)) = ic_host_fs::read::read_file_prefix(path, 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
}
impl std::fmt::Display for PocketIcStartupError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(self.failure.as_ref(), formatter)?;
if !self.output.stdout.is_empty() {
write!(formatter, "; server stdout: {}", self.output.stdout)?;
}
if !self.output.stderr.is_empty() {
write!(formatter, "; server stderr: {}", self.output.stderr)?;
}
for (owner, cleanup) in [
("command", self.command_cleanup()),
("server", self.server_cleanup()),
] {
if let Some(error) = cleanup {
write!(formatter, "; {owner} cleanup also failed: {error}")?;
}
}
Ok(())
}
}
impl std::error::Error for PocketIcStartupError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
std::error::Error::source(self.failure.as_ref())
}
}
#[cfg(test)]
mod tests;