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