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