Skip to main content

ic_testkit/pic/
startup.rs

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