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