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