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