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