Skip to main content

ic_testkit/pic/
startup.rs

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