Skip to main content

ic_testkit/pic/
startup.rs

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