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