Skip to main content

ic_testkit/pic/
startup.rs

1use std::{
2    fmt::Write as _,
3    fs::{self, File, OpenOptions},
4    future::Future as _,
5    io::{self, Read as _},
6    path::{Path, PathBuf},
7    process::{Command, ExitStatus, Stdio},
8    sync::mpsc::{self, RecvTimeoutError},
9    thread,
10    time::{Duration, Instant},
11};
12
13#[cfg(unix)]
14use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
15
16use ic_host_process::child::{CleanupError, CleanupPolicy, OwnedChild};
17use ic_platform::pocket_ic::{PocketIc, PocketIcBuilder};
18
19use super::transport;
20
21const STARTUP_POLL_INTERVAL: Duration = Duration::from_millis(20);
22const SERVER_OUTPUT_LIMIT: usize = 16 * 1024;
23// PocketIC publishes a decimal u16 and a newline. Leave whitespace room
24// without allowing readiness polling to read an arbitrary-size file.
25const SERVER_PORT_FILE_LIMIT: usize = 64;
26
27/// Explicit bounded source and policy for one PocketIC startup.
28///
29/// Spawn-only settings cannot be attached to a borrowed connection. Callers own
30/// binary admission, output retention and namespace custody.
31#[derive(Clone, Debug, Eq, PartialEq)]
32pub enum PocketIcStartupConfig {
33    /// Spawn and own one exact server executable.
34    Spawn {
35        /// Caller-selected, already prepared server binary.
36        server_binary: PathBuf,
37        /// Complete startup deadline; must be positive.
38        timeout: Duration,
39        /// Optional hard lifetime, in whole seconds; `None` disables it.
40        server_hard_ttl: Option<Duration>,
41        /// Optional operation-idle lifetime; `None` uses PocketIC's default.
42        server_idle_ttl: Option<Duration>,
43        /// New caller-owned stdout/stderr files, or temporary output when absent.
44        /// Existing entries are refused. Created files survive failure and teardown.
45        server_output_files: Option<(PathBuf, PathBuf)>,
46    },
47    /// Borrow an existing server without claiming its process lifetime.
48    Connect {
49        /// Existing server URL, admitted by the upstream builder.
50        server_url: String,
51        /// Complete startup deadline; must be positive.
52        timeout: Duration,
53    },
54}
55
56/// Caller-owned PocketIC server process with bounded startup and output capture.
57///
58/// Dropping the handle terminates and waits for the managed child. On Unix,
59/// teardown also terminates descendants remaining in its owned process group.
60/// Callers may create several instances through [`Self::url`] and
61/// [`PocketIcStartupConfig::Connect`] while retaining explicit server ownership.
62/// The handle is process-local and does not coordinate ownership across Cargo
63/// or test-runner processes; use an externally owned server with bounded
64/// connect mode for that topology.
65/// The handle owns no binary discovery, download, cache, or compatibility policy.
66pub struct PocketIcManagedServer {
67    server: ManagedServer,
68    url: String,
69}
70
71/// An upstream instance together with the server owned by its construction.
72///
73/// Fields are ordered so a synchronous [`PocketIc`] is deleted before the server
74/// is terminated. Connect mode borrows the caller's server and returns `None`.
75/// Native PocketIC operations remain available directly through `instance`.
76/// Destructuring transfers responsibility for this lifetime ordering to the caller.
77///
78/// PocketIC 16 synchronous deletion has no request deadline and can block drop.
79/// Its nonblocking instance has no automatic deletion: call
80/// `instance.drop().await` while retaining `server` when instance deletion matters,
81/// especially with a borrowed server. Dropping an owned server stops all its instances.
82#[must_use]
83pub struct PocketIcInstance<T> {
84    /// The upstream synchronous or nonblocking PocketIC instance.
85    pub instance: T,
86    /// Exact managed server, or `None` for a borrowed connection.
87    pub server: Option<PocketIcManagedServer>,
88}
89
90/// Bounded lossy UTF-8 output captured from a managed PocketIC server.
91///
92/// Each stream retains at most the first 16 KiB. A textual suffix reports the
93/// number of omitted bytes when truncation occurred. Unreadable streams and
94/// paths replaced with non-regular files are omitted.
95#[derive(Clone, Debug, Default, Eq, PartialEq)]
96pub struct PocketIcManagedServerOutput {
97    stdout: String,
98    stderr: String,
99}
100
101/// Original startup failure, bounded server output and typed cleanup failures.
102///
103/// Match [`Self::failure`] to classify the original cause. Cleanup diagnostics
104/// never replace it, and command/server failures retain separate ownership.
105/// Output is an inert bounded snapshot; it grants no process authority.
106#[derive(Debug)]
107pub struct PocketIcStartupError {
108    failure: Box<PocketIcStartupFailure>,
109    output: PocketIcManagedServerOutput,
110    command_cleanup: Option<Box<CleanupError>>,
111    server_cleanup: Option<Box<CleanupError>>,
112}
113
114impl PocketIcStartupError {
115    fn new(failure: PocketIcStartupFailure) -> Self {
116        Self {
117            failure: Box::new(failure),
118            output: PocketIcManagedServerOutput::default(),
119            command_cleanup: None,
120            server_cleanup: None,
121        }
122    }
123
124    /// Original cause, unaffected by subsequent cleanup failure.
125    #[must_use]
126    pub fn failure(&self) -> &PocketIcStartupFailure {
127        &self.failure
128    }
129
130    /// Bounded output from the server owned by this operation, if any.
131    #[must_use]
132    pub const fn output(&self) -> &PocketIcManagedServerOutput {
133        &self.output
134    }
135
136    /// Failure cleaning the command's owned process group/direct child.
137    #[must_use]
138    pub fn command_cleanup(&self) -> Option<&CleanupError> {
139        self.command_cleanup.as_deref()
140    }
141
142    /// Failure cleaning the managed server's owned process group/direct child.
143    #[must_use]
144    pub fn server_cleanup(&self) -> Option<&CleanupError> {
145        self.server_cleanup.as_deref()
146    }
147
148    fn with_cleanup(
149        mut self,
150        command: Option<CleanupError>,
151        server: Option<(PocketIcManagedServerOutput, Option<CleanupError>)>,
152    ) -> Self {
153        self.command_cleanup = command.map(Box::new);
154        if let Some((output, cleanup)) = server {
155            self.output = output;
156            self.server_cleanup = cleanup.map(Box::new);
157        }
158        self
159    }
160}
161
162/// Original failure, independent of output capture and cleanup outcomes.
163#[non_exhaustive]
164#[derive(Debug, thiserror::Error)]
165pub enum PocketIcStartupFailure {
166    /// Neither the shared server URL nor an explicit executable was configured.
167    #[error(
168        "configure IC_TESTKIT_POCKET_IC_URL or POCKET_IC_BIN; prepare a verified binary with make install-server"
169    )]
170    NotConfigured,
171    /// A selected environment value was empty or was not valid Unicode.
172    #[error("invalid selected environment value: {variable}")]
173    InvalidEnvironment { variable: &'static str },
174    /// The bounded version probe failed, including nonzero exit or timeout.
175    #[error("PocketIC version probe failed: {source}")]
176    ServerVersionProbe {
177        source: ic_host_process::tool::ToolError,
178    },
179    /// The selected executable does not report the qualified server identity.
180    #[error("PocketIC version mismatch: expected {expected:?}, observed {:?}", String::from_utf8_lossy(.observed))]
181    ServerVersionMismatch { expected: String, observed: Vec<u8> },
182    /// Command execution failed.
183    #[error("command {} failed: {source}", .program.display())]
184    CommandRun { program: PathBuf, source: io::Error },
185    /// The caller supplied a zero timeout or unusable hard TTL.
186    #[error("{message}")]
187    InvalidConfiguration { message: String },
188    /// A caller-provided existing server URL could not be parsed.
189    #[error("invalid PocketIC server URL {server_url:?}: {message}")]
190    InvalidServerUrl { server_url: String, message: String },
191    /// Preparing or inspecting bounded startup files failed.
192    #[error("failed to {operation} at {}: {source}", .path.display())]
193    Io {
194        operation: &'static str,
195        path: PathBuf,
196        source: io::Error,
197    },
198    /// The configured PocketIC server process could not be spawned.
199    #[error("failed to spawn PocketIC server {}: {source}", .server_binary.display())]
200    ServerSpawn {
201        server_binary: PathBuf,
202        source: io::Error,
203    },
204    /// The managed PocketIC server exited during construction or command execution.
205    #[error("PocketIC server {} exited with {status} after {elapsed:?}", .server_binary.display())]
206    ServerExited {
207        server_binary: PathBuf,
208        status: ExitStatus,
209        elapsed: Duration,
210    },
211    /// The managed server did not publish a usable port before the deadline.
212    #[error("PocketIC server {} was not ready within {timeout:?}", .server_binary.display())]
213    ReadinessTimeout {
214        server_binary: PathBuf,
215        timeout: Duration,
216    },
217    /// The managed server published an invalid or oversized port-file value.
218    #[error("PocketIC server {} published invalid port {value:?}", .server_binary.display())]
219    InvalidServerPort {
220        server_binary: PathBuf,
221        value: String,
222    },
223    /// PocketIC instance creation did not finish before the startup deadline.
224    #[error("PocketIC instance creation exceeded {timeout:?}")]
225    InstanceCreationTimeout { timeout: Duration },
226    /// Spawning the bounded builder worker failed.
227    #[error("failed to spawn PocketIC builder worker: {source}")]
228    BuilderThreadSpawn { source: io::Error },
229    /// Upstream PocketIC construction panicked before returning an instance.
230    #[error("PocketIC startup panicked: {source}")]
231    BuilderPanicked {
232        source: super::PocketIcOperationError,
233    },
234    /// The bounded builder worker ended without returning a result.
235    #[error("PocketIC builder worker disconnected without a result")]
236    BuilderDisconnected,
237    /// The command completed, but the owned server could not be cleaned up.
238    #[error("PocketIC server cleanup failed after command exited with {command_status}")]
239    ServerCleanup { command_status: ExitStatus },
240}
241
242impl PocketIcStartupConfig {
243    /// Select the shared environment contract without discovery or downloads.
244    ///
245    /// `IC_TESTKIT_POCKET_IC_URL` takes precedence over `POCKET_IC_BIN`. An
246    /// explicitly empty or invalid selected value fails rather than falling
247    /// back. URL mode never launches a version probe or claims server ownership.
248    /// Binary mode resolves the explicit path and checks `--version` against
249    /// Testkit's supported protocol range using the shared bounded capture
250    /// engine. This is version qualification, not executable-byte admission;
251    /// prepare and verify the binary with `ic-testkit-server setup` / `check`.
252    /// The probe has its own `timeout`; subsequent startup has the same budget.
253    pub fn from_env(timeout: Duration) -> Result<Self, PocketIcStartupError> {
254        Self::from_environment(timeout, |name| std::env::var_os(name))
255    }
256
257    fn from_environment(
258        timeout: Duration,
259        mut variable: impl FnMut(&str) -> Option<std::ffi::OsString>,
260    ) -> Result<Self, PocketIcStartupError> {
261        if let Some(value) = variable("IC_TESTKIT_POCKET_IC_URL") {
262            let server_url = value
263                .into_string()
264                .ok()
265                .filter(|value| !value.is_empty())
266                .ok_or_else(|| {
267                    PocketIcStartupError::new(PocketIcStartupFailure::InvalidEnvironment {
268                        variable: "IC_TESTKIT_POCKET_IC_URL",
269                    })
270                })?;
271            let config = Self::Connect {
272                server_url: (&server_url).into(),
273                timeout,
274            };
275            config.validate()?;
276            let _ = configured_builder(PocketIcBuilder::new(), &server_url)?;
277            return Ok(config);
278        }
279        let value = variable("POCKET_IC_BIN")
280            .ok_or_else(|| PocketIcStartupError::new(PocketIcStartupFailure::NotConfigured))?;
281        if value.is_empty() {
282            return Err(PocketIcStartupError::new(
283                PocketIcStartupFailure::InvalidEnvironment {
284                    variable: "POCKET_IC_BIN",
285                },
286            ));
287        }
288        let path = PathBuf::from(value);
289        let binary = fs::canonicalize(&path).map_err(|source| {
290            PocketIcStartupError::new(PocketIcStartupFailure::Io {
291                operation: "resolve configured PocketIC executable",
292                path,
293                source,
294            })
295        })?;
296        let config = Self::Spawn {
297            server_binary: (&binary).into(),
298            timeout,
299            server_hard_ttl: None,
300            server_idle_ttl: None,
301            server_output_files: None,
302        };
303        config.validate()?;
304        let evidence = ic_host_process::tool::capture_group_command(
305            Command::new(&binary).arg("--version"),
306            ic_host_process::tool::OutputLimits {
307                stdout: ic_host_process::tool::OutputLimit::Terminate(SERVER_OUTPUT_LIMIT),
308                stderr: ic_host_process::tool::OutputLimit::Terminate(SERVER_OUTPUT_LIMIT),
309                timeout: Some(timeout),
310            },
311        )
312        .and_then(ic_host_process::tool::CompletedExecution::require_success)
313        .map_err(|source| {
314            PocketIcStartupError::new(PocketIcStartupFailure::ServerVersionProbe { source })
315        })?;
316        let expected = "pocket-ic-server >=16.0.0,<17.0.0 (stable)".to_owned();
317        if !super::supports_pocket_ic_server(&evidence.stdout) {
318            return Err(PocketIcStartupError::new(
319                PocketIcStartupFailure::ServerVersionMismatch {
320                    expected,
321                    observed: evidence.stdout,
322                },
323            ));
324        }
325        Ok(config)
326    }
327
328    /// Complete startup deadline for either source.
329    #[must_use]
330    pub const fn timeout(&self) -> Duration {
331        match self {
332            Self::Spawn { timeout, .. } | Self::Connect { timeout, .. } => *timeout,
333        }
334    }
335
336    /// Construct a synchronous instance and retain its exact managed server.
337    ///
338    /// One deadline covers server readiness and instance creation. Topology,
339    /// state paths and request policy remain on the caller's builder. Spawn mode
340    /// terminates the server on failure and retains it on success; connect mode
341    /// never claims the external server. See [`PocketIcInstance`] for drop limits.
342    ///
343    /// ```no_run
344    /// use ic_testkit::pic::{PocketIcBuilder, PocketIcStartupConfig};
345    /// use std::{path::Path, time::Duration};
346    /// # fn run(binary: &Path) -> Result<(), ic_testkit::pic::PocketIcStartupError> {
347    /// let owned = PocketIcStartupConfig::Spawn { server_binary: binary.into(), timeout: Duration::from_secs(60), server_hard_ttl: None, server_idle_ttl: None, server_output_files: None }
348    ///     .build_instance(PocketIcBuilder::new().with_application_subnet())?;
349    /// let canister = owned.instance.create_canister();
350    /// assert!(owned.instance.canister_exists(canister));
351    /// // Drop deletes the instance, then terminates and reaps the owned server.
352    /// # Ok(())
353    /// # }
354    /// ```
355    pub fn build_instance(
356        self,
357        builder: PocketIcBuilder,
358    ) -> Result<PocketIcInstance<PocketIc>, PocketIcStartupError> {
359        self.validate()?;
360        let started = Instant::now();
361        let timeout = self.timeout();
362        let deadline = startup_deadline(started, timeout)?;
363        match self {
364            Self::Connect { server_url, .. } => {
365                build_bounded(builder, &server_url, deadline, timeout, None)
366            }
367            Self::Spawn {
368                server_binary,
369                server_hard_ttl,
370                server_idle_ttl,
371                server_output_files,
372                ..
373            } => {
374                let (server, server_url) = ManagedServer::start(
375                    server_binary,
376                    server_hard_ttl,
377                    server_idle_ttl,
378                    server_output_files,
379                    deadline,
380                    timeout,
381                    started,
382                )?;
383                build_bounded(builder, &server_url, deadline, timeout, Some(server))
384            }
385        }
386    }
387
388    /// Construct an upstream nonblocking instance on the caller's Tokio runtime.
389    ///
390    /// Uses the same deadline, readiness checks, typed panic/exit failures and
391    /// bounded output as [`Self::build_instance`], without running the synchronous
392    /// instance constructor. Requires a Tokio runtime with its time driver enabled.
393    /// Readiness and instance waits yield; process spawning and small file reads
394    /// remain synchronous, as does exact owned-child cleanup. The deadline cannot
395    /// preempt synchronous work inside an upstream future poll (such as copying
396    /// a read-only state directory); it is enforced when that poll returns.
397    ///
398    /// Dropping the future cancels local construction and terminates/reaps its
399    /// owned server, including during readiness. Cancellation has no returned error;
400    /// select output files to retain diagnostics. A borrowed server is untouched.
401    /// If a borrowed server created an instance before timeout or a lost reply, local
402    /// cancellation cannot identify or delete that instance; the caller owns this
403    /// reconciliation. The deadline covers construction, not subsequent deletion.
404    ///
405    /// ```no_run
406    /// use ic_testkit::pic::{PocketIcBuilder, PocketIcStartupConfig};
407    /// use std::{path::Path, time::Duration};
408    /// # async fn run(binary: &Path) -> Result<(), ic_testkit::pic::PocketIcStartupError> {
409    /// let owned = PocketIcStartupConfig::Spawn { server_binary: binary.into(), timeout: Duration::from_secs(60), server_hard_ttl: None, server_idle_ttl: None, server_output_files: None }
410    ///     .build_instance_async(PocketIcBuilder::new().with_application_subnet()).await?;
411    /// let canister = owned.instance.create_canister().await;
412    /// assert!(owned.instance.canister_exists(canister).await);
413    /// owned.instance.drop().await;
414    /// drop(owned.server);
415    /// # Ok(())
416    /// # }
417    /// ```
418    pub async fn build_instance_async(
419        self,
420        builder: PocketIcBuilder,
421    ) -> Result<PocketIcInstance<ic_platform::pocket_ic::nonblocking::PocketIc>, PocketIcStartupError>
422    {
423        self.validate()?;
424        let started = Instant::now();
425        let timeout = self.timeout();
426        let deadline = startup_deadline(started, timeout)?;
427        let (server, server_url) = match self {
428            Self::Connect { server_url, .. } => (None, server_url),
429            Self::Spawn {
430                server_binary,
431                server_hard_ttl,
432                server_idle_ttl,
433                server_output_files,
434                ..
435            } => {
436                let mut server = ManagedServer::spawn(
437                    server_binary,
438                    server_hard_ttl,
439                    server_idle_ttl,
440                    server_output_files,
441                    started,
442                )?;
443                let server_url = loop {
444                    match server.poll_ready(deadline, timeout) {
445                        Ok(Some(url)) => break url,
446                        Ok(None) => {
447                            tokio::time::sleep(
448                                deadline
449                                    .saturating_duration_since(Instant::now())
450                                    .min(STARTUP_POLL_INTERVAL),
451                            )
452                            .await;
453                        }
454                        Err(error) => return Err(finalize_failure(error, None, Some(server))),
455                    }
456                };
457                (Some(server), server_url)
458            }
459        };
460        build_async_bounded(builder, &server_url, deadline, timeout, server).await
461    }
462
463    /// Start a caller-owned managed server without constructing an instance.
464    ///
465    /// This requires a configuration created by [`Self::Spawn`]. Readiness is
466    /// bounded by [`Self::timeout`]. No hard TTL is passed by default; an
467    /// explicit `server_hard_ttl` value is passed to the child.
468    /// `server_idle_ttl` independently selects the operation-idle
469    /// lifetime; otherwise PocketIC retains its own idle default.
470    /// Readiness requires a nonzero decimal port followed by a newline in a
471    /// regular UTF-8 file of at most 64 bytes; oversized files fail with bounded
472    /// diagnostics. Non-regular port files fail with [`PocketIcStartupFailure::Io`]
473    /// and [`io::ErrorKind::InvalidData`] without waiting for a FIFO writer.
474    /// The returned handle terminates the child on drop; use its URL with
475    /// [`Self::Connect`] to construct bounded instances.
476    pub fn start_managed_server(self) -> Result<PocketIcManagedServer, PocketIcStartupError> {
477        self.validate()?;
478        let timeout = self.timeout();
479        let Self::Spawn {
480            server_binary,
481            server_hard_ttl,
482            server_idle_ttl,
483            server_output_files,
484            ..
485        } = self
486        else {
487            return Err(PocketIcStartupError::new(
488                PocketIcStartupFailure::InvalidConfiguration {
489                    message: "starting a managed PocketIC server requires a spawn configuration"
490                        .to_owned(),
491                },
492            ));
493        };
494        let started = Instant::now();
495        let deadline = startup_deadline(started, timeout)?;
496        let (server, url) = ManagedServer::start(
497            server_binary,
498            server_hard_ttl,
499            server_idle_ttl,
500            server_output_files,
501            deadline,
502            timeout,
503            started,
504        )?;
505        Ok(PocketIcManagedServer { server, url })
506    }
507
508    /// Run a command with `IC_TESTKIT_POCKET_IC_URL` set to this server.
509    ///
510    /// Spawn mode retains the managed server until command completion; connect
511    /// mode borrows the external server and never terminates it. The command's
512    /// IO and other environment selections remain caller-owned. Cancellation
513    /// is polled after bounded startup and every 20 ms while the command runs.
514    /// It returns an [`io::ErrorKind::Interrupted`] error after cleanup.
515    /// If the owned server exits while the command is pending, the command is
516    /// terminated and the server's status and bounded diagnostics are returned
517    /// as [`PocketIcStartupFailure::ServerExited`]. External servers are not monitored.
518    ///
519    /// On Unix the command starts in a new owned process group. Completion,
520    /// cancellation and observation failures terminate remaining group members
521    /// before reaping the leader, using the same lifecycle engine as managed
522    /// servers. This does not impose a command deadline or sandbox descendants
523    /// that deliberately leave the owned group. Other hosts own the direct child.
524    pub fn run_command(
525        self,
526        command: &mut Command,
527        mut cancelled: impl FnMut() -> bool,
528    ) -> Result<ExitStatus, PocketIcStartupError> {
529        self.validate()?;
530        let program = PathBuf::from(command.get_program());
531        let command_error = |source| {
532            PocketIcStartupError::new(PocketIcStartupFailure::CommandRun {
533                program: program.clone(),
534                source,
535            })
536        };
537        if cancelled() {
538            return Err(command_error(io::Error::from(io::ErrorKind::Interrupted)));
539        }
540        let (server, url) = match self {
541            Self::Connect { server_url, .. } => (None, server_url),
542            spawn @ Self::Spawn { .. } => {
543                let server = spawn.start_managed_server()?;
544                let url = server.url().to_owned();
545                (Some(server.server), url)
546            }
547        };
548        let mut server = server;
549        if cancelled() {
550            return Err(finalize_failure(
551                command_error(io::Error::from(io::ErrorKind::Interrupted)),
552                None,
553                server,
554            ));
555        }
556        command.env("IC_TESTKIT_POCKET_IC_URL", url);
557        let mut owned_child = match OwnedChild::spawn(command, CleanupPolicy::KillAndWait) {
558            Ok(child) => child,
559            Err(source) => {
560                return Err(finalize_failure(
561                    PocketIcStartupError::new(PocketIcStartupFailure::Io {
562                        operation: "spawn command with PocketIC server",
563                        path: PathBuf::from(command.get_program()),
564                        source,
565                    }),
566                    None,
567                    server,
568                ));
569            }
570        };
571        let result = loop {
572            if cancelled() {
573                break Err(command_error(io::Error::from(io::ErrorKind::Interrupted)));
574            }
575            match owned_child.try_wait() {
576                Ok(Some(status)) => break Ok(status),
577                Ok(None) => {
578                    if let Some(managed) = server.as_mut() {
579                        match managed.try_wait() {
580                            Ok(Some(status)) => break Err(managed.exit_failure(status)),
581                            Ok(None) => {}
582                            Err(error) => break Err(error),
583                        }
584                    }
585                    thread::sleep(STARTUP_POLL_INTERVAL);
586                }
587                Err(source) => break Err(command_error(source)),
588            }
589        };
590        match result {
591            Err(error) => Err(finalize_failure(error, Some(&mut owned_child), server)),
592            Ok(status) => {
593                if let Some(server) = server {
594                    let (output, cleanup) = server.terminate_and_capture();
595                    if let Some(cleanup) = cleanup {
596                        return Err(PocketIcStartupError::new(
597                            PocketIcStartupFailure::ServerCleanup {
598                                command_status: status,
599                            },
600                        )
601                        .with_cleanup(None, Some((output, Some(cleanup)))));
602                    }
603                }
604                Ok(status)
605            }
606        }
607    }
608
609    fn validate(&self) -> Result<(), PocketIcStartupError> {
610        if self.timeout().is_zero() {
611            return Err(PocketIcStartupError::new(
612                PocketIcStartupFailure::InvalidConfiguration {
613                    message: "PocketIC startup timeout must be greater than zero".to_owned(),
614                },
615            ));
616        }
617        if let Self::Spawn {
618            server_hard_ttl,
619            server_idle_ttl,
620            ..
621        } = self
622        {
623            for (ttl, message) in [
624                (
625                    server_hard_ttl,
626                    "PocketIC server hard TTL must be at least one second",
627                ),
628                (
629                    server_idle_ttl,
630                    "PocketIC server idle TTL must be at least one second",
631                ),
632            ] {
633                if ttl.is_some_and(|ttl| ttl.as_secs() == 0) {
634                    return Err(PocketIcStartupError::new(
635                        PocketIcStartupFailure::InvalidConfiguration {
636                            message: message.to_owned(),
637                        },
638                    ));
639                }
640            }
641        }
642        Ok(())
643    }
644}
645
646impl PocketIcManagedServer {
647    /// OS process ID of the owned server child, for caller-managed monitoring.
648    ///
649    /// This identifies the server process, not its descendants, and does not
650    /// establish that it is still running. The OS may reuse the ID after the
651    /// child exits and is reaped. Dropping this handle terminates and waits for
652    /// the child; retaining the ID does not retain server ownership.
653    #[must_use]
654    pub fn process_id(&self) -> u32 {
655        self.server.child.id()
656    }
657
658    /// Loopback URL published by the managed server.
659    #[must_use]
660    pub fn url(&self) -> &str {
661        &self.url
662    }
663
664    /// Current bounded stdout and stderr captured from the managed server.
665    ///
666    /// This reads at most the first 16 KiB from each retained output file,
667    /// renders it as lossy UTF-8, and adds an omitted-byte suffix when truncated.
668    /// The diagnostic read is bounded; files can grow while the server runs.
669    #[must_use]
670    pub fn output(&self) -> PocketIcManagedServerOutput {
671        self.server.files.capture()
672    }
673}
674
675impl PocketIcManagedServerOutput {
676    /// Bounded lossy UTF-8 standard output.
677    #[must_use]
678    pub fn stdout(&self) -> &str {
679        &self.stdout
680    }
681
682    /// Bounded lossy UTF-8 standard error.
683    #[must_use]
684    pub fn stderr(&self) -> &str {
685        &self.stderr
686    }
687}
688
689fn startup_deadline(started: Instant, timeout: Duration) -> Result<Instant, PocketIcStartupError> {
690    started.checked_add(timeout).ok_or_else(|| {
691        PocketIcStartupError::new(PocketIcStartupFailure::InvalidConfiguration {
692            message: "PocketIC startup timeout exceeds the platform clock range".to_owned(),
693        })
694    })
695}
696
697fn build_bounded(
698    builder: PocketIcBuilder,
699    server_url: &str,
700    deadline: Instant,
701    timeout: Duration,
702    mut server: Option<ManagedServer>,
703) -> Result<PocketIcInstance<PocketIc>, PocketIcStartupError> {
704    let builder = configured_builder(builder, server_url)
705        .map_err(|error| finalize_failure(error, None, server.take()))?;
706    let (sender, receiver) = mpsc::sync_channel(1);
707    if let Err(source) = thread::Builder::new()
708        .name("ic-testkit-pocket-ic-startup".to_owned())
709        .spawn(move || {
710            let result = transport::capture_operation(|| builder.build());
711            let _ = sender.send(result);
712        })
713    {
714        return Err(finalize_failure(
715            PocketIcStartupError::new(PocketIcStartupFailure::BuilderThreadSpawn { source }),
716            None,
717            server,
718        ));
719    }
720    await_builder(receiver, server_url, deadline, timeout, &mut server)
721}
722
723fn await_builder(
724    receiver: mpsc::Receiver<Result<PocketIc, super::PocketIcOperationError>>,
725    server_url: &str,
726    deadline: Instant,
727    timeout: Duration,
728    server: &mut Option<ManagedServer>,
729) -> Result<PocketIcInstance<PocketIc>, PocketIcStartupError> {
730    loop {
731        let now = Instant::now();
732        if now >= deadline {
733            return Err(finalize_failure(
734                PocketIcStartupError::new(PocketIcStartupFailure::InstanceCreationTimeout {
735                    timeout,
736                }),
737                None,
738                server.take(),
739            ));
740        }
741        let remaining = deadline.saturating_duration_since(now);
742        let wait = if server.is_some() {
743            remaining.min(STARTUP_POLL_INTERVAL)
744        } else {
745            remaining
746        };
747        match receiver.recv_timeout(wait) {
748            Ok(Ok(pocket_ic)) => {
749                check_server(server)?;
750                return Ok(PocketIcInstance {
751                    instance: pocket_ic,
752                    server: server.take().map(|server| PocketIcManagedServer {
753                        server,
754                        url: server_url.to_owned(),
755                    }),
756                });
757            }
758            Ok(Err(source)) => {
759                return Err(finalize_failure(
760                    PocketIcStartupError::new(PocketIcStartupFailure::BuilderPanicked { source }),
761                    None,
762                    server.take(),
763                ));
764            }
765            Err(RecvTimeoutError::Disconnected) => {
766                return Err(finalize_failure(
767                    PocketIcStartupError::new(PocketIcStartupFailure::BuilderDisconnected),
768                    None,
769                    server.take(),
770                ));
771            }
772            Err(RecvTimeoutError::Timeout) => {
773                check_server(server)?;
774            }
775        }
776    }
777}
778
779fn configured_builder(
780    builder: PocketIcBuilder,
781    server_url: &str,
782) -> Result<PocketIcBuilder, PocketIcStartupError> {
783    server_url
784        .parse()
785        .map(|url| builder.with_server_url(url))
786        .map_err(|error| {
787            PocketIcStartupError::new(PocketIcStartupFailure::InvalidServerUrl {
788                server_url: server_url.to_owned(),
789                message: error.to_string(),
790            })
791        })
792}
793
794fn check_server(server: &mut Option<ManagedServer>) -> Result<(), PocketIcStartupError> {
795    if let Some(managed) = server.as_mut() {
796        let error = match managed.try_wait() {
797            Ok(Some(status)) => Some(managed.exit_failure(status)),
798            Ok(None) => None,
799            Err(error) => Some(error),
800        };
801        if let Some(error) = error {
802            return Err(finalize_failure(error, None, server.take()));
803        }
804    }
805    Ok(())
806}
807
808async fn build_async_bounded(
809    builder: PocketIcBuilder,
810    server_url: &str,
811    deadline: Instant,
812    timeout: Duration,
813    mut server: Option<ManagedServer>,
814) -> Result<PocketIcInstance<ic_platform::pocket_ic::nonblocking::PocketIc>, PocketIcStartupError> {
815    let builder = configured_builder(builder, server_url)
816        .map_err(|error| finalize_failure(error, None, server.take()))?;
817    let mut construction = std::pin::pin!(builder.build_async());
818    let mut construction = std::pin::pin!(std::future::poll_fn(|context| {
819        match transport::capture_operation(|| construction.as_mut().poll(context)) {
820            Ok(poll) => poll.map(Ok),
821            Err(source) => std::task::Poll::Ready(Err(PocketIcStartupError::new(
822                PocketIcStartupFailure::BuilderPanicked { source },
823            ))),
824        }
825    }));
826    // Drop this local before the pending upstream future on cancellation.
827    let mut server = server;
828    loop {
829        let now = Instant::now();
830        if now >= deadline {
831            return Err(finalize_failure(
832                PocketIcStartupError::new(PocketIcStartupFailure::InstanceCreationTimeout {
833                    timeout,
834                }),
835                None,
836                server,
837            ));
838        }
839        check_server(&mut server)?;
840        let next_check = if server.is_some() {
841            now + deadline
842                .saturating_duration_since(now)
843                .min(STARTUP_POLL_INTERVAL)
844        } else {
845            deadline
846        };
847        match tokio::time::timeout_at(next_check.into(), construction.as_mut()).await {
848            Ok(Ok(instance)) => {
849                if Instant::now() >= deadline {
850                    return Err(finalize_failure(
851                        PocketIcStartupError::new(
852                            PocketIcStartupFailure::InstanceCreationTimeout { timeout },
853                        ),
854                        None,
855                        server,
856                    ));
857                }
858                check_server(&mut server)?;
859                return Ok(PocketIcInstance {
860                    instance,
861                    server: server.map(|server| PocketIcManagedServer {
862                        server,
863                        url: server_url.to_owned(),
864                    }),
865                });
866            }
867            Ok(Err(error)) => return Err(finalize_failure(error, None, server)),
868            Err(_) => {}
869        }
870    }
871}
872
873/// Finalize an owned operation once, preserving the primary failure.
874fn finalize_failure(
875    error: PocketIcStartupError,
876    command: Option<&mut OwnedChild>,
877    server: Option<ManagedServer>,
878) -> PocketIcStartupError {
879    error.with_cleanup(
880        command.and_then(|child| child.terminate().err()),
881        server.map(ManagedServer::terminate_and_capture),
882    )
883}
884
885struct ManagedServer {
886    child: OwnedChild,
887    binary: PathBuf,
888    files: StartupFiles,
889    started: Instant,
890}
891
892enum PortFileState {
893    Pending,
894    Ready(u16),
895    Invalid(String),
896}
897
898impl ManagedServer {
899    fn start(
900        binary: PathBuf,
901        hard_ttl: Option<Duration>,
902        idle_ttl: Option<Duration>,
903        output_files: Option<(PathBuf, PathBuf)>,
904        deadline: Instant,
905        timeout: Duration,
906        started: Instant,
907    ) -> Result<(Self, String), PocketIcStartupError> {
908        let mut server = Self::spawn(binary, hard_ttl, idle_ttl, output_files, started)?;
909        loop {
910            match server.poll_ready(deadline, timeout) {
911                Ok(Some(url)) => return Ok((server, url)),
912                Ok(None) => thread::sleep(
913                    deadline
914                        .saturating_duration_since(Instant::now())
915                        .min(STARTUP_POLL_INTERVAL),
916                ),
917                Err(error) => return Err(finalize_failure(error, None, Some(server))),
918            }
919        }
920    }
921
922    fn spawn(
923        binary: PathBuf,
924        hard_ttl: Option<Duration>,
925        idle_ttl: Option<Duration>,
926        output_files: Option<(PathBuf, PathBuf)>,
927        started: Instant,
928    ) -> Result<Self, PocketIcStartupError> {
929        let (files, stdout, stderr) = StartupFiles::create(output_files)?;
930        let mut command = Command::new(&binary);
931        if let Some(hard_ttl) = hard_ttl {
932            command
933                .arg("--hard-ttl")
934                .arg(hard_ttl.as_secs().to_string());
935        }
936        if let Some(idle_ttl) = idle_ttl {
937            command.arg("--ttl").arg(idle_ttl.as_secs().to_string());
938        }
939        command
940            .arg("--port-file")
941            .arg(&files.port)
942            .stdout(Stdio::from(stdout))
943            .stderr(Stdio::from(stderr));
944        let child =
945            OwnedChild::spawn(&mut command, CleanupPolicy::KillAndWait).map_err(|source| {
946                PocketIcStartupError::new(PocketIcStartupFailure::ServerSpawn {
947                    server_binary: binary.clone(),
948                    source,
949                })
950            })?;
951        Ok(Self {
952            child,
953            binary,
954            files,
955            started,
956        })
957    }
958
959    fn poll_ready(
960        &mut self,
961        deadline: Instant,
962        timeout: Duration,
963    ) -> Result<Option<String>, PocketIcStartupError> {
964        if let Some(status) = self.try_wait()? {
965            return Err(self.exit_failure(status));
966        }
967        if Instant::now() >= deadline {
968            return Err(PocketIcStartupError::new(
969                PocketIcStartupFailure::ReadinessTimeout {
970                    server_binary: self.binary.clone(),
971                    timeout,
972                },
973            ));
974        }
975        match self.files.read_port()? {
976            PortFileState::Pending => Ok(None),
977            PortFileState::Ready(port) => Ok(Some(format!("http://127.0.0.1:{port}/"))),
978            PortFileState::Invalid(value) => Err(PocketIcStartupError::new(
979                PocketIcStartupFailure::InvalidServerPort {
980                    server_binary: self.binary.clone(),
981                    value,
982                },
983            )),
984        }
985    }
986
987    fn try_wait(&mut self) -> Result<Option<ExitStatus>, PocketIcStartupError> {
988        self.child.try_wait().map_err(|source| {
989            PocketIcStartupError::new(PocketIcStartupFailure::Io {
990                operation: "inspect PocketIC server child",
991                path: self.binary.clone(),
992                source,
993            })
994        })
995    }
996
997    fn exit_failure(&self, status: ExitStatus) -> PocketIcStartupError {
998        PocketIcStartupError::new(PocketIcStartupFailure::ServerExited {
999            server_binary: self.binary.clone(),
1000            status,
1001            elapsed: self.started.elapsed(),
1002        })
1003    }
1004
1005    fn terminate_and_capture(self) -> (PocketIcManagedServerOutput, Option<CleanupError>) {
1006        let Self {
1007            mut child, files, ..
1008        } = self;
1009        let cleanup = child.terminate().err();
1010        // Finish the child's Drop cleanup before reading the final diagnostics.
1011        drop(child);
1012        (files.capture(), cleanup)
1013    }
1014}
1015
1016struct StartupFiles {
1017    _directory: tempfile::TempDir,
1018    port: PathBuf,
1019    stdout: PathBuf,
1020    stderr: PathBuf,
1021}
1022
1023impl StartupFiles {
1024    fn read_port(&self) -> Result<PortFileState, PocketIcStartupError> {
1025        let port_path = &self.port;
1026        let mut contents = String::new();
1027        match ic_host_fs::read::open_regular_file(port_path)
1028            .map_err(|error| match error {
1029                ic_host_artifacts::artifact::ArtifactError::Io(source) => source,
1030                error => io::Error::new(io::ErrorKind::InvalidData, error),
1031            })
1032            .and_then(|file| {
1033                file.take((SERVER_PORT_FILE_LIMIT + 1) as u64)
1034                    .read_to_string(&mut contents)
1035            }) {
1036            Ok(_) => {}
1037            Err(error) if error.kind() == io::ErrorKind::NotFound => {
1038                return Ok(PortFileState::Pending);
1039            }
1040            Err(source) => {
1041                return Err(PocketIcStartupError::new(PocketIcStartupFailure::Io {
1042                    operation: "read PocketIC server port file",
1043                    path: port_path.clone(),
1044                    source,
1045                }));
1046            }
1047        }
1048        if contents.len() > SERVER_PORT_FILE_LIMIT {
1049            return Ok(PortFileState::Invalid(format!(
1050                "{} (port file exceeds {SERVER_PORT_FILE_LIMIT} bytes)",
1051                contents.trim()
1052            )));
1053        }
1054        if !contents.contains('\n') {
1055            return Ok(PortFileState::Pending);
1056        }
1057        let value = contents.trim().to_owned();
1058        match value.parse::<u16>() {
1059            Ok(port) if port != 0 => Ok(PortFileState::Ready(port)),
1060            _ => Ok(PortFileState::Invalid(value)),
1061        }
1062    }
1063
1064    fn capture(&self) -> PocketIcManagedServerOutput {
1065        PocketIcManagedServerOutput {
1066            stdout: read_bounded_lossy(&self.stdout),
1067            stderr: read_bounded_lossy(&self.stderr),
1068        }
1069    }
1070
1071    fn create(
1072        output_files: Option<(PathBuf, PathBuf)>,
1073    ) -> Result<(Self, File, File), PocketIcStartupError> {
1074        let output_files = output_files
1075            .map(|(stdout, stderr)| {
1076                Ok::<_, PocketIcStartupError>((
1077                    std::path::absolute(&stdout)
1078                        .map_err(|source| startup_file_error("resolve", &stdout, source))?,
1079                    std::path::absolute(&stderr)
1080                        .map_err(|source| startup_file_error("resolve", &stderr, source))?,
1081                ))
1082            })
1083            .transpose()?;
1084        let mut directory = tempfile::Builder::new();
1085        directory.prefix("ic-testkit-pocket-ic-startup-");
1086        #[cfg(unix)]
1087        directory.permissions(fs::Permissions::from_mode(0o700));
1088        let directory = directory
1089            .tempdir()
1090            .map_err(|source| startup_file_error("create", &std::env::temp_dir(), source))?;
1091        let base = directory.path();
1092        let (stdout_path, stderr_path) =
1093            output_files.unwrap_or_else(|| (base.join("stdout"), base.join("stderr")));
1094        let files = Self {
1095            port: base.join("port"),
1096            stdout: stdout_path,
1097            stderr: stderr_path,
1098            _directory: directory,
1099        };
1100        let stdout = create_new_file(&files.stdout)
1101            .map_err(|source| startup_file_error("create", &files.stdout, source))?;
1102        let stderr = create_new_file(&files.stderr)
1103            .map_err(|source| startup_file_error("create", &files.stderr, source))?;
1104        Ok((files, stdout, stderr))
1105    }
1106}
1107
1108fn create_new_file(path: &Path) -> io::Result<File> {
1109    let mut options = OpenOptions::new();
1110    options.write(true).create_new(true);
1111    #[cfg(unix)]
1112    options.mode(0o600);
1113    options.open(path)
1114}
1115
1116fn startup_file_error(
1117    operation: &'static str,
1118    path: &Path,
1119    source: io::Error,
1120) -> PocketIcStartupError {
1121    PocketIcStartupError::new(PocketIcStartupFailure::Io {
1122        operation,
1123        path: path.to_owned(),
1124        source,
1125    })
1126}
1127
1128fn read_bounded_lossy(path: &Path) -> String {
1129    let Ok((bytes, length)) = ic_host_fs::read::read_file_prefix(path, SERVER_OUTPUT_LIMIT) else {
1130        return String::new();
1131    };
1132    let mut output = String::from_utf8_lossy(&bytes).into_owned();
1133    let omitted = length.saturating_sub(bytes.len() as u64);
1134    if omitted > 0 {
1135        let _ = write!(output, "\n<truncated {omitted} bytes>");
1136    }
1137    output
1138}
1139
1140impl std::fmt::Display for PocketIcStartupError {
1141    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1142        std::fmt::Display::fmt(self.failure.as_ref(), formatter)?;
1143        if !self.output.stdout.is_empty() {
1144            write!(formatter, "; server stdout: {}", self.output.stdout)?;
1145        }
1146        if !self.output.stderr.is_empty() {
1147            write!(formatter, "; server stderr: {}", self.output.stderr)?;
1148        }
1149        for (owner, cleanup) in [
1150            ("command", self.command_cleanup()),
1151            ("server", self.server_cleanup()),
1152        ] {
1153            if let Some(error) = cleanup {
1154                write!(formatter, "; {owner} cleanup also failed: {error}")?;
1155            }
1156        }
1157        Ok(())
1158    }
1159}
1160
1161impl std::error::Error for PocketIcStartupError {
1162    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1163        std::error::Error::source(self.failure.as_ref())
1164    }
1165}
1166
1167#[cfg(test)]
1168mod tests {
1169    mod instances;
1170
1171    use std::{
1172        fs,
1173        path::PathBuf,
1174        time::{Duration, Instant},
1175    };
1176
1177    use super::{
1178        PocketIcStartupConfig, PocketIcStartupError, PocketIcStartupFailure, StartupFiles,
1179    };
1180    use ic_platform::pocket_ic::PocketIcBuilder;
1181
1182    #[cfg(unix)]
1183    use ic_host_process::testing::write_executable_script;
1184    #[cfg(unix)]
1185    use std::{
1186        io::Write as _,
1187        os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _},
1188        process::Command,
1189        sync::mpsc,
1190    };
1191
1192    #[test]
1193    fn original_failure_output_and_both_typed_cleanup_reports_survive_projection() {
1194        use std::error::Error as _;
1195        for failure in [
1196            PocketIcStartupFailure::ReadinessTimeout {
1197                server_binary: "server".into(),
1198                timeout: Duration::from_secs(1),
1199            },
1200            PocketIcStartupFailure::InstanceCreationTimeout {
1201                timeout: Duration::from_secs(1),
1202            },
1203            PocketIcStartupFailure::InvalidServerPort {
1204                server_binary: "server".into(),
1205                value: "invalid".into(),
1206            },
1207            PocketIcStartupFailure::BuilderPanicked {
1208                source: super::super::PocketIcOperationError::new("original-panic"),
1209            },
1210            PocketIcStartupFailure::BuilderDisconnected,
1211            PocketIcStartupFailure::BuilderThreadSpawn {
1212                source: std::io::Error::from_raw_os_error(9),
1213            },
1214            PocketIcStartupFailure::Io {
1215                operation: "original-io",
1216                path: "port".into(),
1217                source: std::io::Error::from_raw_os_error(9),
1218            },
1219        ] {
1220            let primary_text = failure.to_string();
1221            let output = super::PocketIcManagedServerOutput {
1222                stdout: "original-stdout".into(),
1223                stderr: "original-stderr".into(),
1224            };
1225            let command = super::CleanupError {
1226                status: None,
1227                group_error: None,
1228                term_error: Some(std::io::Error::from_raw_os_error(4)),
1229                kill_error: Some(std::io::Error::from_raw_os_error(5)),
1230                wait_error: None,
1231            };
1232            let server = super::CleanupError {
1233                status: None,
1234                group_error: Some(std::io::Error::from_raw_os_error(1)),
1235                term_error: None,
1236                kill_error: None,
1237                wait_error: Some(std::io::Error::from_raw_os_error(10)),
1238            };
1239            let error = PocketIcStartupError::new(failure)
1240                .with_cleanup(Some(command), Some((output, Some(server))));
1241            assert_eq!(error.failure().to_string(), primary_text);
1242            assert_eq!(error.output().stdout(), "original-stdout");
1243            assert_eq!(error.output().stderr(), "original-stderr");
1244            let command = error.command_cleanup().unwrap();
1245            let server = error.server_cleanup().unwrap();
1246            assert_eq!(command.kill_error.as_ref().unwrap().raw_os_error(), Some(5));
1247            assert_eq!(command.term_error.as_ref().unwrap().raw_os_error(), Some(4));
1248            assert_eq!(server.group_error.as_ref().unwrap().raw_os_error(), Some(1));
1249            assert_eq!(server.wait_error.as_ref().unwrap().raw_os_error(), Some(10));
1250            if matches!(
1251                error.failure(),
1252                PocketIcStartupFailure::Io { .. }
1253                    | PocketIcStartupFailure::BuilderThreadSpawn { .. }
1254            ) {
1255                assert_eq!(
1256                    error
1257                        .source()
1258                        .unwrap()
1259                        .downcast_ref::<std::io::Error>()
1260                        .unwrap()
1261                        .raw_os_error(),
1262                    Some(9)
1263                );
1264            }
1265            if let PocketIcStartupFailure::BuilderPanicked { source } = error.failure() {
1266                assert_eq!(
1267                    error
1268                        .source()
1269                        .unwrap()
1270                        .downcast_ref::<super::super::PocketIcOperationError>(),
1271                    Some(source)
1272                );
1273            }
1274            let displayed = error.to_string();
1275            assert!(displayed.starts_with(&primary_text));
1276            for marker in [
1277                "original-stdout",
1278                "original-stderr",
1279                "command cleanup also failed",
1280                "server cleanup also failed",
1281            ] {
1282                assert!(displayed.contains(marker));
1283            }
1284        }
1285        let error = PocketIcStartupError::new(PocketIcStartupFailure::NotConfigured);
1286        assert_eq!(error.to_string(), error.failure().to_string());
1287        assert!(error.command_cleanup().is_none());
1288        assert!(error.server_cleanup().is_none());
1289    }
1290
1291    #[cfg(unix)]
1292    #[test]
1293    fn builder_failures_finalize_the_owned_server_and_keep_output() {
1294        for disconnected in [false, true] {
1295            let script = TestServerScript::new(
1296                "builder-failure",
1297                "#!/bin/sh\nprintf '%s\n%s\n' \"$$\" \"$2\"\nprintf 'builder-diagnostic' >&2\nprintf '34567\n' > \"$2\"\nexec sleep 30\n",
1298            );
1299            let managed = PocketIcStartupConfig::Spawn {
1300                server_binary: script.path(),
1301                timeout: Duration::from_secs(2),
1302                server_hard_ttl: None,
1303                server_idle_ttl: None,
1304                server_output_files: None,
1305            }
1306            .start_managed_server()
1307            .unwrap();
1308            let pid = managed.process_id();
1309            let directory = managed.server.files._directory.path().to_path_buf();
1310            let (sender, receiver) = mpsc::channel();
1311            if !disconnected {
1312                sender
1313                    .send(Err(super::super::PocketIcOperationError::new(
1314                        "original-builder-panic",
1315                    )))
1316                    .unwrap();
1317            }
1318            drop(sender);
1319            let error = super::await_builder(
1320                receiver,
1321                "http://127.0.0.1:34567/",
1322                Instant::now() + Duration::from_secs(2),
1323                Duration::from_secs(2),
1324                &mut Some(managed.server),
1325            )
1326            .err()
1327            .unwrap();
1328            assert!(matches!(
1329                (disconnected, error.failure()),
1330                (true, PocketIcStartupFailure::BuilderDisconnected)
1331                    | (false, PocketIcStartupFailure::BuilderPanicked { .. })
1332            ));
1333            assert_eq!(error.output().stderr(), "builder-diagnostic");
1334            assert!(error.server_cleanup().is_none());
1335            assert!(!directory.exists());
1336            assert_eq!(process_state(pid), None);
1337        }
1338    }
1339
1340    #[cfg(unix)]
1341    #[test]
1342    fn port_io_and_command_spawn_failures_finalize_the_owned_server() {
1343        for port_io in [true, false] {
1344            let port = if port_io {
1345                "mkfifo \"$2\""
1346            } else {
1347                "printf '34567\n' > \"$2\""
1348            };
1349            let script = TestServerScript::new(
1350                "owned-io-failure",
1351                &format!(
1352                    "#!/bin/sh\nprintf '%s\n%s\n' \"$$\" \"$2\"\nprintf 'original-server-diagnostic' >&2\n{port}\nexec sleep 30\n"
1353                ),
1354            );
1355            let config = PocketIcStartupConfig::Spawn {
1356                server_binary: script.path(),
1357                timeout: Duration::from_secs(2),
1358                server_hard_ttl: None,
1359                server_idle_ttl: None,
1360                server_output_files: None,
1361            };
1362            let error = if port_io {
1363                config.start_managed_server().err().unwrap()
1364            } else {
1365                config
1366                    .run_command(&mut Command::new("/missing/ic-testkit-command"), || false)
1367                    .unwrap_err()
1368            };
1369            let PocketIcStartupFailure::Io { source, .. } = error.failure() else {
1370                panic!("expected original IO failure: {error:?}");
1371            };
1372            assert_eq!(
1373                source.kind(),
1374                if port_io {
1375                    std::io::ErrorKind::InvalidData
1376                } else {
1377                    std::io::ErrorKind::NotFound
1378                }
1379            );
1380            assert_eq!(error.output().stderr(), "original-server-diagnostic");
1381            let mut lines = error.output().stdout().lines();
1382            let pid = lines.next().unwrap().parse().unwrap();
1383            let port = PathBuf::from(lines.next().unwrap());
1384            assert_eq!(process_state(pid), None);
1385            assert!(!port.parent().unwrap().exists());
1386            assert!(error.server_cleanup().is_none());
1387        }
1388    }
1389
1390    #[cfg(unix)]
1391    #[test]
1392    fn command_cancellation_before_startup_has_no_spawn_effects() {
1393        let error = PocketIcStartupConfig::Spawn {
1394            server_binary: ("/missing/server").into(),
1395            timeout: Duration::from_secs(1),
1396            server_hard_ttl: None,
1397            server_idle_ttl: None,
1398            server_output_files: None,
1399        }
1400        .run_command(&mut Command::new("/missing/command"), || true)
1401        .unwrap_err();
1402        assert!(
1403            matches!(error.failure(), PocketIcStartupFailure::CommandRun { source, .. } if source.kind() == std::io::ErrorKind::Interrupted)
1404        );
1405        assert_eq!(error.output().stdout(), "");
1406        assert_eq!(error.output().stderr(), "");
1407        assert!(error.command_cleanup().is_none());
1408        assert!(error.server_cleanup().is_none());
1409    }
1410
1411    #[cfg(unix)]
1412    #[test]
1413    fn cancellation_callback_panic_still_reaps_the_owned_command() {
1414        let script = TestServerScript::new(
1415            "cancel-panic",
1416            "#!/bin/sh\nprintf '%s' \"$$\" > \"$1\"\nexec sleep 30\n",
1417        );
1418        let pid_file = script.path().with_extension("pid");
1419        let result = std::panic::catch_unwind(|| {
1420            PocketIcStartupConfig::Connect {
1421                server_url: ("http://127.0.0.1:12345/").into(),
1422                timeout: Duration::from_secs(1),
1423            }
1424            .run_command(Command::new(script.path()).arg(&pid_file), || {
1425                assert!(
1426                    !fs::read_to_string(&pid_file).is_ok_and(|value| !value.is_empty()),
1427                    "caller cancellation failed"
1428                );
1429                false
1430            })
1431        });
1432        assert!(result.is_err());
1433        let pid = fs::read_to_string(&pid_file).unwrap().parse().unwrap();
1434        assert!(process_state(pid).is_none_or(|state| state == 'Z'));
1435        fs::remove_file(pid_file).unwrap();
1436    }
1437
1438    #[cfg(unix)]
1439    #[test]
1440    fn environment_selection_prefers_urls_and_fails_closed() {
1441        use std::os::unix::ffi::OsStringExt as _;
1442
1443        let timeout = Duration::from_secs(1);
1444        let config = PocketIcStartupConfig::from_environment(timeout, |name| {
1445            Some(
1446                if name == "IC_TESTKIT_POCKET_IC_URL" {
1447                    "http://127.0.0.1:12345/"
1448                } else {
1449                    "/missing/server"
1450                }
1451                .into(),
1452            )
1453        })
1454        .unwrap();
1455        assert!(
1456            matches!(config, PocketIcStartupConfig::Connect { ref server_url, .. } if server_url == "http://127.0.0.1:12345/")
1457        );
1458        assert!(matches!(
1459            PocketIcStartupConfig::from_environment(timeout, |_| None)
1460                .as_ref()
1461                .map_err(PocketIcStartupError::failure),
1462            Err(PocketIcStartupFailure::NotConfigured)
1463        ));
1464        assert!(matches!(
1465            PocketIcStartupConfig::from_environment(timeout, |_| Some("".into()))
1466                .as_ref()
1467                .map_err(PocketIcStartupError::failure),
1468            Err(PocketIcStartupFailure::InvalidEnvironment {
1469                variable: "IC_TESTKIT_POCKET_IC_URL"
1470            })
1471        ));
1472        assert!(matches!(
1473            PocketIcStartupConfig::from_environment(timeout, |_| Some("bad URL".into()))
1474                .as_ref()
1475                .map_err(PocketIcStartupError::failure),
1476            Err(PocketIcStartupFailure::InvalidServerUrl { .. })
1477        ));
1478        assert!(matches!(
1479            PocketIcStartupConfig::from_environment(timeout, |_| Some(
1480                std::ffi::OsString::from_vec(vec![0xff])
1481            ))
1482            .as_ref()
1483            .map_err(PocketIcStartupError::failure),
1484            Err(PocketIcStartupFailure::InvalidEnvironment { .. })
1485        ));
1486    }
1487
1488    #[cfg(unix)]
1489    #[test]
1490    fn version_capture_cleans_wrapper_descendants_on_exit_and_timeout() {
1491        for timeout in [false, true] {
1492            let qualified = format!(
1493                "printf 'pocket-ic-server {}\\n'",
1494                ic_platform::pocket_ic::LATEST_SERVER_VERSION
1495            );
1496            let ending = if timeout { "wait" } else { &qualified };
1497            let script = TestServerScript::new(
1498                "version-group",
1499                &format!(
1500                    "#!/bin/sh\nsleep 30 >/dev/null 2>&1 &\nprintf '%s' \"$!\" > \"$0.pid\"\n{ending}\n"
1501                ),
1502            );
1503            let result = PocketIcStartupConfig::from_environment(Duration::from_secs(1), |name| {
1504                (name == "POCKET_IC_BIN").then(|| script.path().into_os_string())
1505            });
1506            if timeout {
1507                assert!(matches!(
1508                    result.as_ref().map_err(PocketIcStartupError::failure),
1509                    Err(PocketIcStartupFailure::ServerVersionProbe { .. })
1510                ));
1511            } else {
1512                assert!(result.is_ok());
1513            }
1514            let pid_file = script.path().with_extension("pid");
1515            let pid = fs::read_to_string(&pid_file).unwrap().parse().unwrap();
1516            let deadline = Instant::now() + Duration::from_secs(3);
1517            while process_state(pid).is_some_and(|state| state != 'Z') {
1518                assert!(
1519                    Instant::now() < deadline,
1520                    "version wrapper descendant remained alive"
1521                );
1522                std::thread::sleep(Duration::from_millis(20));
1523            }
1524            fs::remove_file(pid_file).unwrap();
1525        }
1526    }
1527
1528    #[cfg(unix)]
1529    #[test]
1530    fn owned_builder_panic_retains_server_output_and_reaps_child() {
1531        let script = TestServerScript::new(
1532            "builder-panic",
1533            "#!/bin/sh\nprintf '%s' \"$$\" > \"$0.pid\"\nprintf 'builder stdout'\nprintf 'builder stderr' >&2\nprintf '34567\\n' > \"$2\"\nexec sleep 30\n",
1534        );
1535        let error = PocketIcStartupConfig::Spawn {
1536            server_binary: script.path(),
1537            timeout: Duration::from_secs(2),
1538            server_hard_ttl: None,
1539            server_idle_ttl: None,
1540            server_output_files: None,
1541        }
1542        .build_instance(PocketIcBuilder::new())
1543        .err()
1544        .unwrap();
1545        let PocketIcStartupFailure::BuilderPanicked { source } = error.failure() else {
1546            panic!("expected builder failure, got {error:?}");
1547        };
1548        assert_ne!(source.message(), "");
1549        assert_eq!(
1550            std::error::Error::source(&error)
1551                .unwrap()
1552                .downcast_ref::<super::super::PocketIcOperationError>(),
1553            Some(source)
1554        );
1555        assert_eq!(error.output().stdout(), "builder stdout");
1556        assert_eq!(error.output().stderr(), "builder stderr");
1557        assert!(error.server_cleanup().is_none());
1558        let pid_file = script.path().with_extension("pid");
1559        let pid = fs::read_to_string(&pid_file).unwrap().parse().unwrap();
1560        assert_eq!(process_state(pid), None);
1561        fs::remove_file(pid_file).unwrap();
1562    }
1563
1564    #[cfg(unix)]
1565    #[test]
1566    fn environment_binary_selection_uses_bounded_shared_version_capture() {
1567        let qualified = format!(
1568            "printf 'pocket-ic-server {}\\n'",
1569            ic_platform::pocket_ic::LATEST_SERVER_VERSION
1570        );
1571        let failed = format!("{qualified}; exit 23");
1572        for (label, body, expected) in [
1573            ("qualified", qualified.as_str(), 0),
1574            ("compatible-16.0", "printf 'pocket-ic-server 16.0.0\\n'", 0),
1575            ("compatible-patch", "printf 'pocket-ic-server 16.0.1\\n'", 0),
1576            ("wrong-version", "printf 'pocket-ic-server 15.0.0\\n'", 1),
1577            ("future-major", "printf 'pocket-ic-server 17.0.0\\n'", 1),
1578            ("malformed-version", "printf 'pocket-ic-server 16.1\\n'", 1),
1579            ("failed-version", failed.as_str(), 2),
1580            ("invalid-utf8", "printf '\\377'", 1),
1581            ("version-timeout", "exec sleep 30", 2),
1582        ] {
1583            let script = TestServerScript::new(
1584                label,
1585                &format!("#!/bin/sh\n[ \"$1\" = --version ] || exit 99\n{body}\n"),
1586            );
1587            // A successful process launch needs scheduling headroom on loaded CI
1588            // hosts; only the deliberately stalled probe needs a short deadline.
1589            let timeout = if label == "version-timeout" {
1590                Duration::from_millis(200)
1591            } else {
1592                Duration::from_secs(5)
1593            };
1594            let result = PocketIcStartupConfig::from_environment(timeout, |name| {
1595                (name == "POCKET_IC_BIN").then(|| script.path().into_os_string())
1596            });
1597            match (
1598                expected,
1599                result.as_ref().map_err(PocketIcStartupError::failure),
1600            ) {
1601                (0, Ok(config)) => {
1602                    let binary = script.path().canonicalize().unwrap();
1603                    assert!(
1604                        matches!(config, PocketIcStartupConfig::Spawn { server_binary, .. } if server_binary == &binary)
1605                    );
1606                }
1607                (1, Err(PocketIcStartupFailure::ServerVersionMismatch { .. }))
1608                | (2, Err(PocketIcStartupFailure::ServerVersionProbe { .. })) => {}
1609                (_, result) => panic!("unexpected {label} result: {result:?}"),
1610            }
1611        }
1612    }
1613
1614    #[cfg(unix)]
1615    fn process_state(pid: u32) -> Option<char> {
1616        // Both supported Unix hosts provide this ps field. A zombie has stopped
1617        // running but may remain visible until its parent reaps it.
1618        let output = Command::new("/bin/ps")
1619            .args(["-p", &pid.to_string(), "-o", "stat="])
1620            .output()
1621            .expect("inspect managed test process state");
1622        assert!(
1623            output.status.success()
1624                || (output.status.code() == Some(1)
1625                    && output.stdout.is_empty()
1626                    && output.stderr.is_empty()),
1627            "process-state inspection failed: {}: {}",
1628            output.status,
1629            String::from_utf8_lossy(&output.stderr),
1630        );
1631        String::from_utf8(output.stdout)
1632            .expect("process state is ASCII")
1633            .trim()
1634            .chars()
1635            .next()
1636    }
1637
1638    #[cfg(unix)]
1639    #[test]
1640    fn reading_large_sparse_server_output_is_bounded() {
1641        let (files, _, _) = StartupFiles::create(None).expect("allocate startup files");
1642        let mut file = fs::File::create(&files.stdout).expect("create sparse log");
1643        file.write_all(b"server started\n").expect("write prefix");
1644        let size = 8_u64 * 1024 * 1024 * 1024;
1645        file.set_len(size).expect("extend sparse log");
1646        let output = super::read_bounded_lossy(&files.stdout);
1647        assert!(output.starts_with("server started\n"));
1648        assert!(output.ends_with(&format!(
1649            "<truncated {} bytes>",
1650            size - super::SERVER_OUTPUT_LIMIT as u64
1651        )));
1652        assert!(output.len() < super::SERVER_OUTPUT_LIMIT + 100);
1653    }
1654
1655    #[cfg(unix)]
1656    #[test]
1657    fn startup_readers_reject_fifos_without_waiting_for_a_writer() {
1658        let (files, stdout, stderr) = StartupFiles::create(None).expect("allocate startup files");
1659        drop((stdout, stderr));
1660        fs::remove_file(&files.stdout).unwrap();
1661        fs::remove_file(&files.stderr).unwrap();
1662        assert!(
1663            Command::new("mkfifo")
1664                .args([&files.port, &files.stdout, &files.stderr])
1665                .status()
1666                .expect("create FIFO startup files")
1667                .success()
1668        );
1669        for path in [&files.port, &files.stdout, &files.stderr] {
1670            // A delayed writer bounds a blocked read and records whether the
1671            // reader needed it. Completion cancels the writer; with no reader,
1672            // a nonblocking open fails and is retried if the reader starts late.
1673            let fifo = path.clone();
1674            let (stop_writer, stopped) = mpsc::channel();
1675            let writer = std::thread::spawn(move || {
1676                loop {
1677                    match stopped.recv_timeout(Duration::from_millis(200)) {
1678                        Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return false,
1679                        Err(mpsc::RecvTimeoutError::Timeout) => {}
1680                    }
1681                    if fs::OpenOptions::new()
1682                        .write(true)
1683                        .custom_flags(libc::O_NONBLOCK)
1684                        .open(&fifo)
1685                        .is_ok()
1686                    {
1687                        return true;
1688                    }
1689                }
1690            });
1691            let result = if path == &files.port {
1692                Some(files.read_port())
1693            } else {
1694                assert_eq!(super::read_bounded_lossy(path), "");
1695                None
1696            };
1697            let _ = stop_writer.send(());
1698            assert!(
1699                !writer.join().expect("join delayed FIFO writer"),
1700                "startup reader waited for a writer: {}",
1701                path.display(),
1702            );
1703            if let Some(result) = result {
1704                assert!(matches!(
1705                    result.as_ref().map_err(PocketIcStartupError::failure),
1706                    Err(PocketIcStartupFailure::Io { source, .. })
1707                        if source.kind() == std::io::ErrorKind::InvalidData
1708                ));
1709            }
1710        }
1711    }
1712
1713    #[test]
1714    fn port_file_readiness_preserves_partial_writes_and_rejects_oversized_contents() {
1715        let (files, _, _) = StartupFiles::create(None).expect("allocate startup files");
1716        assert!(matches!(
1717            files.read_port().unwrap(),
1718            super::PortFileState::Pending
1719        ));
1720        for contents in ["", "34567"] {
1721            fs::write(&files.port, contents).unwrap();
1722            assert!(matches!(
1723                files.read_port().unwrap(),
1724                super::PortFileState::Pending
1725            ));
1726        }
1727        for (contents, expected) in [("1\n", 1), ("65535\n", 65535), (" 34567\r\n", 34567)] {
1728            fs::write(&files.port, contents).unwrap();
1729            assert!(matches!(
1730                files.read_port().unwrap(),
1731                super::PortFileState::Ready(port) if port == expected
1732            ));
1733        }
1734        for contents in ["0\n", "65536\n", "invalid\n", "1\n2\n"] {
1735            fs::write(&files.port, contents).unwrap();
1736            assert!(matches!(
1737                files.read_port().unwrap(),
1738                super::PortFileState::Invalid(_)
1739            ));
1740        }
1741        fs::write(&files.port, [0xff, b'\n']).unwrap();
1742        assert!(matches!(
1743            files.read_port().as_ref().map_err(PocketIcStartupError::failure),
1744            Err(PocketIcStartupFailure::Io { source, .. })
1745                if source.kind() == std::io::ErrorKind::InvalidData
1746        ));
1747        for contents in ["1\n".to_owned() + &" ".repeat(128), "0".repeat(128)] {
1748            fs::write(&files.port, contents).unwrap();
1749            assert!(
1750                matches!(files.read_port().unwrap(), super::PortFileState::Invalid(_)),
1751                "oversized port contents must fail even without a newline",
1752            );
1753        }
1754        // A large backing file must not enlarge the returned diagnostic.
1755        fs::File::options()
1756            .write(true)
1757            .open(&files.port)
1758            .unwrap()
1759            .set_len(1024 * 1024)
1760            .unwrap();
1761        assert!(matches!(
1762            files.read_port().unwrap(),
1763            super::PortFileState::Invalid(value) if value.len() < 256
1764        ));
1765    }
1766
1767    #[test]
1768    fn startup_config_requires_positive_bounds() {
1769        let config = PocketIcStartupConfig::Spawn {
1770            server_binary: "pocket-ic".into(),
1771            timeout: Duration::from_secs(1),
1772            server_hard_ttl: None,
1773            server_idle_ttl: Some(Duration::from_millis(1)),
1774            server_output_files: None,
1775        };
1776        assert!(matches!(
1777            config.validate().unwrap_err().failure(),
1778            PocketIcStartupFailure::InvalidConfiguration { .. }
1779        ));
1780        let error = PocketIcStartupConfig::Connect {
1781            server_url: ("http://127.0.0.1:1/").into(),
1782            timeout: Duration::ZERO,
1783        }
1784        .validate()
1785        .expect_err("zero startup timeout must fail");
1786        assert!(matches!(
1787            error.failure(),
1788            PocketIcStartupFailure::InvalidConfiguration { .. }
1789        ));
1790
1791        let error = PocketIcStartupConfig::Spawn {
1792            server_binary: ("pocket-ic").into(),
1793            timeout: Duration::from_secs(1),
1794            server_hard_ttl: Some(Duration::from_millis(1)),
1795            server_idle_ttl: None,
1796            server_output_files: None,
1797        }
1798        .validate()
1799        .expect_err("subsecond server hard TTL must fail");
1800        assert!(matches!(
1801            error.failure(),
1802            PocketIcStartupFailure::InvalidConfiguration { .. }
1803        ));
1804    }
1805
1806    #[test]
1807    fn startup_files_leave_the_server_owned_port_path_absent() {
1808        let (files, stdout, stderr) = StartupFiles::create(None).expect("allocate startup files");
1809        let directory = files._directory.path().to_path_buf();
1810
1811        assert!(directory.is_dir());
1812        assert!(!files.port.exists());
1813        assert!(files.stdout.is_file());
1814        assert!(files.stderr.is_file());
1815        #[cfg(unix)]
1816        {
1817            let mode = fs::metadata(&directory)
1818                .expect("inspect private startup directory")
1819                .permissions()
1820                .mode();
1821            assert_eq!(mode & 0o077, 0);
1822        }
1823
1824        drop(stdout);
1825        drop(stderr);
1826        drop(files);
1827        assert!(!directory.exists());
1828    }
1829
1830    #[cfg(unix)]
1831    #[test]
1832    fn caller_output_files_survive_startup_command_and_cancellation_cleanup() {
1833        for outcome in [
1834            "timeout",
1835            "startup-exit",
1836            "server-exit",
1837            "command-exit",
1838            "cancel",
1839            "success",
1840        ] {
1841            let (owner, _, _) = StartupFiles::create(None).unwrap();
1842            let stdout = owner._directory.path().join("retained-stdout");
1843            let stderr = owner._directory.path().join("retained-stderr");
1844            let ending = match outcome {
1845                "timeout" => "exec sleep 30",
1846                "startup-exit" => "exit 41",
1847                "server-exit" => {
1848                    "printf '34567\\n' > \"$2\"; while [ ! -s \"$0.command\" ]; do sleep 0.02; done; exit 42"
1849                }
1850                _ => "printf '34567\\n' > \"$2\"; exec sleep 30",
1851            };
1852            let script = TestServerScript::new(
1853                outcome,
1854                &format!(
1855                    "#!/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"
1856                ),
1857            );
1858            let pid_file = script.path().with_extension("pid");
1859            let command_file = script.path().with_extension("command");
1860            let config = PocketIcStartupConfig::Spawn {
1861                server_binary: script.path(),
1862                timeout: Duration::from_millis(if outcome == "timeout" { 1000 } else { 2000 }),
1863                server_hard_ttl: None,
1864                server_idle_ttl: None,
1865                server_output_files: Some(((&stdout).into(), (&stderr).into())),
1866            };
1867            if outcome == "timeout" || outcome == "startup-exit" {
1868                let error = config.start_managed_server().err().unwrap();
1869                match (outcome, error.failure()) {
1870                    ("timeout", PocketIcStartupFailure::ReadinessTimeout { .. }) => {
1871                        assert!(error.output().stdout().contains("truncated"));
1872                        assert!(!error.output().stderr().contains("raw-stderr-end"));
1873                    }
1874                    ("startup-exit", PocketIcStartupFailure::ServerExited { status, .. }) => {
1875                        assert_eq!(status.code(), Some(41));
1876                    }
1877                    (_, error) => panic!("unexpected startup result: {error:?}"),
1878                }
1879            } else {
1880                let command_end = match outcome {
1881                    "command-exit" => "exit 37",
1882                    "success" => "exit 0",
1883                    _ => "exec sleep 30",
1884                };
1885                let result = config.run_command(
1886                    Command::new("/bin/sh")
1887                        .args([
1888                            "-c",
1889                            &format!("printf '%s' \"$$\" > \"$1\"; {command_end}"),
1890                            "fixture",
1891                        ])
1892                        .arg(&command_file),
1893                    || {
1894                        outcome == "cancel"
1895                            && fs::metadata(&command_file).is_ok_and(|m| m.len() > 0)
1896                    },
1897                );
1898                let result = result.as_ref().map_err(PocketIcStartupError::failure);
1899                match (outcome, result) {
1900                    ("command-exit", Ok(status)) => assert_eq!(status.code(), Some(37)),
1901                    ("success", Ok(status)) => assert!(status.success()),
1902                    ("server-exit", Err(PocketIcStartupFailure::ServerExited { status, .. })) => {
1903                        assert_eq!(status.code(), Some(42));
1904                    }
1905                    ("cancel", Err(PocketIcStartupFailure::CommandRun { source, .. })) => {
1906                        assert_eq!(source.kind(), std::io::ErrorKind::Interrupted);
1907                    }
1908                    (_, result) => panic!("unexpected command result: {result:?}"),
1909                }
1910                let pid = fs::read_to_string(&command_file).unwrap().parse().unwrap();
1911                assert!(process_state(pid).is_none_or(|state| state == 'Z'));
1912            }
1913            for (path, suffix) in [(&stdout, b"raw-stdout-end"), (&stderr, b"raw-stderr-end")] {
1914                let bytes = fs::read(path).unwrap();
1915                assert_eq!(bytes.len(), 20 * 1024 + suffix.len());
1916                assert!(bytes.ends_with(suffix));
1917                assert_eq!(
1918                    fs::metadata(path).unwrap().permissions().mode() & 0o777,
1919                    0o600
1920                );
1921            }
1922            let report = fs::read_to_string(&pid_file).unwrap();
1923            let mut lines = report.lines();
1924            assert_eq!(process_state(lines.next().unwrap().parse().unwrap()), None);
1925            assert!(
1926                !PathBuf::from(lines.next().unwrap())
1927                    .parent()
1928                    .unwrap()
1929                    .exists()
1930            );
1931            fs::remove_file(pid_file).unwrap();
1932        }
1933    }
1934
1935    #[cfg(unix)]
1936    #[test]
1937    fn caller_output_files_refuse_existing_entries_and_keep_partial_preparation() {
1938        use std::os::unix::fs::symlink;
1939
1940        let (owner, _, _) = StartupFiles::create(None).unwrap();
1941        let stdout = owner._directory.path().join("retained-stdout");
1942        let stderr = owner._directory.path().join("retained-stderr");
1943        let unrelated = owner._directory.path().join("unrelated");
1944        fs::write(&unrelated, b"original").unwrap();
1945        symlink(&unrelated, &stderr).unwrap();
1946        let error = StartupFiles::create(Some((stdout.clone(), stderr.clone())))
1947            .err()
1948            .unwrap();
1949        assert!(
1950            matches!(error.failure(), PocketIcStartupFailure::Io { source, .. } if source.kind() == std::io::ErrorKind::AlreadyExists)
1951        );
1952        assert!(stdout.is_file());
1953        assert_eq!(fs::read(&unrelated).unwrap(), b"original");
1954        fs::write(&stdout, b"retained attempt").unwrap();
1955        assert!(StartupFiles::create(Some((stdout.clone(), stderr.clone()))).is_err());
1956        assert_eq!(fs::read(&stdout).unwrap(), b"retained attempt");
1957        fs::remove_file(stderr.clone()).unwrap();
1958        assert!(
1959            Command::new("mkfifo")
1960                .arg(&stderr)
1961                .status()
1962                .unwrap()
1963                .success()
1964        );
1965        fs::remove_file(stdout.clone()).unwrap();
1966        assert!(StartupFiles::create(Some((stdout.clone(), stderr))).is_err());
1967        assert!(stdout.is_file());
1968    }
1969
1970    #[cfg(unix)]
1971    #[test]
1972    fn managed_startup_reports_an_exited_server_with_bounded_output() {
1973        let script = TestServerScript::new(
1974            "exit",
1975            "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nprintf 'synthetic server stdout'\nprintf 'synthetic bind failure' >&2\nexit 23\n",
1976        );
1977
1978        let result = PocketIcStartupConfig::Spawn {
1979            server_binary: script.path(),
1980            timeout: Duration::from_secs(2),
1981            server_hard_ttl: None,
1982            server_idle_ttl: None,
1983            server_output_files: None,
1984        }
1985        .build_instance(PocketIcBuilder::new().with_application_subnet());
1986
1987        let error = result.err().unwrap();
1988        let PocketIcStartupFailure::ServerExited {
1989            server_binary,
1990            status,
1991            ..
1992        } = error.failure()
1993        else {
1994            panic!("expected a structured server exit, got {error:?}");
1995        };
1996        assert_eq!(server_binary, &script.path());
1997        assert_eq!(status.code(), Some(23));
1998        assert_eq!(error.output().stdout(), "synthetic server stdout");
1999        assert_eq!(error.output().stderr(), "synthetic bind failure");
2000    }
2001
2002    #[cfg(unix)]
2003    #[test]
2004    fn managed_startup_rejects_oversized_port_files_and_cleans_up() {
2005        let script = TestServerScript::new(
2006            "oversized-port",
2007            "#!/bin/sh\nprintf '%s\\n%s\\n' \"$$\" \"$2\"\nprintf '34567\\n%064s' '' > \"$2.pending\"\nmv \"$2.pending\" \"$2\"\nexec sleep 30\n",
2008        );
2009        let result = PocketIcStartupConfig::Spawn {
2010            server_binary: script.path(),
2011            timeout: Duration::from_secs(2),
2012            server_hard_ttl: None,
2013            server_idle_ttl: None,
2014            server_output_files: None,
2015        }
2016        .start_managed_server();
2017        let error = result.err().unwrap();
2018        let PocketIcStartupFailure::InvalidServerPort { value, .. } = error.failure() else {
2019            panic!("oversized port publication must fail readiness");
2020        };
2021        assert!(value.contains("port file exceeds"));
2022        assert!(value.len() < 256);
2023        let mut lines = error.output().stdout().lines();
2024        let pid = lines.next().unwrap().parse::<u32>().unwrap();
2025        let port_path = PathBuf::from(lines.next().unwrap());
2026        assert!(!port_path.parent().unwrap().exists());
2027        assert_eq!(process_state(pid), None, "failed server must be reaped");
2028    }
2029
2030    #[cfg(unix)]
2031    #[test]
2032    fn managed_startup_terminates_a_server_that_never_becomes_ready() {
2033        let script = TestServerScript::new(
2034            "timeout",
2035            "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nexec sleep 30\n",
2036        );
2037        let timeout = Duration::from_millis(100);
2038        let started = Instant::now();
2039
2040        let result = PocketIcStartupConfig::Spawn {
2041            server_binary: script.path(),
2042            timeout,
2043            server_hard_ttl: None,
2044            server_idle_ttl: None,
2045            server_output_files: None,
2046        }
2047        .build_instance(PocketIcBuilder::new().with_application_subnet());
2048
2049        assert!(
2050            started.elapsed() < Duration::from_secs(2),
2051            "bounded startup should not wait for the sleeping child"
2052        );
2053        let error = result.err().unwrap();
2054        assert!(
2055            matches!(error.failure(), PocketIcStartupFailure::ReadinessTimeout {
2056            server_binary, timeout: actual_timeout,
2057        } if server_binary == &script.path() && *actual_timeout == timeout)
2058        );
2059        assert!(error.server_cleanup().is_none());
2060    }
2061
2062    #[cfg(unix)]
2063    #[test]
2064    fn managed_server_handle_exposes_process_id_url_output_and_raii_ownership() {
2065        let script = TestServerScript::new(
2066            "handle",
2067            "#!/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",
2068        );
2069
2070        let server = PocketIcStartupConfig::Spawn {
2071            server_binary: script.path(),
2072            timeout: Duration::from_secs(2),
2073            server_hard_ttl: None,
2074            server_idle_ttl: None,
2075            server_output_files: None,
2076        }
2077        .start_managed_server()
2078        .expect("start caller-owned managed server");
2079
2080        assert_eq!(server.url(), "http://127.0.0.1:34567/");
2081        assert_eq!(
2082            server.output().stdout(),
2083            format!("managed server ready: {}", server.process_id())
2084        );
2085        assert_eq!(server.output().stderr(), "");
2086        let pid = server.process_id();
2087        assert!(process_state(pid).is_some_and(|state| state != 'Z'));
2088        drop(server);
2089        assert_eq!(process_state(pid), None, "owned server must be reaped");
2090    }
2091
2092    #[cfg(unix)]
2093    #[test]
2094    fn managed_server_cleans_descendants_on_drop_timeout_and_exit() {
2095        for mode in ["drop", "timeout", "exit"] {
2096            let publish = if mode == "drop" {
2097                "printf '34567\\n' > \"$2\"\n"
2098            } else {
2099                ""
2100            };
2101            let finish = if mode == "exit" {
2102                "sleep 0.03\nexit 23\n"
2103            } else {
2104                "exec sleep 30\n"
2105            };
2106            let script = TestServerScript::new(
2107                mode,
2108                &format!("#!/bin/sh\nsleep 30 &\nprintf '%s' \"$!\"\n{publish}{finish}"),
2109            );
2110            let result = PocketIcStartupConfig::Spawn {
2111                server_binary: script.path(),
2112                timeout: Duration::from_millis(300),
2113                server_hard_ttl: None,
2114                server_idle_ttl: None,
2115                server_output_files: None,
2116            }
2117            .start_managed_server();
2118            let output = match result {
2119                Ok(server) => {
2120                    let output = server.output().stdout().to_owned();
2121                    drop(server);
2122                    output
2123                }
2124                Err(error) => {
2125                    match error.failure() {
2126                        PocketIcStartupFailure::ReadinessTimeout { .. } => {
2127                            assert_eq!(mode, "timeout");
2128                            assert!(error.server_cleanup().is_none());
2129                        }
2130                        PocketIcStartupFailure::ServerExited { status, .. } => {
2131                            assert_eq!(mode, "exit");
2132                            assert_eq!(status.code(), Some(23));
2133                        }
2134                        _ => panic!("unexpected {mode} startup result: {error}"),
2135                    }
2136                    error.output().stdout().to_owned()
2137                }
2138            };
2139            let pid = output
2140                .parse::<u32>()
2141                .expect("server published its descendant PID");
2142            let deadline = Instant::now() + Duration::from_secs(2);
2143            while process_state(pid).is_some_and(|state| state != 'Z') {
2144                assert!(
2145                    Instant::now() < deadline,
2146                    "{mode} left its descendant running"
2147                );
2148                std::thread::sleep(Duration::from_millis(10));
2149            }
2150        }
2151    }
2152
2153    #[cfg(unix)]
2154    #[test]
2155    fn managed_server_passes_an_explicit_hard_ttl() {
2156        let script = TestServerScript::new(
2157            "hard-ttl",
2158            "#!/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",
2159        );
2160
2161        let server = PocketIcStartupConfig::Spawn {
2162            server_binary: script.path(),
2163            timeout: Duration::from_secs(2),
2164            server_hard_ttl: Some(Duration::from_secs(17)),
2165            server_idle_ttl: None,
2166            server_output_files: None,
2167        }
2168        .start_managed_server()
2169        .expect("start managed server with an explicit hard TTL");
2170
2171        assert_eq!(server.url(), "http://127.0.0.1:34567/");
2172    }
2173
2174    #[cfg(unix)]
2175    #[test]
2176    #[ignore = "requires POCKET_IC_BIN=<caller-provided PocketIC server binary>"]
2177    fn caller_provided_server_publishes_port_constructs_instance_and_cleans_up() {
2178        let binary = std::env::var_os("POCKET_IC_BIN")
2179            .map(PathBuf::from)
2180            .expect("set POCKET_IC_BIN to the exact server binary");
2181        // Observe this exact child's port path without inspecting other startup
2182        // directories created concurrently.
2183        let wrapper = TestServerScript::new(
2184            "real-owned-instance",
2185            "#!/bin/sh\nprintf '%s\n%s\n' \"$$\" \"$4\" > \"$0.record\"\nexec \"$POCKET_IC_BIN\" \"$@\"\n",
2186        );
2187        let owned = PocketIcStartupConfig::Spawn {
2188            server_binary: wrapper.path(),
2189            timeout: Duration::from_secs(30),
2190            server_hard_ttl: Some(Duration::from_secs(60)),
2191            server_idle_ttl: None,
2192            server_output_files: None,
2193        }
2194        .build_instance(PocketIcBuilder::new().with_application_subnet())
2195        .expect("owned managed spawn must construct an instance");
2196        let record_file = wrapper.path().with_extension("record");
2197        let record = fs::read_to_string(&record_file).unwrap();
2198        let mut fields = record.lines();
2199        let pid = fields.next().unwrap().parse().unwrap();
2200        let port_path = PathBuf::from(fields.next().unwrap());
2201        let startup_directory = port_path.parent().unwrap();
2202        drop(owned);
2203        assert!(!startup_directory.exists());
2204        assert_eq!(process_state(pid), None);
2205        fs::remove_file(record_file).unwrap();
2206
2207        let server = PocketIcStartupConfig::Spawn {
2208            server_binary: (&binary).into(),
2209            timeout: Duration::from_secs(30),
2210            server_hard_ttl: Some(Duration::from_secs(60)),
2211            server_idle_ttl: None,
2212            server_output_files: None,
2213        }
2214        .start_managed_server()
2215        .expect("caller-provided PocketIC server must publish its port");
2216        let files = &server.server.files;
2217        let startup_directory = files._directory.path().to_path_buf();
2218
2219        assert!(files.port.is_file());
2220        let pocket_ic = PocketIcStartupConfig::Connect {
2221            server_url: (server.url()).into(),
2222            timeout: Duration::from_secs(30),
2223        }
2224        .build_instance(PocketIcBuilder::new().with_application_subnet())
2225        .expect("construct instance through caller-provided server");
2226
2227        drop(pocket_ic);
2228        drop(server);
2229        assert!(!startup_directory.exists());
2230    }
2231
2232    #[cfg(unix)]
2233    struct TestServerScript {
2234        path: PathBuf,
2235        _directory: tempfile::TempDir,
2236    }
2237
2238    #[cfg(unix)]
2239    impl TestServerScript {
2240        fn new(label: &str, contents: &str) -> Self {
2241            let directory = tempfile::Builder::new()
2242                .prefix(&format!("ic-testkit-pocket-ic-{label}-"))
2243                .permissions(fs::Permissions::from_mode(0o700))
2244                .tempdir()
2245                .expect("create test server directory");
2246            let path = directory.path().join("server");
2247            write_executable_script(&path, contents).expect("write executable test script");
2248            Self {
2249                path,
2250                _directory: directory,
2251            }
2252        }
2253
2254        fn path(&self) -> PathBuf {
2255            self.path.clone()
2256        }
2257    }
2258}