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