Skip to main content

ic_testkit/pic/
startup.rs

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