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