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