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 qualified = format!(
1313                "printf 'pocket-ic-server {}\\n'",
1314                pocket_ic::LATEST_SERVER_VERSION
1315            );
1316            let ending = if timeout { "wait" } else { &qualified };
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        let qualified = format!(
1385            "printf 'pocket-ic-server {}\\n'",
1386            pocket_ic::LATEST_SERVER_VERSION
1387        );
1388        let failed = format!("{qualified}; exit 23");
1389        for (label, body, expected) in [
1390            ("qualified", qualified.as_str(), 0),
1391            ("wrong-version", "printf 'pocket-ic-server 15.0.0\\n'", 1),
1392            ("failed-version", failed.as_str(), 2),
1393            ("invalid-utf8", "printf '\\377'", 1),
1394            ("version-timeout", "exec sleep 30", 2),
1395        ] {
1396            let script = TestServerScript::new(
1397                label,
1398                &format!("#!/bin/sh\n[ \"$1\" = --version ] || exit 99\n{body}\n"),
1399            );
1400            let result =
1401                PocketIcStartupConfig::from_environment(Duration::from_millis(200), |name| {
1402                    (name == "POCKET_IC_BIN").then(|| script.path().into_os_string())
1403                });
1404            match (expected, result) {
1405                (0, Ok(config)) => {
1406                    let binary = script.path().canonicalize().unwrap();
1407                    assert_eq!(config.server_binary(), Some(binary.as_path()));
1408                }
1409                (1, Err(PocketIcStartupError::ServerVersionMismatch { .. }))
1410                | (2, Err(PocketIcStartupError::ServerVersionProbe { .. })) => {}
1411                (_, result) => panic!("unexpected {label} result: {result:?}"),
1412            }
1413        }
1414    }
1415
1416    #[cfg(unix)]
1417    fn process_state(pid: u32) -> Option<char> {
1418        // Both supported Unix hosts provide this ps field. A zombie has stopped
1419        // running but may remain visible until its parent reaps it.
1420        let output = Command::new("/bin/ps")
1421            .args(["-p", &pid.to_string(), "-o", "stat="])
1422            .output()
1423            .expect("inspect managed test process state");
1424        assert!(
1425            output.status.success()
1426                || (output.status.code() == Some(1)
1427                    && output.stdout.is_empty()
1428                    && output.stderr.is_empty()),
1429            "process-state inspection failed: {}: {}",
1430            output.status,
1431            String::from_utf8_lossy(&output.stderr),
1432        );
1433        String::from_utf8(output.stdout)
1434            .expect("process state is ASCII")
1435            .trim()
1436            .chars()
1437            .next()
1438    }
1439
1440    #[cfg(unix)]
1441    #[test]
1442    fn reading_large_sparse_server_output_is_bounded() {
1443        let (files, _, _) = StartupFiles::create(None).expect("allocate startup files");
1444        let mut file = fs::File::create(&files.stdout).expect("create sparse log");
1445        file.write_all(b"server started\n").expect("write prefix");
1446        let size = 8_u64 * 1024 * 1024 * 1024;
1447        file.set_len(size).expect("extend sparse log");
1448        let output = super::read_bounded_lossy(&files.stdout);
1449        assert!(output.starts_with("server started\n"));
1450        assert!(output.ends_with(&format!(
1451            "<truncated {} bytes>",
1452            size - super::SERVER_OUTPUT_LIMIT as u64
1453        )));
1454        assert!(output.len() < super::SERVER_OUTPUT_LIMIT + 100);
1455    }
1456
1457    #[cfg(unix)]
1458    #[test]
1459    fn startup_readers_reject_fifos_without_waiting_for_a_writer() {
1460        let (files, stdout, stderr) = StartupFiles::create(None).expect("allocate startup files");
1461        drop((stdout, stderr));
1462        fs::remove_file(&files.stdout).unwrap();
1463        fs::remove_file(&files.stderr).unwrap();
1464        assert!(
1465            Command::new("mkfifo")
1466                .args([&files.port, &files.stdout, &files.stderr])
1467                .status()
1468                .expect("create FIFO startup files")
1469                .success()
1470        );
1471        let server = super::ManagedServer {
1472            child: None,
1473            binary: PathBuf::from("unused-server"),
1474            files,
1475            started: Instant::now(),
1476        };
1477        for path in [
1478            &server.files.port,
1479            &server.files.stdout,
1480            &server.files.stderr,
1481        ] {
1482            // A delayed writer bounds a blocked read and records whether the
1483            // reader needed it. Completion cancels the writer; with no reader,
1484            // a nonblocking open fails and is retried if the reader starts late.
1485            let fifo = path.clone();
1486            let (stop_writer, stopped) = mpsc::channel();
1487            let writer = std::thread::spawn(move || {
1488                loop {
1489                    match stopped.recv_timeout(Duration::from_millis(200)) {
1490                        Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return false,
1491                        Err(mpsc::RecvTimeoutError::Timeout) => {}
1492                    }
1493                    if fs::OpenOptions::new()
1494                        .write(true)
1495                        .custom_flags(libc::O_NONBLOCK)
1496                        .open(&fifo)
1497                        .is_ok()
1498                    {
1499                        return true;
1500                    }
1501                }
1502            });
1503            let result = if path == &server.files.port {
1504                Some(server.read_port())
1505            } else {
1506                assert_eq!(super::read_bounded_lossy(path), "");
1507                None
1508            };
1509            let _ = stop_writer.send(());
1510            assert!(
1511                !writer.join().expect("join delayed FIFO writer"),
1512                "startup reader waited for a writer: {}",
1513                path.display(),
1514            );
1515            if let Some(result) = result {
1516                assert!(matches!(
1517                    result,
1518                    Err(PocketIcStartupError::Io { source, .. })
1519                        if source.kind() == std::io::ErrorKind::InvalidData
1520                ));
1521            }
1522        }
1523    }
1524
1525    #[test]
1526    fn port_file_readiness_preserves_partial_writes_and_rejects_oversized_contents() {
1527        let (files, _, _) = StartupFiles::create(None).expect("allocate startup files");
1528        let server = super::ManagedServer {
1529            child: None,
1530            binary: PathBuf::from("unused-server"),
1531            files,
1532            started: Instant::now(),
1533        };
1534        assert!(matches!(
1535            server.read_port().unwrap(),
1536            super::PortFileState::Pending
1537        ));
1538        for contents in ["", "34567"] {
1539            fs::write(&server.files.port, contents).unwrap();
1540            assert!(matches!(
1541                server.read_port().unwrap(),
1542                super::PortFileState::Pending
1543            ));
1544        }
1545        for (contents, expected) in [("1\n", 1), ("65535\n", 65535), (" 34567\r\n", 34567)] {
1546            fs::write(&server.files.port, contents).unwrap();
1547            assert!(matches!(
1548                server.read_port().unwrap(),
1549                super::PortFileState::Ready(port) if port == expected
1550            ));
1551        }
1552        for contents in ["0\n", "65536\n", "invalid\n", "1\n2\n"] {
1553            fs::write(&server.files.port, contents).unwrap();
1554            assert!(matches!(
1555                server.read_port().unwrap(),
1556                super::PortFileState::Invalid(_)
1557            ));
1558        }
1559        fs::write(&server.files.port, [0xff, b'\n']).unwrap();
1560        assert!(matches!(
1561            server.read_port(),
1562            Err(PocketIcStartupError::Io { source, .. })
1563                if source.kind() == std::io::ErrorKind::InvalidData
1564        ));
1565        for contents in ["1\n".to_owned() + &" ".repeat(128), "0".repeat(128)] {
1566            fs::write(&server.files.port, contents).unwrap();
1567            assert!(
1568                matches!(
1569                    server.read_port().unwrap(),
1570                    super::PortFileState::Invalid(_)
1571                ),
1572                "oversized port contents must fail even without a newline",
1573            );
1574        }
1575        // A large backing file must not enlarge the returned diagnostic.
1576        fs::File::options()
1577            .write(true)
1578            .open(&server.files.port)
1579            .unwrap()
1580            .set_len(1024 * 1024)
1581            .unwrap();
1582        assert!(matches!(
1583            server.read_port().unwrap(),
1584            super::PortFileState::Invalid(value) if value.len() < 256
1585        ));
1586    }
1587
1588    #[test]
1589    fn startup_config_requires_positive_bounds() {
1590        let error = PocketIcStartupConfig::connect("http://127.0.0.1:1/", Duration::ZERO)
1591            .validate()
1592            .expect_err("zero startup timeout must fail");
1593        assert!(matches!(
1594            error,
1595            PocketIcStartupError::InvalidConfiguration { .. }
1596        ));
1597
1598        let error = PocketIcStartupConfig::spawn("pocket-ic", Duration::from_secs(1))
1599            .with_server_hard_ttl(Duration::from_millis(1))
1600            .validate()
1601            .expect_err("subsecond server hard TTL must fail");
1602        assert!(matches!(
1603            error,
1604            PocketIcStartupError::InvalidConfiguration { .. }
1605        ));
1606    }
1607
1608    #[test]
1609    fn managed_server_hard_ttl_is_opt_in() {
1610        let default = PocketIcStartupConfig::spawn("pocket-ic", Duration::from_secs(1));
1611        assert_eq!(default.server_hard_ttl(), None);
1612
1613        let explicit = default.with_server_hard_ttl(Duration::from_secs(17));
1614        assert_eq!(explicit.server_hard_ttl(), Some(Duration::from_secs(17)));
1615    }
1616
1617    #[test]
1618    fn startup_files_leave_the_server_owned_port_path_absent() {
1619        let (files, stdout, stderr) = StartupFiles::create(None).expect("allocate startup files");
1620        let directory = files.directory.clone();
1621
1622        assert!(directory.is_dir());
1623        assert!(!files.port.exists());
1624        assert!(files.stdout.is_file());
1625        assert!(files.stderr.is_file());
1626        #[cfg(unix)]
1627        {
1628            let mode = fs::metadata(&directory)
1629                .expect("inspect private startup directory")
1630                .permissions()
1631                .mode();
1632            assert_eq!(mode & 0o077, 0);
1633        }
1634
1635        drop(stdout);
1636        drop(stderr);
1637        drop(files);
1638        assert!(!directory.exists());
1639    }
1640
1641    #[cfg(unix)]
1642    #[test]
1643    fn caller_output_files_survive_startup_command_and_cancellation_cleanup() {
1644        for outcome in [
1645            "timeout",
1646            "startup-exit",
1647            "server-exit",
1648            "command-exit",
1649            "cancel",
1650            "success",
1651        ] {
1652            let (owner, _, _) = StartupFiles::create(None).unwrap();
1653            let stdout = owner.directory.join("retained-stdout");
1654            let stderr = owner.directory.join("retained-stderr");
1655            let ending = match outcome {
1656                "timeout" => "exec sleep 30",
1657                "startup-exit" => "exit 41",
1658                "server-exit" => {
1659                    "printf '34567\\n' > \"$2\"; while [ ! -s \"$0.command\" ]; do sleep 0.02; done; exit 42"
1660                }
1661                _ => "printf '34567\\n' > \"$2\"; exec sleep 30",
1662            };
1663            let script = TestServerScript::new(
1664                outcome,
1665                &format!(
1666                    "#!/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"
1667                ),
1668            );
1669            let pid_file = script.path().with_extension("pid");
1670            let command_file = script.path().with_extension("command");
1671            let config = PocketIcStartupConfig::spawn(
1672                script.path(),
1673                Duration::from_millis(if outcome == "timeout" { 1000 } else { 2000 }),
1674            )
1675            .with_server_output_files(&stdout, &stderr);
1676            if outcome == "timeout" || outcome == "startup-exit" {
1677                let error = config.start_managed_server().err().unwrap();
1678                match (outcome, error) {
1679                    ("timeout", PocketIcStartupError::ReadinessTimeout { stdout, stderr, .. }) => {
1680                        assert!(stdout.contains("truncated"));
1681                        assert!(!stderr.contains("raw-stderr-end"));
1682                    }
1683                    ("startup-exit", PocketIcStartupError::ServerExited { status, .. }) => {
1684                        assert_eq!(status.code(), Some(41));
1685                    }
1686                    (_, error) => panic!("unexpected startup result: {error:?}"),
1687                }
1688            } else {
1689                let command_end = match outcome {
1690                    "command-exit" => "exit 37",
1691                    "success" => "exit 0",
1692                    _ => "exec sleep 30",
1693                };
1694                let result = config.run_command(
1695                    Command::new("/bin/sh")
1696                        .args([
1697                            "-c",
1698                            &format!("printf '%s' \"$$\" > \"$1\"; {command_end}"),
1699                            "fixture",
1700                        ])
1701                        .arg(&command_file),
1702                    || {
1703                        outcome == "cancel"
1704                            && fs::metadata(&command_file).is_ok_and(|m| m.len() > 0)
1705                    },
1706                );
1707                match (outcome, result) {
1708                    ("command-exit", Ok(status)) => assert_eq!(status.code(), Some(37)),
1709                    ("success", Ok(status)) => assert!(status.success()),
1710                    ("server-exit", Err(PocketIcStartupError::ServerExited { status, .. })) => {
1711                        assert_eq!(status.code(), Some(42));
1712                    }
1713                    ("cancel", Err(PocketIcStartupError::CommandRun { source, .. })) => {
1714                        assert_eq!(source.kind(), std::io::ErrorKind::Interrupted);
1715                    }
1716                    (_, result) => panic!("unexpected command result: {result:?}"),
1717                }
1718                let pid = fs::read_to_string(&command_file).unwrap().parse().unwrap();
1719                assert!(process_state(pid).is_none_or(|state| state == 'Z'));
1720                fs::remove_file(command_file).unwrap();
1721            }
1722            for (path, suffix) in [(&stdout, b"raw-stdout-end"), (&stderr, b"raw-stderr-end")] {
1723                let bytes = fs::read(path).unwrap();
1724                assert_eq!(bytes.len(), 20 * 1024 + suffix.len());
1725                assert!(bytes.ends_with(suffix));
1726                assert_eq!(
1727                    fs::metadata(path).unwrap().permissions().mode() & 0o777,
1728                    0o600
1729                );
1730            }
1731            let report = fs::read_to_string(&pid_file).unwrap();
1732            let mut lines = report.lines();
1733            assert_eq!(process_state(lines.next().unwrap().parse().unwrap()), None);
1734            assert!(
1735                !PathBuf::from(lines.next().unwrap())
1736                    .parent()
1737                    .unwrap()
1738                    .exists()
1739            );
1740            fs::remove_file(pid_file).unwrap();
1741        }
1742    }
1743
1744    #[cfg(unix)]
1745    #[test]
1746    fn caller_output_files_refuse_existing_entries_and_keep_partial_preparation() {
1747        use std::os::unix::fs::symlink;
1748
1749        let (owner, _, _) = StartupFiles::create(None).unwrap();
1750        let stdout = owner.directory.join("retained-stdout");
1751        let stderr = owner.directory.join("retained-stderr");
1752        let unrelated = owner.directory.join("unrelated");
1753        fs::write(&unrelated, b"original").unwrap();
1754        symlink(&unrelated, &stderr).unwrap();
1755        let error = StartupFiles::create(Some((stdout.clone(), stderr.clone())))
1756            .err()
1757            .unwrap();
1758        assert!(
1759            matches!(error, PocketIcStartupError::Io { source, .. } if source.kind() == std::io::ErrorKind::AlreadyExists)
1760        );
1761        assert!(stdout.is_file());
1762        assert_eq!(fs::read(&unrelated).unwrap(), b"original");
1763        fs::write(&stdout, b"retained attempt").unwrap();
1764        assert!(StartupFiles::create(Some((stdout.clone(), stderr.clone()))).is_err());
1765        assert_eq!(fs::read(&stdout).unwrap(), b"retained attempt");
1766        fs::remove_file(stderr.clone()).unwrap();
1767        assert!(
1768            Command::new("mkfifo")
1769                .arg(&stderr)
1770                .status()
1771                .unwrap()
1772                .success()
1773        );
1774        fs::remove_file(stdout.clone()).unwrap();
1775        assert!(StartupFiles::create(Some((stdout.clone(), stderr.clone()))).is_err());
1776        assert!(stdout.is_file());
1777        let error =
1778            PocketIcStartupConfig::connect("http://127.0.0.1:12345/", Duration::from_secs(1))
1779                .with_server_output_files(&stdout, &stderr)
1780                .run_command(&mut Command::new("/missing/command"), || false)
1781                .unwrap_err();
1782        assert!(matches!(
1783            error,
1784            PocketIcStartupError::InvalidConfiguration { .. }
1785        ));
1786    }
1787
1788    #[cfg(unix)]
1789    #[test]
1790    fn managed_startup_reports_an_exited_server_with_bounded_output() {
1791        let script = TestServerScript::new(
1792            "exit",
1793            "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nprintf 'synthetic server stdout'\nprintf 'synthetic bind failure' >&2\nexit 23\n",
1794        );
1795
1796        let result = PocketIcBuilder::new().with_application_subnet().try_build(
1797            PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2)),
1798        );
1799
1800        let Err(PocketIcStartupError::ServerExited {
1801            server_binary,
1802            status,
1803            stdout,
1804            stderr,
1805            ..
1806        }) = result
1807        else {
1808            panic!(
1809                "an exited managed server must return a structured exit error; got {:?}",
1810                result.err(),
1811            );
1812        };
1813        assert_eq!(server_binary, script.path());
1814        assert_eq!(status.code(), Some(23));
1815        assert_eq!(stdout, "synthetic server stdout");
1816        assert_eq!(stderr, "synthetic bind failure");
1817    }
1818
1819    #[cfg(unix)]
1820    #[test]
1821    fn managed_startup_rejects_oversized_port_files_and_cleans_up() {
1822        let script = TestServerScript::new(
1823            "oversized-port",
1824            "#!/bin/sh\nprintf '%s\\n%s\\n' \"$$\" \"$2\"\nprintf '34567\\n%064s' '' > \"$2.pending\"\nmv \"$2.pending\" \"$2\"\nexec sleep 30\n",
1825        );
1826        let result = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
1827            .start_managed_server();
1828        let Err(PocketIcStartupError::InvalidServerPort { value, stdout, .. }) = result else {
1829            panic!("oversized port publication must fail readiness");
1830        };
1831        assert!(value.contains("port file exceeds"));
1832        assert!(value.len() < 256);
1833        let mut lines = stdout.lines();
1834        let pid = lines.next().unwrap().parse::<u32>().unwrap();
1835        let port_path = PathBuf::from(lines.next().unwrap());
1836        assert!(!port_path.parent().unwrap().exists());
1837        assert_eq!(process_state(pid), None, "failed server must be reaped");
1838    }
1839
1840    #[cfg(unix)]
1841    #[test]
1842    fn managed_startup_terminates_a_server_that_never_becomes_ready() {
1843        let script = TestServerScript::new(
1844            "timeout",
1845            "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nexec sleep 30\n",
1846        );
1847        let timeout = Duration::from_millis(100);
1848        let started = Instant::now();
1849
1850        let result = PocketIcBuilder::new()
1851            .with_application_subnet()
1852            .try_build(PocketIcStartupConfig::spawn(script.path(), timeout));
1853
1854        assert!(
1855            started.elapsed() < Duration::from_secs(2),
1856            "bounded startup should not wait for the sleeping child"
1857        );
1858        assert!(matches!(
1859            result,
1860            Err(PocketIcStartupError::ReadinessTimeout {
1861                server_binary,
1862                timeout: actual_timeout,
1863                termination_error: None,
1864                ..
1865            }) if server_binary == script.path() && actual_timeout == timeout
1866        ));
1867    }
1868
1869    #[cfg(unix)]
1870    #[test]
1871    fn managed_server_handle_exposes_process_id_url_output_and_raii_ownership() {
1872        let script = TestServerScript::new(
1873            "handle",
1874            "#!/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",
1875        );
1876
1877        let server = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
1878            .start_managed_server()
1879            .expect("start caller-owned managed server");
1880
1881        assert_eq!(server.url(), "http://127.0.0.1:34567/");
1882        assert_eq!(
1883            server.output().stdout(),
1884            format!("managed server ready: {}", server.process_id())
1885        );
1886        assert_eq!(server.output().stderr(), "");
1887        let pid = server.process_id();
1888        assert!(process_state(pid).is_some_and(|state| state != 'Z'));
1889        drop(server);
1890        assert_eq!(process_state(pid), None, "owned server must be reaped");
1891    }
1892
1893    #[cfg(unix)]
1894    #[test]
1895    fn managed_server_cleans_descendants_on_drop_timeout_exit_and_background_reap() {
1896        for mode in ["drop", "timeout", "exit", "background"] {
1897            let publish = if matches!(mode, "drop" | "background") {
1898                "printf '34567\\n' > \"$2\"\n"
1899            } else {
1900                ""
1901            };
1902            let finish = if mode == "background" {
1903                // The caller removes the port only after handing off to the
1904                // reaper, so slow native hosts cannot miss this ready server.
1905                "while [ -e \"$2\" ]; do sleep 0.01; done\nexit 23\n"
1906            } else if mode == "exit" {
1907                "sleep 0.03\nexit 23\n"
1908            } else {
1909                "exec sleep 30\n"
1910            };
1911            let script = TestServerScript::new(
1912                mode,
1913                &format!("#!/bin/sh\nsleep 30 &\nprintf '%s' \"$!\"\n{publish}{finish}"),
1914            );
1915            let result = PocketIcStartupConfig::spawn(script.path(), Duration::from_millis(300))
1916                .start_managed_server();
1917            let output = match result {
1918                Ok(server) => {
1919                    let output = server.output().stdout().to_owned();
1920                    if mode == "background" {
1921                        let port = server.server.files.port.clone();
1922                        server.server.reap_in_background();
1923                        fs::remove_file(port).expect("release the background server after handoff");
1924                    } else {
1925                        drop(server);
1926                    }
1927                    output
1928                }
1929                Err(PocketIcStartupError::ReadinessTimeout {
1930                    stdout,
1931                    termination_error,
1932                    ..
1933                }) => {
1934                    assert_eq!(mode, "timeout");
1935                    assert_eq!(termination_error, None);
1936                    stdout
1937                }
1938                Err(PocketIcStartupError::ServerExited { stdout, status, .. }) => {
1939                    assert_eq!(mode, "exit");
1940                    assert_eq!(status.code(), Some(23));
1941                    stdout
1942                }
1943                other => panic!(
1944                    "unexpected {mode} startup result: {}",
1945                    match other {
1946                        Err(error) => error.to_string(),
1947                        Ok(_) => unreachable!(),
1948                    }
1949                ),
1950            };
1951            let pid = output
1952                .parse::<u32>()
1953                .expect("server published its descendant PID");
1954            let deadline = Instant::now() + Duration::from_secs(2);
1955            while process_state(pid).is_some_and(|state| state != 'Z') {
1956                assert!(
1957                    Instant::now() < deadline,
1958                    "{mode} left its descendant running"
1959                );
1960                std::thread::sleep(Duration::from_millis(10));
1961            }
1962        }
1963    }
1964
1965    #[cfg(unix)]
1966    #[test]
1967    fn managed_server_passes_an_explicit_hard_ttl() {
1968        let script = TestServerScript::new(
1969            "hard-ttl",
1970            "#!/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",
1971        );
1972
1973        let server = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
1974            .with_server_hard_ttl(Duration::from_secs(17))
1975            .start_managed_server()
1976            .expect("start managed server with an explicit hard TTL");
1977
1978        assert_eq!(server.url(), "http://127.0.0.1:34567/");
1979    }
1980
1981    #[test]
1982    #[ignore = "requires POCKET_IC_BIN=<caller-provided PocketIC server binary>"]
1983    fn caller_provided_server_publishes_port_constructs_instance_and_cleans_up() {
1984        let binary = std::env::var_os("POCKET_IC_BIN")
1985            .map(PathBuf::from)
1986            .expect("set POCKET_IC_BIN to the exact server binary");
1987        let one_shot_sequence = super::STARTUP_FILE_SEQUENCE.load(super::Ordering::Relaxed);
1988        let one_shot_directory = std::env::temp_dir().join(format!(
1989            "ic-testkit-pocket-ic-startup-{}-{one_shot_sequence}",
1990            std::process::id()
1991        ));
1992        let one_shot = PocketIcBuilder::new()
1993            .with_application_subnet()
1994            .try_build(
1995                PocketIcStartupConfig::spawn(&binary, Duration::from_secs(30))
1996                    .with_server_hard_ttl(Duration::from_secs(1)),
1997            )
1998            .expect("one-shot managed spawn must construct an instance");
1999        assert!(one_shot_directory.is_dir());
2000        drop(one_shot);
2001        let cleanup_deadline = Instant::now() + Duration::from_secs(3);
2002        while one_shot_directory.exists() && Instant::now() < cleanup_deadline {
2003            std::thread::sleep(Duration::from_millis(20));
2004        }
2005        assert!(!one_shot_directory.exists());
2006
2007        let server = PocketIcStartupConfig::spawn(&binary, Duration::from_secs(30))
2008            .with_server_hard_ttl(Duration::from_secs(60))
2009            .start_managed_server()
2010            .expect("caller-provided PocketIC server must publish its port");
2011        let files = &server.server.files;
2012        let startup_directory = files.directory.clone();
2013
2014        assert!(files.port.is_file());
2015        let pocket_ic = PocketIcBuilder::new()
2016            .with_application_subnet()
2017            .try_build(PocketIcStartupConfig::connect(
2018                server.url(),
2019                Duration::from_secs(30),
2020            ))
2021            .expect("construct instance through caller-provided server");
2022
2023        drop(pocket_ic);
2024        drop(server);
2025        assert!(!startup_directory.exists());
2026    }
2027
2028    #[cfg(unix)]
2029    struct TestServerScript {
2030        path: PathBuf,
2031    }
2032
2033    #[cfg(unix)]
2034    impl TestServerScript {
2035        fn new(label: &str, contents: &str) -> Self {
2036            let path = std::env::temp_dir().join(format!(
2037                "ic-testkit-pocket-ic-{label}-{}-{}",
2038                std::process::id(),
2039                super::STARTUP_FILE_SEQUENCE.fetch_add(1, super::Ordering::Relaxed),
2040            ));
2041            write_executable_script(&path, contents);
2042            Self { path }
2043        }
2044
2045        fn path(&self) -> PathBuf {
2046            self.path.clone()
2047        }
2048    }
2049
2050    #[cfg(unix)]
2051    impl Drop for TestServerScript {
2052        fn drop(&mut self) {
2053            let _ = fs::remove_file(&self.path);
2054        }
2055    }
2056}