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
15use pocket_ic::{PocketIc, PocketIcBuilder};
16
17use super::transport;
18
19const STARTUP_POLL_INTERVAL: Duration = Duration::from_millis(20);
20const SERVER_OUTPUT_LIMIT: usize = 16 * 1024;
21// PocketIC publishes a decimal u16 and a newline. Leave whitespace room
22// without allowing readiness polling to read an arbitrary-size file.
23const SERVER_PORT_FILE_LIMIT: usize = 64;
24
25static STARTUP_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
26
27/// Explicit bounded source and policy for one PocketIC startup.
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub struct PocketIcStartupConfig {
30    source: PocketIcStartupSource,
31    timeout: Duration,
32    server_hard_ttl: Option<Duration>,
33}
34
35/// Caller-owned PocketIC server process with bounded startup and output capture.
36///
37/// Dropping the handle terminates and waits for the managed child. On Unix,
38/// teardown also terminates descendants remaining in its owned process group.
39/// Callers may create several instances through [`Self::url`] and
40/// [`PocketIcStartupConfig::connect`] while retaining explicit server ownership.
41/// The handle is process-local and does not coordinate ownership across Cargo
42/// or test-runner processes; use an externally owned server with bounded
43/// connect mode for that topology.
44/// The handle owns no binary discovery, download, cache, or compatibility policy.
45pub struct PocketIcManagedServer {
46    server: ManagedServer,
47    url: String,
48}
49
50/// Bounded lossy UTF-8 output captured from a managed PocketIC server.
51///
52/// Each stream retains at most the first 16 KiB. A textual suffix reports the
53/// number of omitted bytes when truncation occurred.
54#[derive(Clone, Debug, Default, Eq, PartialEq)]
55pub struct PocketIcManagedServerOutput {
56    stdout: String,
57    stderr: String,
58}
59
60#[derive(Clone, Debug, Eq, PartialEq)]
61enum PocketIcStartupSource {
62    Spawn { server_binary: PathBuf },
63    Connect { server_url: String },
64}
65
66/// Structured failure from bounded PocketIC construction.
67#[non_exhaustive]
68#[derive(Debug)]
69pub enum PocketIcStartupError {
70    /// The caller supplied a zero timeout or unusable hard TTL.
71    InvalidConfiguration { message: String },
72    /// A caller-provided existing server URL could not be parsed.
73    InvalidServerUrl { server_url: String, message: String },
74    /// Preparing or inspecting bounded startup files failed.
75    Io {
76        operation: &'static str,
77        path: PathBuf,
78        source: io::Error,
79    },
80    /// The configured PocketIC server process could not be spawned.
81    ServerSpawn {
82        server_binary: PathBuf,
83        source: io::Error,
84    },
85    /// The managed PocketIC server exited before instance construction completed.
86    ServerExited {
87        server_binary: PathBuf,
88        status: ExitStatus,
89        elapsed: Duration,
90        stdout: String,
91        stderr: String,
92    },
93    /// The managed server did not publish a usable port before the deadline.
94    ReadinessTimeout {
95        server_binary: PathBuf,
96        timeout: Duration,
97        stdout: String,
98        stderr: String,
99        termination_error: Option<String>,
100    },
101    /// The managed server published an invalid or oversized port-file value.
102    InvalidServerPort {
103        server_binary: PathBuf,
104        value: String,
105        stdout: String,
106        stderr: String,
107    },
108    /// PocketIC instance creation did not finish before the startup deadline.
109    InstanceCreationTimeout {
110        timeout: Duration,
111        stdout: String,
112        stderr: String,
113        termination_error: Option<String>,
114    },
115    /// Spawning the bounded builder worker failed.
116    BuilderThreadSpawn { source: io::Error },
117    /// Upstream PocketIC construction panicked before returning an instance.
118    BuilderPanicked { message: String },
119    /// The bounded builder worker ended without returning a result.
120    BuilderDisconnected,
121}
122
123/// Fallible construction at PocketIC's panicking builder boundary.
124///
125/// Startup is explicit: callers either provide an existing server URL or let
126/// `ic-testkit` spawn and monitor one exact server binary. This prevents the
127/// upstream builder from hiding an unobservable child process.
128pub trait PocketIcBuilderExt {
129    /// Build one PocketIC instance within the configured deadline.
130    ///
131    /// Managed server startup detects child exit while awaiting the port file,
132    /// terminates the child on timeout, and reads bounded stdout/stderr prefixes.
133    /// Instance creation is also bounded. Upstream panics remain structured.
134    ///
135    /// This deadline covers construction only. Dropping the returned instance
136    /// uses PocketIC's synchronous HTTP deletion, which has no request deadline
137    /// in PocketIC 16. An operation's maximum request time does not bound drop.
138    fn try_build(self, config: PocketIcStartupConfig) -> Result<PocketIc, PocketIcStartupError>;
139}
140
141impl PocketIcStartupConfig {
142    /// Spawn and monitor one exact PocketIC server binary.
143    ///
144    /// Startup allocates a unique private temporary directory while leaving
145    /// the `--port-file` path absent for PocketIC to create.
146    #[must_use]
147    pub fn spawn(server_binary: impl Into<PathBuf>, timeout: Duration) -> Self {
148        Self {
149            source: PocketIcStartupSource::Spawn {
150                server_binary: server_binary.into(),
151            },
152            timeout,
153            server_hard_ttl: None,
154        }
155    }
156
157    /// Connect to a caller-owned existing PocketIC server.
158    ///
159    /// The URL is applied to the builder explicitly, so this mode never lets
160    /// the upstream builder spawn a hidden server child.
161    #[must_use]
162    pub fn connect(server_url: impl Into<String>, timeout: Duration) -> Self {
163        Self {
164            source: PocketIcStartupSource::Connect {
165                server_url: server_url.into(),
166            },
167            timeout,
168            server_hard_ttl: None,
169        }
170    }
171
172    /// Set the hard lifetime passed to an `ic-testkit`-managed server.
173    #[must_use]
174    pub const fn with_server_hard_ttl(mut self, hard_ttl: Duration) -> Self {
175        self.server_hard_ttl = Some(hard_ttl);
176        self
177    }
178
179    /// Complete startup deadline.
180    #[must_use]
181    pub const fn timeout(&self) -> Duration {
182        self.timeout
183    }
184
185    /// Explicit managed server hard lifetime, or `None` when disabled.
186    #[must_use]
187    pub const fn server_hard_ttl(&self) -> Option<Duration> {
188        self.server_hard_ttl
189    }
190
191    /// Managed server binary, when this configuration spawns one.
192    #[must_use]
193    pub fn server_binary(&self) -> Option<&Path> {
194        match &self.source {
195            PocketIcStartupSource::Spawn { server_binary } => Some(server_binary),
196            PocketIcStartupSource::Connect { .. } => None,
197        }
198    }
199
200    /// Existing caller-owned server URL, when configured.
201    #[must_use]
202    pub fn server_url(&self) -> Option<&str> {
203        match &self.source {
204            PocketIcStartupSource::Connect { server_url } => Some(server_url),
205            PocketIcStartupSource::Spawn { .. } => None,
206        }
207    }
208
209    /// Start a caller-owned managed server without constructing an instance.
210    ///
211    /// This requires a configuration created by [`Self::spawn`]. Readiness is
212    /// bounded by [`Self::timeout`]. No hard TTL is passed by default; an
213    /// explicit [`Self::with_server_hard_ttl`] value is passed to the child.
214    /// Readiness requires a nonzero decimal port followed by a newline in a
215    /// UTF-8 file of at most 64 bytes; oversized files fail with bounded diagnostics.
216    /// The returned handle terminates the child on drop; use its URL with
217    /// [`Self::connect`] to construct bounded instances.
218    pub fn start_managed_server(self) -> Result<PocketIcManagedServer, PocketIcStartupError> {
219        self.validate()?;
220        let PocketIcStartupSource::Spawn { server_binary } = self.source else {
221            return Err(PocketIcStartupError::InvalidConfiguration {
222                message: "starting a managed PocketIC server requires a spawn configuration"
223                    .to_owned(),
224            });
225        };
226        let started = Instant::now();
227        let deadline = startup_deadline(started, self.timeout)?;
228        let (server, url) = ManagedServer::start(
229            server_binary,
230            self.server_hard_ttl,
231            deadline,
232            self.timeout,
233            started,
234        )?;
235        Ok(PocketIcManagedServer { server, url })
236    }
237
238    fn validate(&self) -> Result<(), PocketIcStartupError> {
239        if self.timeout.is_zero() {
240            return Err(PocketIcStartupError::InvalidConfiguration {
241                message: "PocketIC startup timeout must be greater than zero".to_owned(),
242            });
243        }
244        if matches!(&self.source, PocketIcStartupSource::Spawn { .. })
245            && self
246                .server_hard_ttl
247                .is_some_and(|hard_ttl| hard_ttl.as_secs() == 0)
248        {
249            return Err(PocketIcStartupError::InvalidConfiguration {
250                message: "PocketIC server hard TTL must be at least one second".to_owned(),
251            });
252        }
253        Ok(())
254    }
255}
256
257impl PocketIcManagedServer {
258    /// OS process ID of the owned server child, for caller-managed monitoring.
259    ///
260    /// This identifies the server process, not its descendants, and does not
261    /// establish that it is still running. The OS may reuse the ID after the
262    /// child exits and is reaped. Dropping this handle terminates and waits for
263    /// the child; retaining the ID does not retain server ownership.
264    #[must_use]
265    pub fn process_id(&self) -> u32 {
266        self.server
267            .child
268            .as_ref()
269            .expect("managed server handle must own its child")
270            .id()
271    }
272
273    /// Loopback URL published by the managed server.
274    #[must_use]
275    pub fn url(&self) -> &str {
276        &self.url
277    }
278
279    /// Current bounded stdout and stderr captured from the managed server.
280    ///
281    /// This reads at most the first 16 KiB from each retained output file,
282    /// renders it as lossy UTF-8, and adds an omitted-byte suffix when truncated.
283    /// The diagnostic read is bounded; files can grow while the server runs.
284    #[must_use]
285    pub fn output(&self) -> PocketIcManagedServerOutput {
286        self.server.capture().into()
287    }
288}
289
290impl PocketIcManagedServerOutput {
291    /// Bounded lossy UTF-8 standard output.
292    #[must_use]
293    pub fn stdout(&self) -> &str {
294        &self.stdout
295    }
296
297    /// Bounded lossy UTF-8 standard error.
298    #[must_use]
299    pub fn stderr(&self) -> &str {
300        &self.stderr
301    }
302}
303
304impl PocketIcBuilderExt for PocketIcBuilder {
305    fn try_build(self, config: PocketIcStartupConfig) -> Result<PocketIc, PocketIcStartupError> {
306        config.validate()?;
307        let started = Instant::now();
308        let deadline = startup_deadline(started, config.timeout)?;
309        match config.source {
310            PocketIcStartupSource::Connect { server_url } => {
311                build_bounded(self, &server_url, deadline, config.timeout, None)
312            }
313            PocketIcStartupSource::Spawn { server_binary } => {
314                let (server, server_url) = ManagedServer::start(
315                    server_binary,
316                    config.server_hard_ttl,
317                    deadline,
318                    config.timeout,
319                    started,
320                )?;
321                build_bounded(self, &server_url, deadline, config.timeout, Some(server))
322            }
323        }
324    }
325}
326
327fn startup_deadline(started: Instant, timeout: Duration) -> Result<Instant, PocketIcStartupError> {
328    started
329        .checked_add(timeout)
330        .ok_or_else(|| PocketIcStartupError::InvalidConfiguration {
331            message: "PocketIC startup timeout exceeds the platform clock range".to_owned(),
332        })
333}
334
335fn build_bounded(
336    builder: PocketIcBuilder,
337    server_url: &str,
338    deadline: Instant,
339    timeout: Duration,
340    mut server: Option<ManagedServer>,
341) -> Result<PocketIc, PocketIcStartupError> {
342    let builder = match server_url.parse() {
343        Ok(server_url) => builder.with_server_url(server_url),
344        Err(error) => {
345            return Err(PocketIcStartupError::InvalidServerUrl {
346                server_url: server_url.to_owned(),
347                message: error.to_string(),
348            });
349        }
350    };
351    let (sender, receiver) = mpsc::sync_channel(1);
352    if let Err(source) = thread::Builder::new()
353        .name("ic-testkit-pocket-ic-startup".to_owned())
354        .spawn(move || {
355            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| builder.build()))
356                .map_err(|payload| transport::panic_payload_to_string(payload.as_ref()));
357            let _ = sender.send(result);
358        })
359    {
360        return Err(PocketIcStartupError::BuilderThreadSpawn { source });
361    }
362
363    loop {
364        let now = Instant::now();
365        if now >= deadline {
366            let captured = server.take().map_or_else(
367                CapturedServer::default,
368                ManagedServer::terminate_and_capture,
369            );
370            return Err(PocketIcStartupError::InstanceCreationTimeout {
371                timeout,
372                stdout: captured.stdout,
373                stderr: captured.stderr,
374                termination_error: captured.termination_error,
375            });
376        }
377        let remaining = deadline.saturating_duration_since(now);
378        let wait = if server.is_some() {
379            remaining.min(STARTUP_POLL_INTERVAL)
380        } else {
381            remaining
382        };
383        match receiver.recv_timeout(wait) {
384            Ok(Ok(pocket_ic)) => {
385                if let Some(mut managed) = server.take() {
386                    if let Some(status) = managed.try_wait()? {
387                        return Err(managed.exited_error(status));
388                    }
389                    managed.reap_in_background();
390                }
391                return Ok(pocket_ic);
392            }
393            Ok(Err(message)) => {
394                if let Some(server) = server.take() {
395                    let _ = server.terminate_and_capture();
396                }
397                return Err(PocketIcStartupError::BuilderPanicked { message });
398            }
399            Err(RecvTimeoutError::Disconnected) => {
400                if let Some(server) = server.take() {
401                    let _ = server.terminate_and_capture();
402                }
403                return Err(PocketIcStartupError::BuilderDisconnected);
404            }
405            Err(RecvTimeoutError::Timeout) => {
406                if let Some(managed) = &mut server
407                    && let Some(status) = managed.try_wait()?
408                {
409                    return Err(server
410                        .take()
411                        .expect("managed server must remain present")
412                        .exited_error(status));
413                }
414            }
415        }
416    }
417}
418
419struct ManagedServer {
420    child: Option<Child>,
421    binary: PathBuf,
422    files: StartupFiles,
423    started: Instant,
424}
425
426enum PortFileState {
427    Pending,
428    Ready(u16),
429    Invalid(String),
430}
431
432impl ManagedServer {
433    fn start(
434        binary: PathBuf,
435        hard_ttl: Option<Duration>,
436        deadline: Instant,
437        timeout: Duration,
438        started: Instant,
439    ) -> Result<(Self, String), PocketIcStartupError> {
440        let (files, stdout, stderr) = StartupFiles::create()?;
441        let mut command = Command::new(&binary);
442        if let Some(hard_ttl) = hard_ttl {
443            command
444                .arg("--hard-ttl")
445                .arg(hard_ttl.as_secs().to_string());
446        }
447        command
448            .arg("--port-file")
449            .arg(&files.port)
450            .stdout(Stdio::from(stdout))
451            .stderr(Stdio::from(stderr));
452        #[cfg(unix)]
453        {
454            use std::os::unix::process::CommandExt as _;
455            command.process_group(0);
456        }
457        let child = command
458            .spawn()
459            .map_err(|source| PocketIcStartupError::ServerSpawn {
460                server_binary: binary.clone(),
461                source,
462            })?;
463        let mut server = Self {
464            child: Some(child),
465            binary,
466            files,
467            started,
468        };
469
470        loop {
471            if let Some(status) = server.try_wait()? {
472                return Err(server.exited_error(status));
473            }
474            let now = Instant::now();
475            if now >= deadline {
476                let binary = server.binary.clone();
477                let captured = server.terminate_and_capture();
478                return Err(PocketIcStartupError::ReadinessTimeout {
479                    server_binary: binary,
480                    timeout,
481                    stdout: captured.stdout,
482                    stderr: captured.stderr,
483                    termination_error: captured.termination_error,
484                });
485            }
486            match server.read_port()? {
487                PortFileState::Pending => {}
488                PortFileState::Ready(port) => {
489                    return Ok((server, format!("http://127.0.0.1:{port}/")));
490                }
491                PortFileState::Invalid(value) => {
492                    let binary = server.binary.clone();
493                    let captured = server.terminate_and_capture();
494                    return Err(PocketIcStartupError::InvalidServerPort {
495                        server_binary: binary,
496                        value,
497                        stdout: captured.stdout,
498                        stderr: captured.stderr,
499                    });
500                }
501            }
502            thread::sleep(
503                deadline
504                    .saturating_duration_since(now)
505                    .min(STARTUP_POLL_INTERVAL),
506            );
507        }
508    }
509
510    fn try_wait(&mut self) -> Result<Option<ExitStatus>, PocketIcStartupError> {
511        let child = self
512            .child
513            .as_mut()
514            .expect("managed server child must remain present");
515        poll_child(child).map_err(|source| PocketIcStartupError::Io {
516            operation: "inspect PocketIC server child",
517            path: self.binary.clone(),
518            source,
519        })
520    }
521
522    fn read_port(&self) -> Result<PortFileState, PocketIcStartupError> {
523        let port_path = &self.files.port;
524        let mut contents = String::new();
525        match File::open(port_path).and_then(|file| {
526            file.take((SERVER_PORT_FILE_LIMIT + 1) as u64)
527                .read_to_string(&mut contents)
528        }) {
529            Ok(_) => {}
530            Err(error) if error.kind() == io::ErrorKind::NotFound => {
531                return Ok(PortFileState::Pending);
532            }
533            Err(source) => {
534                return Err(PocketIcStartupError::Io {
535                    operation: "read PocketIC server port file",
536                    path: port_path.clone(),
537                    source,
538                });
539            }
540        }
541        if contents.len() > SERVER_PORT_FILE_LIMIT {
542            return Ok(PortFileState::Invalid(format!(
543                "{} (port file exceeds {SERVER_PORT_FILE_LIMIT} bytes)",
544                contents.trim()
545            )));
546        }
547        if !contents.contains('\n') {
548            return Ok(PortFileState::Pending);
549        }
550        let value = contents.trim().to_owned();
551        match value.parse::<u16>() {
552            Ok(port) if port != 0 => Ok(PortFileState::Ready(port)),
553            _ => Ok(PortFileState::Invalid(value)),
554        }
555    }
556
557    fn exited_error(mut self, status: ExitStatus) -> PocketIcStartupError {
558        let elapsed = self.started.elapsed();
559        let binary = self.binary.clone();
560        self.child.take();
561        let captured = self.capture();
562        PocketIcStartupError::ServerExited {
563            server_binary: binary,
564            status,
565            elapsed,
566            stdout: captured.stdout,
567            stderr: captured.stderr,
568        }
569    }
570
571    fn terminate_and_capture(mut self) -> CapturedServer {
572        let termination_error = match self.child.take() {
573            Some(mut child) => terminate_child(&mut child),
574            None => None,
575        };
576        let mut captured = self.capture();
577        captured.termination_error = termination_error;
578        captured
579    }
580
581    fn capture(&self) -> CapturedServer {
582        let files = &self.files;
583        CapturedServer {
584            stdout: read_bounded_lossy(&files.stdout),
585            stderr: read_bounded_lossy(&files.stderr),
586            termination_error: None,
587        }
588    }
589
590    fn reap_in_background(self) {
591        let _ = thread::Builder::new()
592            .name("ic-testkit-pocket-ic-server-reaper".to_owned())
593            .spawn(move || {
594                // Keep child and files under one owner, including if spawning
595                // this thread fails. On Unix, Drop terminates the group before reaping.
596                let mut server = self;
597                if let Some(child) = server.child.as_mut() {
598                    #[cfg(unix)]
599                    let _ = wait_for_child_exit(child, false);
600                    #[cfg(not(unix))]
601                    let _ = child.wait();
602                }
603            });
604    }
605}
606
607impl Drop for ManagedServer {
608    fn drop(&mut self) {
609        if let Some(mut child) = self.child.take() {
610            let _ = terminate_child(&mut child);
611        }
612    }
613}
614
615#[cfg(unix)]
616fn wait_for_child_exit(child: &Child, nonblocking: bool) -> io::Result<bool> {
617    // WNOWAIT keeps the leader's PID reserved until group cleanup completes.
618    let flags = libc::WEXITED | libc::WNOWAIT | if nonblocking { libc::WNOHANG } else { 0 };
619    loop {
620        // SAFETY: siginfo_t is a C value for which zero initialization is valid.
621        let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
622        // SAFETY: the PID is an owned, unreaped child and info is writable.
623        let result = unsafe { libc::waitid(libc::P_PID, child.id(), &raw mut info, flags) };
624        if result == 0 {
625            return Ok(info.si_signo != 0);
626        }
627        let error = io::Error::last_os_error();
628        if error.kind() != io::ErrorKind::Interrupted {
629            return Err(error);
630        }
631    }
632}
633
634fn poll_child(child: &mut Child) -> io::Result<Option<ExitStatus>> {
635    #[cfg(unix)]
636    {
637        if !wait_for_child_exit(child, true)? {
638            return Ok(None);
639        }
640        // The leader is still unreaped, so its process group cannot be reused.
641        terminate_process_group(child)?;
642        child.wait().map(Some)
643    }
644    #[cfg(not(unix))]
645    child.try_wait()
646}
647
648#[cfg(unix)]
649fn terminate_process_group(child: &Child) -> io::Result<()> {
650    let group = libc::pid_t::try_from(child.id())
651        .map_err(|_| io::Error::other("managed child PID exceeds the OS process ID range"))?;
652    loop {
653        // SAFETY: spawn sets this child's PGID to its PID, and it remains
654        // unreaped until after this call. No caller/test-runner group is used.
655        let result = unsafe { libc::killpg(group, libc::SIGKILL) };
656        if result == 0 {
657            return Ok(());
658        }
659        let error = io::Error::last_os_error();
660        if error.raw_os_error() == Some(libc::ESRCH) {
661            return Ok(());
662        }
663        if error.kind() != io::ErrorKind::Interrupted {
664            return Err(error);
665        }
666    }
667}
668
669#[cfg(unix)]
670fn terminate_child(child: &mut Child) -> Option<String> {
671    let termination = terminate_process_group(child);
672    if termination.is_err() {
673        let _ = child.kill();
674    }
675    let wait = child.wait();
676    termination
677        .and_then(|()| wait.map(|_| ()))
678        .err()
679        .map(|error| error.to_string())
680}
681
682#[cfg(not(unix))]
683fn terminate_child(child: &mut Child) -> Option<String> {
684    match child.try_wait() {
685        Ok(Some(_)) => None,
686        Ok(None) => child
687            .kill()
688            .and_then(|()| child.wait().map(|_| ()))
689            .err()
690            .map(|error| error.to_string()),
691        Err(inspect_error) => {
692            let termination_error = child.kill().and_then(|()| child.wait().map(|_| ())).err();
693            termination_error.map(|termination_error| {
694                format!(
695                    "failed to inspect child before termination: {inspect_error}; termination also failed: {termination_error}"
696                )
697            })
698        }
699    }
700}
701
702#[derive(Default)]
703struct CapturedServer {
704    stdout: String,
705    stderr: String,
706    termination_error: Option<String>,
707}
708
709impl From<CapturedServer> for PocketIcManagedServerOutput {
710    fn from(captured: CapturedServer) -> Self {
711        Self {
712            stdout: captured.stdout,
713            stderr: captured.stderr,
714        }
715    }
716}
717
718struct StartupFiles {
719    directory: PathBuf,
720    port: PathBuf,
721    stdout: PathBuf,
722    stderr: PathBuf,
723}
724
725impl StartupFiles {
726    fn create() -> Result<(Self, File, File), PocketIcStartupError> {
727        loop {
728            let sequence = STARTUP_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
729            let base = std::env::temp_dir().join(format!(
730                "ic-testkit-pocket-ic-startup-{}-{sequence}",
731                std::process::id()
732            ));
733            let mut directory = fs::DirBuilder::new();
734            #[cfg(unix)]
735            {
736                use std::os::unix::fs::DirBuilderExt as _;
737                directory.mode(0o700);
738            }
739            match directory.create(&base) {
740                Ok(()) => {}
741                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
742                Err(source) => return Err(startup_file_error("create", &base, source)),
743            }
744            let files = Self {
745                port: base.join("port"),
746                stdout: base.join("stdout"),
747                stderr: base.join("stderr"),
748                directory: base,
749            };
750            let stdout = create_new_file(&files.stdout)
751                .map_err(|source| startup_file_error("create", &files.stdout, source))?;
752            let stderr = create_new_file(&files.stderr)
753                .map_err(|source| startup_file_error("create", &files.stderr, source))?;
754            return Ok((files, stdout, stderr));
755        }
756    }
757}
758
759impl Drop for StartupFiles {
760    fn drop(&mut self) {
761        let _ = fs::remove_dir_all(&self.directory);
762    }
763}
764
765fn create_new_file(path: &Path) -> io::Result<File> {
766    OpenOptions::new().write(true).create_new(true).open(path)
767}
768
769fn startup_file_error(
770    operation: &'static str,
771    path: &Path,
772    source: io::Error,
773) -> PocketIcStartupError {
774    PocketIcStartupError::Io {
775        operation,
776        path: path.to_owned(),
777        source,
778    }
779}
780
781fn read_bounded_lossy(path: &Path) -> String {
782    let Ok(file) = File::open(path) else {
783        return String::new();
784    };
785    let length = file.metadata().map_or(0, |metadata| metadata.len());
786    let mut bytes = Vec::with_capacity(SERVER_OUTPUT_LIMIT);
787    if file
788        .take(SERVER_OUTPUT_LIMIT as u64)
789        .read_to_end(&mut bytes)
790        .is_err()
791    {
792        return String::new();
793    }
794    let mut output = String::from_utf8_lossy(&bytes).into_owned();
795    let omitted = length.saturating_sub(bytes.len() as u64);
796    if omitted > 0 {
797        let _ = write!(output, "\n<truncated {omitted} bytes>");
798    }
799    output
800}
801
802impl std::fmt::Display for PocketIcStartupError {
803    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
804        match self {
805            Self::InvalidConfiguration { message } => formatter.write_str(message),
806            Self::InvalidServerUrl {
807                server_url,
808                message,
809            } => write!(
810                formatter,
811                "invalid PocketIC server URL {server_url:?}: {message}"
812            ),
813            Self::Io {
814                operation,
815                path,
816                source,
817            } => write!(
818                formatter,
819                "failed to {operation} at {}: {source}",
820                path.display()
821            ),
822            Self::ServerSpawn {
823                server_binary,
824                source,
825            } => write!(
826                formatter,
827                "failed to spawn PocketIC server {}: {source}",
828                server_binary.display()
829            ),
830            Self::ServerExited {
831                server_binary,
832                status,
833                elapsed,
834                stderr,
835                ..
836            } => write!(
837                formatter,
838                "PocketIC server {} exited with {status} after {elapsed:?}: {stderr}",
839                server_binary.display()
840            ),
841            Self::ReadinessTimeout {
842                server_binary,
843                timeout,
844                ..
845            } => write!(
846                formatter,
847                "PocketIC server {} was not ready within {timeout:?}",
848                server_binary.display()
849            ),
850            Self::InvalidServerPort {
851                server_binary,
852                value,
853                ..
854            } => write!(
855                formatter,
856                "PocketIC server {} published invalid port {value:?}",
857                server_binary.display()
858            ),
859            Self::InstanceCreationTimeout { timeout, .. } => {
860                write!(formatter, "PocketIC instance creation exceeded {timeout:?}")
861            }
862            Self::BuilderThreadSpawn { source } => {
863                write!(
864                    formatter,
865                    "failed to spawn PocketIC builder worker: {source}"
866                )
867            }
868            Self::BuilderPanicked { message } => {
869                write!(formatter, "PocketIC startup panicked: {message}")
870            }
871            Self::BuilderDisconnected => {
872                formatter.write_str("PocketIC builder worker disconnected without a result")
873            }
874        }
875    }
876}
877
878impl std::error::Error for PocketIcStartupError {
879    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
880        match self {
881            Self::Io { source, .. }
882            | Self::ServerSpawn { source, .. }
883            | Self::BuilderThreadSpawn { source } => Some(source),
884            _ => None,
885        }
886    }
887}
888
889#[cfg(test)]
890mod tests {
891    use std::{
892        fs,
893        path::PathBuf,
894        time::{Duration, Instant},
895    };
896
897    use super::{
898        PocketIcBuilderExt as _, PocketIcStartupConfig, PocketIcStartupError, StartupFiles,
899    };
900    use pocket_ic::PocketIcBuilder;
901
902    #[cfg(unix)]
903    use crate::test_executable::write_executable_script;
904
905    #[cfg(unix)]
906    #[test]
907    fn reading_large_sparse_server_output_is_bounded() {
908        use std::io::Write as _;
909        let (files, _, _) = StartupFiles::create().expect("allocate startup files");
910        let mut file = fs::File::create(&files.stdout).expect("create sparse log");
911        file.write_all(b"server started\n").expect("write prefix");
912        let size = 8_u64 * 1024 * 1024 * 1024;
913        file.set_len(size).expect("extend sparse log");
914        let output = super::read_bounded_lossy(&files.stdout);
915        assert!(output.starts_with("server started\n"));
916        assert!(output.ends_with(&format!(
917            "<truncated {} bytes>",
918            size - super::SERVER_OUTPUT_LIMIT as u64
919        )));
920        assert!(output.len() < super::SERVER_OUTPUT_LIMIT + 100);
921    }
922
923    #[test]
924    fn port_file_readiness_preserves_partial_writes_and_rejects_oversized_contents() {
925        let (files, _, _) = StartupFiles::create().expect("allocate startup files");
926        let server = super::ManagedServer {
927            child: None,
928            binary: PathBuf::from("unused-server"),
929            files,
930            started: Instant::now(),
931        };
932        assert!(matches!(
933            server.read_port().unwrap(),
934            super::PortFileState::Pending
935        ));
936        for contents in ["", "34567"] {
937            fs::write(&server.files.port, contents).unwrap();
938            assert!(matches!(
939                server.read_port().unwrap(),
940                super::PortFileState::Pending
941            ));
942        }
943        for (contents, expected) in [("1\n", 1), ("65535\n", 65535), (" 34567\r\n", 34567)] {
944            fs::write(&server.files.port, contents).unwrap();
945            assert!(matches!(
946                server.read_port().unwrap(),
947                super::PortFileState::Ready(port) if port == expected
948            ));
949        }
950        for contents in ["0\n", "65536\n", "invalid\n", "1\n2\n"] {
951            fs::write(&server.files.port, contents).unwrap();
952            assert!(matches!(
953                server.read_port().unwrap(),
954                super::PortFileState::Invalid(_)
955            ));
956        }
957        fs::write(&server.files.port, [0xff, b'\n']).unwrap();
958        assert!(matches!(
959            server.read_port(),
960            Err(PocketIcStartupError::Io { source, .. })
961                if source.kind() == std::io::ErrorKind::InvalidData
962        ));
963        for contents in ["1\n".to_owned() + &" ".repeat(128), "0".repeat(128)] {
964            fs::write(&server.files.port, contents).unwrap();
965            assert!(
966                matches!(
967                    server.read_port().unwrap(),
968                    super::PortFileState::Invalid(_)
969                ),
970                "oversized port contents must fail even without a newline",
971            );
972        }
973        // A large backing file must not enlarge the returned diagnostic.
974        fs::File::options()
975            .write(true)
976            .open(&server.files.port)
977            .unwrap()
978            .set_len(1024 * 1024)
979            .unwrap();
980        assert!(matches!(
981            server.read_port().unwrap(),
982            super::PortFileState::Invalid(value) if value.len() < 256
983        ));
984    }
985
986    #[test]
987    fn startup_config_requires_positive_bounds() {
988        let error = PocketIcStartupConfig::connect("http://127.0.0.1:1/", Duration::ZERO)
989            .validate()
990            .expect_err("zero startup timeout must fail");
991        assert!(matches!(
992            error,
993            PocketIcStartupError::InvalidConfiguration { .. }
994        ));
995
996        let error = PocketIcStartupConfig::spawn("pocket-ic", Duration::from_secs(1))
997            .with_server_hard_ttl(Duration::from_millis(1))
998            .validate()
999            .expect_err("subsecond server hard TTL must fail");
1000        assert!(matches!(
1001            error,
1002            PocketIcStartupError::InvalidConfiguration { .. }
1003        ));
1004    }
1005
1006    #[test]
1007    fn managed_server_hard_ttl_is_opt_in() {
1008        let default = PocketIcStartupConfig::spawn("pocket-ic", Duration::from_secs(1));
1009        assert_eq!(default.server_hard_ttl(), None);
1010
1011        let explicit = default.with_server_hard_ttl(Duration::from_secs(17));
1012        assert_eq!(explicit.server_hard_ttl(), Some(Duration::from_secs(17)));
1013    }
1014
1015    #[test]
1016    fn startup_files_leave_the_server_owned_port_path_absent() {
1017        let (files, stdout, stderr) = StartupFiles::create().expect("allocate startup files");
1018        let directory = files.directory.clone();
1019
1020        assert!(directory.is_dir());
1021        assert!(!files.port.exists());
1022        assert!(files.stdout.is_file());
1023        assert!(files.stderr.is_file());
1024        #[cfg(unix)]
1025        {
1026            use std::os::unix::fs::PermissionsExt as _;
1027
1028            let mode = fs::metadata(&directory)
1029                .expect("inspect private startup directory")
1030                .permissions()
1031                .mode();
1032            assert_eq!(mode & 0o077, 0);
1033        }
1034
1035        drop(stdout);
1036        drop(stderr);
1037        drop(files);
1038        assert!(!directory.exists());
1039    }
1040
1041    #[cfg(unix)]
1042    #[test]
1043    fn managed_startup_reports_an_exited_server_with_bounded_output() {
1044        let script = TestServerScript::new(
1045            "exit",
1046            "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nprintf 'synthetic server stdout'\nprintf 'synthetic bind failure' >&2\nexit 23\n",
1047        );
1048
1049        let result = PocketIcBuilder::new().with_application_subnet().try_build(
1050            PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2)),
1051        );
1052
1053        let Err(PocketIcStartupError::ServerExited {
1054            server_binary,
1055            status,
1056            stdout,
1057            stderr,
1058            ..
1059        }) = result
1060        else {
1061            panic!("an exited managed server must return a structured exit error");
1062        };
1063        assert_eq!(server_binary, script.path());
1064        assert_eq!(status.code(), Some(23));
1065        assert_eq!(stdout, "synthetic server stdout");
1066        assert_eq!(stderr, "synthetic bind failure");
1067    }
1068
1069    #[cfg(unix)]
1070    #[test]
1071    fn managed_startup_rejects_oversized_port_files_and_cleans_up() {
1072        let script = TestServerScript::new(
1073            "oversized-port",
1074            "#!/bin/sh\nprintf '%s\\n%s\\n' \"$$\" \"$2\"\nprintf '34567\\n%064s' '' > \"$2.pending\"\nmv \"$2.pending\" \"$2\"\nexec sleep 30\n",
1075        );
1076        let result = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
1077            .start_managed_server();
1078        let Err(PocketIcStartupError::InvalidServerPort { value, stdout, .. }) = result else {
1079            panic!("oversized port publication must fail readiness");
1080        };
1081        assert!(value.contains("port file exceeds"));
1082        assert!(value.len() < 256);
1083        let mut lines = stdout.lines();
1084        let pid = lines.next().unwrap().parse::<u32>().unwrap();
1085        let port_path = PathBuf::from(lines.next().unwrap());
1086        assert!(!port_path.parent().unwrap().exists());
1087        #[cfg(target_os = "linux")]
1088        assert!(!PathBuf::from(format!("/proc/{pid}")).exists());
1089        #[cfg(not(target_os = "linux"))]
1090        let _ = pid;
1091    }
1092
1093    #[cfg(unix)]
1094    #[test]
1095    fn managed_startup_terminates_a_server_that_never_becomes_ready() {
1096        let script = TestServerScript::new(
1097            "timeout",
1098            "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nexec sleep 30\n",
1099        );
1100        let timeout = Duration::from_millis(100);
1101        let started = Instant::now();
1102
1103        let result = PocketIcBuilder::new()
1104            .with_application_subnet()
1105            .try_build(PocketIcStartupConfig::spawn(script.path(), timeout));
1106
1107        assert!(
1108            started.elapsed() < Duration::from_secs(2),
1109            "bounded startup should not wait for the sleeping child"
1110        );
1111        assert!(matches!(
1112            result,
1113            Err(PocketIcStartupError::ReadinessTimeout {
1114                server_binary,
1115                timeout: actual_timeout,
1116                termination_error: None,
1117                ..
1118            }) if server_binary == script.path() && actual_timeout == timeout
1119        ));
1120    }
1121
1122    #[cfg(unix)]
1123    #[test]
1124    fn managed_server_handle_exposes_process_id_url_output_and_raii_ownership() {
1125        let script = TestServerScript::new(
1126            "handle",
1127            "#!/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",
1128        );
1129
1130        let server = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
1131            .start_managed_server()
1132            .expect("start caller-owned managed server");
1133
1134        assert_eq!(server.url(), "http://127.0.0.1:34567/");
1135        assert_eq!(
1136            server.output().stdout(),
1137            format!("managed server ready: {}", server.process_id())
1138        );
1139        assert_eq!(server.output().stderr(), "");
1140        #[cfg(target_os = "linux")]
1141        let child_process = PathBuf::from(format!("/proc/{}", server.process_id()));
1142        #[cfg(target_os = "linux")]
1143        assert!(child_process.exists());
1144        drop(server);
1145        #[cfg(target_os = "linux")]
1146        assert!(!child_process.exists());
1147    }
1148
1149    #[cfg(target_os = "linux")]
1150    #[test]
1151    fn managed_server_cleans_descendants_on_drop_timeout_exit_and_background_reap() {
1152        for mode in ["drop", "timeout", "exit", "background"] {
1153            let publish = if matches!(mode, "drop" | "background") {
1154                "printf '34567\\n' > \"$2\"\n"
1155            } else {
1156                ""
1157            };
1158            let finish = if matches!(mode, "exit" | "background") {
1159                "sleep 0.03\nexit 23\n"
1160            } else {
1161                "exec sleep 30\n"
1162            };
1163            let script = TestServerScript::new(
1164                mode,
1165                &format!("#!/bin/sh\nsleep 3 &\nprintf '%s' \"$!\"\n{publish}{finish}"),
1166            );
1167            let result = PocketIcStartupConfig::spawn(script.path(), Duration::from_millis(300))
1168                .start_managed_server();
1169            let output = match result {
1170                Ok(server) => {
1171                    let output = server.output().stdout().to_owned();
1172                    if mode == "background" {
1173                        server.server.reap_in_background();
1174                    } else {
1175                        drop(server);
1176                    }
1177                    output
1178                }
1179                Err(PocketIcStartupError::ReadinessTimeout {
1180                    stdout,
1181                    termination_error,
1182                    ..
1183                }) => {
1184                    assert_eq!(mode, "timeout");
1185                    assert_eq!(termination_error, None);
1186                    stdout
1187                }
1188                Err(PocketIcStartupError::ServerExited { stdout, status, .. }) => {
1189                    assert_eq!(mode, "exit");
1190                    assert_eq!(status.code(), Some(23));
1191                    stdout
1192                }
1193                other => panic!(
1194                    "unexpected {mode} startup result: {}",
1195                    match other {
1196                        Err(error) => error.to_string(),
1197                        Ok(_) => unreachable!(),
1198                    }
1199                ),
1200            };
1201            let pid = output
1202                .parse::<u32>()
1203                .expect("server published its descendant PID");
1204            let state_file = PathBuf::from(format!("/proc/{pid}/stat"));
1205            let deadline = Instant::now() + Duration::from_secs(1);
1206            loop {
1207                match fs::read_to_string(&state_file) {
1208                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => break,
1209                    Ok(state) if state.rsplit_once(") ").unwrap().1.starts_with('Z') => break,
1210                    _ => {}
1211                }
1212                assert!(
1213                    Instant::now() < deadline,
1214                    "{mode} left its descendant running"
1215                );
1216                std::thread::sleep(Duration::from_millis(10));
1217            }
1218        }
1219    }
1220
1221    #[cfg(unix)]
1222    #[test]
1223    fn managed_server_passes_an_explicit_hard_ttl() {
1224        let script = TestServerScript::new(
1225            "hard-ttl",
1226            "#!/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",
1227        );
1228
1229        let server = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
1230            .with_server_hard_ttl(Duration::from_secs(17))
1231            .start_managed_server()
1232            .expect("start managed server with an explicit hard TTL");
1233
1234        assert_eq!(server.url(), "http://127.0.0.1:34567/");
1235    }
1236
1237    #[test]
1238    #[ignore = "requires IC_TESTKIT_POCKET_IC_SERVER=<caller-provided PocketIC server binary>"]
1239    fn caller_provided_server_publishes_port_constructs_instance_and_cleans_up() {
1240        let binary = std::env::var_os("IC_TESTKIT_POCKET_IC_SERVER")
1241            .map(PathBuf::from)
1242            .expect("set IC_TESTKIT_POCKET_IC_SERVER to the exact server binary");
1243        let one_shot_sequence = super::STARTUP_FILE_SEQUENCE.load(super::Ordering::Relaxed);
1244        let one_shot_directory = std::env::temp_dir().join(format!(
1245            "ic-testkit-pocket-ic-startup-{}-{one_shot_sequence}",
1246            std::process::id()
1247        ));
1248        let one_shot = PocketIcBuilder::new()
1249            .with_application_subnet()
1250            .try_build(
1251                PocketIcStartupConfig::spawn(&binary, Duration::from_secs(30))
1252                    .with_server_hard_ttl(Duration::from_secs(1)),
1253            )
1254            .expect("one-shot managed spawn must construct an instance");
1255        assert!(one_shot_directory.is_dir());
1256        drop(one_shot);
1257        let cleanup_deadline = Instant::now() + Duration::from_secs(3);
1258        while one_shot_directory.exists() && Instant::now() < cleanup_deadline {
1259            std::thread::sleep(Duration::from_millis(20));
1260        }
1261        assert!(!one_shot_directory.exists());
1262
1263        let server = PocketIcStartupConfig::spawn(&binary, Duration::from_secs(30))
1264            .with_server_hard_ttl(Duration::from_secs(60))
1265            .start_managed_server()
1266            .expect("caller-provided PocketIC server must publish its port");
1267        let files = &server.server.files;
1268        let startup_directory = files.directory.clone();
1269
1270        assert!(files.port.is_file());
1271        let pocket_ic = PocketIcBuilder::new()
1272            .with_application_subnet()
1273            .try_build(PocketIcStartupConfig::connect(
1274                server.url(),
1275                Duration::from_secs(30),
1276            ))
1277            .expect("construct instance through caller-provided server");
1278
1279        drop(pocket_ic);
1280        drop(server);
1281        assert!(!startup_directory.exists());
1282    }
1283
1284    #[cfg(unix)]
1285    struct TestServerScript {
1286        path: PathBuf,
1287    }
1288
1289    #[cfg(unix)]
1290    impl TestServerScript {
1291        fn new(label: &str, contents: &str) -> Self {
1292            let path = std::env::temp_dir().join(format!(
1293                "ic-testkit-pocket-ic-{label}-{}-{}",
1294                std::process::id(),
1295                super::STARTUP_FILE_SEQUENCE.fetch_add(1, super::Ordering::Relaxed),
1296            ));
1297            write_executable_script(&path, contents);
1298            Self { path }
1299        }
1300
1301        fn path(&self) -> PathBuf {
1302            self.path.clone()
1303        }
1304    }
1305
1306    #[cfg(unix)]
1307    impl Drop for TestServerScript {
1308        fn drop(&mut self) {
1309            let _ = fs::remove_file(&self.path);
1310        }
1311    }
1312}