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