Skip to main content

ic_testkit/pic/
startup.rs

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