Skip to main content

ic_testkit/pic/
startup.rs

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