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