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