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::{Command, ExitStatus, Stdio},
7    thread,
8    time::{Duration, Instant},
9};
10
11#[cfg(unix)]
12use std::os::unix::fs::OpenOptionsExt as _;
13
14use crate::pocket_ic::{ClientError, PocketIc, PocketIcBuilder};
15use ic_host_fs::{directory::create_unique_directory, owned_directory::OwnedDirectory};
16use ic_host_process::child::{CleanupError, CleanupPolicy, OwnedChild};
17
18const STARTUP_POLL_INTERVAL: Duration = Duration::from_millis(20);
19const SERVER_OUTPUT_LIMIT: usize = 16 * 1024;
20// PocketIC publishes a decimal u16 and a newline. Leave whitespace room
21// without allowing readiness polling to read an arbitrary-size file.
22const SERVER_PORT_FILE_LIMIT: usize = 64;
23
24/// Explicit bounded source and policy for one `PocketIC` startup.
25///
26/// Spawn-only settings cannot be attached to a borrowed connection. Callers own
27/// binary admission, output retention and namespace custody.
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub enum PocketIcStartupConfig {
30    /// Spawn and own one exact server executable.
31    Spawn {
32        /// Caller-selected, already prepared server binary.
33        server_binary: PathBuf,
34        /// Complete startup deadline; must be positive.
35        timeout: Duration,
36        /// Optional hard lifetime, in whole seconds; `None` disables it.
37        server_hard_ttl: Option<Duration>,
38        /// Optional operation-idle lifetime; `None` uses `PocketIC`'s default.
39        server_idle_ttl: Option<Duration>,
40        /// New caller-owned stdout/stderr files, or temporary output when absent.
41        /// Existing entries are refused. Created files survive failure and teardown.
42        server_output_files: Option<(PathBuf, PathBuf)>,
43    },
44    /// Borrow an existing server without claiming its process lifetime.
45    Connect {
46        /// Existing server URL, admitted by the upstream builder.
47        server_url: String,
48        /// Complete startup deadline; must be positive.
49        timeout: Duration,
50    },
51}
52
53/// Caller-owned `PocketIC` server process with bounded startup and output capture.
54///
55/// Dropping the handle terminates and waits for the managed child. On Unix,
56/// teardown also terminates descendants remaining in its owned process group.
57/// Callers may create several instances through [`Self::url`] and
58/// [`PocketIcStartupConfig::Connect`] while retaining explicit server ownership.
59/// The handle is process-local and does not coordinate ownership across Cargo
60/// or test-runner processes; use an externally owned server with bounded
61/// connect mode for that topology.
62/// The handle owns no binary discovery, download, cache, or compatibility policy.
63pub struct PocketIcManagedServer {
64    server: ManagedServer,
65    url: String,
66}
67
68/// An upstream instance together with the server owned by its construction.
69///
70/// Call [`Self::close`] to await instance deletion while retaining server custody.
71/// Rust Drop performs no client network I/O; it stops an owned server and all its
72/// instances. Connect mode borrows the caller's server, so explicit close matters.
73/// [`Self::into_parts`] transfers teardown ordering and reconciliation to the caller.
74///
75/// Field projection cannot silently detach the client from its server:
76/// ```compile_fail
77/// use ic_testkit::pic::PocketIcInstance;
78/// fn detach<T>(owned: PocketIcInstance<T>) -> T {
79///     owned.instance
80/// }
81/// ```
82/// Nor can a client borrow outlive a temporary owner:
83/// ```compile_fail
84/// use ic_testkit::pic::PocketIcInstance;
85/// let client = PocketIcInstance::from_borrowed_server(String::new()).instance();
86/// assert!(client.is_empty());
87/// ```
88#[must_use]
89pub struct PocketIcInstance<T> {
90    instance: T,
91    server: Option<PocketIcManagedServer>,
92}
93
94impl<T> PocketIcInstance<T> {
95    /// Retain an existing client whose server lifetime is owned elsewhere.
96    ///
97    /// This creates no server or instance and claims no server process authority.
98    /// The client retains its upstream deletion behavior; its external server
99    /// must outlive it. Use a startup configuration for a managed server owner.
100    pub const fn from_borrowed_server(instance: T) -> Self {
101        Self {
102            instance,
103            server: None,
104        }
105    }
106
107    /// Borrow the client while retaining this record's managed-server ownership.
108    #[must_use]
109    pub const fn instance(&self) -> &T {
110        &self.instance
111    }
112
113    /// Mutably borrow the client while retaining managed-server ownership.
114    /// Supports client setup such as `PocketIc::make_live` without splitting the
115    /// owner. Replacing the client transfers responsibility for the old client's
116    /// server lifetime to the caller, just as with [`Self::into_parts`].
117    ///
118    /// The borrow cannot outlive the owner:
119    /// ```compile_fail
120    /// use ic_testkit::pic::PocketIcInstance;
121    /// let client = PocketIcInstance::from_borrowed_server(String::new()).instance_mut();
122    /// client.push_str("gateway");
123    /// ```
124    pub const fn instance_mut(&mut self) -> &mut T {
125        &mut self.instance
126    }
127
128    /// Borrow the exact managed server, or `None` for an externally owned server.
129    #[must_use]
130    pub const fn server(&self) -> Option<&PocketIcManagedServer> {
131        self.server.as_ref()
132    }
133
134    /// Transfer the client and optional server, without deleting or stopping either.
135    ///
136    /// The caller must retain the server until client deletion is complete. For
137    /// a nonblocking client, explicitly await `instance.drop()` before dropping
138    /// the returned server. No implicit asynchronous or blocking cleanup is added.
139    #[must_use]
140    pub fn into_parts(self) -> (T, Option<PocketIcManagedServer>) {
141        (self.instance, self.server)
142    }
143}
144
145impl PocketIcInstance<PocketIc> {
146    /// Delete this instance while retaining its managed server until the request finishes.
147    /// Dropping the future stops an owned server; borrowed servers require caller reconciliation.
148    /// # Errors
149    /// Returns the client deletion failure, without retrying it.
150    pub async fn close(self) -> Result<(), ClientError> {
151        let (instance, server) = self.into_parts();
152        let result = instance.drop().await;
153        drop(server);
154        result
155    }
156}
157
158/// Bounded lossy UTF-8 output captured from a managed `PocketIC` server.
159///
160/// Each stream retains at most the first 16 KiB. A textual suffix reports the
161/// number of omitted bytes when truncation occurred. Unreadable streams and
162/// paths replaced with non-regular files are omitted.
163#[derive(Clone, Debug, Default, Eq, PartialEq)]
164pub struct PocketIcManagedServerOutput {
165    stdout: String,
166    stderr: String,
167}
168
169/// Original startup failure, bounded server output and typed cleanup failures.
170///
171/// Match [`Self::failure`] to classify the original cause. Cleanup diagnostics
172/// never replace it, and command/server failures retain separate ownership.
173/// Output is an inert bounded snapshot; it grants no process authority.
174#[derive(Debug)]
175pub struct PocketIcStartupError {
176    failure: Box<PocketIcStartupFailure>,
177    output: PocketIcManagedServerOutput,
178    command_cleanup: Option<Box<CleanupError>>,
179    server_cleanup: Option<Box<CleanupError>>,
180}
181
182impl PocketIcStartupError {
183    fn new(failure: PocketIcStartupFailure) -> Self {
184        Self {
185            failure: Box::new(failure),
186            output: PocketIcManagedServerOutput::default(),
187            command_cleanup: None,
188            server_cleanup: None,
189        }
190    }
191
192    /// Original cause, unaffected by subsequent cleanup failure.
193    #[must_use]
194    pub fn failure(&self) -> &PocketIcStartupFailure {
195        &self.failure
196    }
197
198    /// Bounded output from the server owned by this operation, if any.
199    #[must_use]
200    pub const fn output(&self) -> &PocketIcManagedServerOutput {
201        &self.output
202    }
203
204    /// Failure cleaning the command's owned process group/direct child.
205    #[must_use]
206    pub fn command_cleanup(&self) -> Option<&CleanupError> {
207        self.command_cleanup.as_deref()
208    }
209
210    /// Failure cleaning the managed server's owned process group/direct child.
211    #[must_use]
212    pub fn server_cleanup(&self) -> Option<&CleanupError> {
213        self.server_cleanup.as_deref()
214    }
215
216    fn with_cleanup(
217        mut self,
218        command: Option<CleanupError>,
219        server: Option<(PocketIcManagedServerOutput, Option<CleanupError>)>,
220    ) -> Self {
221        self.command_cleanup = command.map(Box::new);
222        if let Some((output, cleanup)) = server {
223            self.output = output;
224            self.server_cleanup = cleanup.map(Box::new);
225        }
226        self
227    }
228}
229
230/// Original failure, independent of output capture and cleanup outcomes.
231#[non_exhaustive]
232#[derive(Debug, thiserror::Error)]
233pub enum PocketIcStartupFailure {
234    /// Neither the shared server URL nor an explicit executable was configured.
235    #[error(
236        "configure IC_TESTKIT_POCKET_IC_URL or POCKET_IC_BIN; prepare a verified binary with make install-server"
237    )]
238    NotConfigured,
239    /// A selected environment value was empty or was not valid Unicode.
240    #[error("invalid selected environment value: {variable}")]
241    InvalidEnvironment {
242        /// Selected environment variable name.
243        variable: &'static str,
244    },
245    /// The bounded version probe failed, including nonzero exit or timeout.
246    #[error("PocketIC version probe failed: {source}")]
247    ServerVersionProbe {
248        /// Underlying operation failure.
249        source: ic_host_process::tool::ToolError,
250    },
251    /// The selected executable does not report the qualified server identity.
252    #[error("PocketIC version mismatch: expected {expected:?}, observed {:?}", String::from_utf8_lossy(.observed))]
253    ServerVersionMismatch {
254        /// Required server identity.
255        expected: String,
256        /// Captured version-probe output.
257        observed: Vec<u8>,
258    },
259    /// Command execution failed.
260    #[error("command {} failed: {source}", .program.display())]
261    CommandRun {
262        /// Selected command executable.
263        program: PathBuf,
264        /// Underlying operation failure.
265        source: io::Error,
266    },
267    /// The caller supplied a zero timeout or unusable hard TTL.
268    #[error("{message}")]
269    InvalidConfiguration {
270        /// Configuration diagnostic.
271        message: String,
272    },
273    /// A caller-provided existing server URL could not be parsed.
274    #[error("invalid PocketIC server URL {server_url:?}: {message}")]
275    InvalidServerUrl {
276        /// Caller-provided server URL.
277        server_url: String,
278        /// Configuration diagnostic.
279        message: String,
280    },
281    /// Preparing or inspecting bounded startup files failed.
282    #[error("failed to {operation} at {}: {source}", .path.display())]
283    Io {
284        /// Failed filesystem operation.
285        operation: &'static str,
286        /// Affected filesystem path.
287        path: PathBuf,
288        /// Underlying operation failure.
289        source: io::Error,
290    },
291    /// The configured `PocketIC` server process could not be spawned.
292    #[error("failed to spawn PocketIC server {}: {source}", .server_binary.display())]
293    ServerSpawn {
294        /// Selected server executable.
295        server_binary: PathBuf,
296        /// Underlying operation failure.
297        source: io::Error,
298    },
299    /// The managed `PocketIC` server exited during construction or command execution.
300    #[error("PocketIC server {} exited with {status} after {elapsed:?}", .server_binary.display())]
301    ServerExited {
302        /// Selected server executable.
303        server_binary: PathBuf,
304        /// Observed server exit status.
305        status: ExitStatus,
306        /// Time spent before the server exited.
307        elapsed: Duration,
308    },
309    /// The managed server did not publish a usable port before the deadline.
310    #[error("PocketIC server {} was not ready within {timeout:?}", .server_binary.display())]
311    ReadinessTimeout {
312        /// Selected server executable.
313        server_binary: PathBuf,
314        /// Caller-selected deadline budget.
315        timeout: Duration,
316    },
317    /// The managed server published an invalid or oversized port-file value.
318    #[error("PocketIC server {} published invalid port {value:?}", .server_binary.display())]
319    InvalidServerPort {
320        /// Selected server executable.
321        server_binary: PathBuf,
322        /// Rejected port-file contents.
323        value: String,
324    },
325    /// `PocketIC` instance creation did not finish before the startup deadline.
326    #[error("PocketIC instance creation exceeded {timeout:?}")]
327    InstanceCreationTimeout {
328        /// Caller-selected deadline budget.
329        timeout: Duration,
330    },
331    /// Upstream `PocketIC` construction failed before returning an instance.
332    #[error("PocketIC startup failed: {source}")]
333    BuilderFailed {
334        /// Underlying operation failure.
335        source: ClientError,
336    },
337    /// The command completed, but the owned server could not be cleaned up.
338    #[error("PocketIC server cleanup failed after command exited with {command_status}")]
339    ServerCleanup {
340        /// Exit status of the completed command.
341        command_status: ExitStatus,
342    },
343}
344
345impl PocketIcStartupConfig {
346    /// Select the shared environment contract without discovery or downloads.
347    ///
348    /// `IC_TESTKIT_POCKET_IC_URL` takes precedence over `POCKET_IC_BIN`. An
349    /// explicitly empty or invalid selected value fails rather than falling
350    /// back. URL mode never launches a version probe or claims server ownership.
351    /// Binary mode resolves the explicit path and checks `--version` against
352    /// Testkit's supported protocol range using the shared bounded capture
353    /// engine. This is version qualification, not executable-byte admission;
354    /// prepare and verify the binary with `ic-testkit-server setup` / `check`.
355    /// The probe has its own `timeout`; subsequent startup has the same budget.
356    ///
357    /// # Errors
358    /// Rejects missing or invalid configuration and failed or incompatible version probes.
359    pub fn from_env(timeout: Duration) -> Result<Self, PocketIcStartupError> {
360        Self::from_environment(timeout, |name| std::env::var_os(name))
361    }
362
363    fn from_environment(
364        timeout: Duration,
365        mut variable: impl FnMut(&str) -> Option<std::ffi::OsString>,
366    ) -> Result<Self, PocketIcStartupError> {
367        if let Some(value) = variable("IC_TESTKIT_POCKET_IC_URL") {
368            let server_url = value
369                .into_string()
370                .ok()
371                .filter(|value| !value.is_empty())
372                .ok_or_else(|| {
373                    PocketIcStartupError::new(PocketIcStartupFailure::InvalidEnvironment {
374                        variable: "IC_TESTKIT_POCKET_IC_URL",
375                    })
376                })?;
377            let config = Self::Connect {
378                server_url: (&server_url).into(),
379                timeout,
380            };
381            config.validate()?;
382            let _ = configured_builder(PocketIcBuilder::new(), &server_url)?;
383            return Ok(config);
384        }
385        let value = variable("POCKET_IC_BIN")
386            .ok_or_else(|| PocketIcStartupError::new(PocketIcStartupFailure::NotConfigured))?;
387        if value.is_empty() {
388            return Err(PocketIcStartupError::new(
389                PocketIcStartupFailure::InvalidEnvironment {
390                    variable: "POCKET_IC_BIN",
391                },
392            ));
393        }
394        let path = PathBuf::from(value);
395        let binary = fs::canonicalize(&path).map_err(|source| {
396            PocketIcStartupError::new(PocketIcStartupFailure::Io {
397                operation: "resolve configured PocketIC executable",
398                path,
399                source,
400            })
401        })?;
402        let config = Self::Spawn {
403            server_binary: (&binary).into(),
404            timeout,
405            server_hard_ttl: None,
406            server_idle_ttl: None,
407            server_output_files: None,
408        };
409        config.validate()?;
410        let evidence = ic_host_process::tool::capture_group_command(
411            Command::new(&binary).arg("--version"),
412            ic_host_process::tool::OutputLimits {
413                stdout: ic_host_process::tool::OutputLimit::Terminate(SERVER_OUTPUT_LIMIT),
414                stderr: ic_host_process::tool::OutputLimit::Terminate(SERVER_OUTPUT_LIMIT),
415                timeout: Some(timeout),
416            },
417        )
418        .and_then(ic_host_process::tool::CompletedExecution::require_success)
419        .map_err(|source| {
420            PocketIcStartupError::new(PocketIcStartupFailure::ServerVersionProbe { source })
421        })?;
422        let expected = super::server::pocket_ic_server_requirement();
423        if !super::supports_pocket_ic_server(&evidence.stdout) {
424            return Err(PocketIcStartupError::new(
425                PocketIcStartupFailure::ServerVersionMismatch {
426                    expected,
427                    observed: evidence.stdout,
428                },
429            ));
430        }
431        Ok(config)
432    }
433
434    /// Complete startup deadline for either source.
435    #[must_use]
436    pub const fn timeout(&self) -> Duration {
437        match self {
438            Self::Spawn { timeout, .. } | Self::Connect { timeout, .. } => *timeout,
439        }
440    }
441
442    /// Construct an instance on the caller's Tokio runtime.
443    ///
444    /// Preserves deadline, readiness, typed client/exit failures and bounded output.
445    /// Requires a Tokio runtime with its time driver enabled.
446    /// Readiness and instance waits yield; process spawning and small file reads
447    /// remain synchronous, as does exact owned-child cleanup. The deadline cannot
448    /// preempt synchronous work inside an upstream future poll (such as copying
449    /// a read-only state directory); it is enforced when that poll returns.
450    ///
451    /// Dropping the future cancels local construction and terminates/reaps its
452    /// owned server, including during readiness. Cancellation has no returned error;
453    /// select output files to retain diagnostics. A borrowed server is untouched.
454    /// If a borrowed server created an instance before timeout or a lost reply, local
455    /// cancellation cannot identify or delete that instance; the caller owns this
456    /// reconciliation. The deadline covers construction, not subsequent deletion.
457    ///
458    /// ```no_run
459    /// use ic_testkit::pocket_ic::PocketIcBuilder;
460    /// use ic_testkit::pic::PocketIcStartupConfig;
461    /// use std::{path::Path, time::Duration};
462    /// # async fn run(binary: &Path) -> Result<(), Box<dyn std::error::Error>> {
463    /// let owned = PocketIcStartupConfig::Spawn { server_binary: binary.into(), timeout: Duration::from_secs(60), server_hard_ttl: None, server_idle_ttl: None, server_output_files: None }
464    ///     .build_instance(PocketIcBuilder::new().with_application_subnet()).await?;
465    /// let canister = owned.instance().create_canister().await?;
466    /// assert!(owned.instance().canister_exists(canister).await?);
467    /// let (instance, server) = owned.into_parts();
468    /// instance.drop().await?;
469    /// drop(server);
470    /// # Ok(())
471    /// # }
472    /// ```
473    ///
474    /// # Errors
475    /// Returns configuration, startup, deadline or instance-creation failures with cleanup evidence.
476    pub async fn build_instance(
477        self,
478        builder: PocketIcBuilder,
479    ) -> Result<PocketIcInstance<PocketIc>, PocketIcStartupError> {
480        let startup = StartupDeadline::new(&self)?;
481        let (server, server_url) = match self {
482            Self::Connect { server_url, .. } => (None, server_url),
483            spawn @ Self::Spawn { .. } => {
484                let mut server = spawn.spawn_server(startup.started)?;
485                let server_url = loop {
486                    match server.poll_ready(&startup) {
487                        Ok(Some(url)) => break url,
488                        Ok(None) => {
489                            tokio::time::sleep(startup.wait_interval(Instant::now(), true)).await;
490                        }
491                        Err(error) => return Err(finalize_failure(error, None, Some(server))),
492                    }
493                };
494                (Some(server), server_url)
495            }
496        };
497        build_bounded(builder, &server_url, &startup, server).await
498    }
499
500    /// Start a caller-owned managed server without constructing an instance.
501    ///
502    /// This requires a configuration created by [`Self::Spawn`]. Readiness is
503    /// bounded by [`Self::timeout`]. No hard TTL is passed by default; an
504    /// explicit `server_hard_ttl` value is passed to the child.
505    /// `server_idle_ttl` independently selects the operation-idle
506    /// lifetime; otherwise `PocketIC` retains its own idle default.
507    /// Readiness requires a nonzero decimal port followed by a newline in a
508    /// regular UTF-8 file of at most 64 bytes; oversized files fail with bounded
509    /// diagnostics. Non-regular port files fail with [`PocketIcStartupFailure::Io`]
510    /// and [`io::ErrorKind::InvalidData`] without waiting for a FIFO writer.
511    /// The returned handle terminates the child on drop; use its URL with
512    /// [`Self::Connect`] to construct bounded instances.
513    ///
514    /// # Errors
515    /// Rejects invalid configuration and server startup or readiness failures.
516    pub fn start_managed_server(self) -> Result<PocketIcManagedServer, PocketIcStartupError> {
517        if matches!(self, Self::Connect { .. }) {
518            self.validate()?;
519            return Err(PocketIcStartupError::new(
520                PocketIcStartupFailure::InvalidConfiguration {
521                    message: "starting a managed PocketIC server requires a spawn configuration"
522                        .to_owned(),
523                },
524            ));
525        }
526        let startup = StartupDeadline::new(&self)?;
527        let (server, url) = self.spawn_server(startup.started)?.wait_ready(&startup)?;
528        Ok(PocketIcManagedServer { server, url })
529    }
530
531    // Only validated spawn configurations reach this projection. Keeping it here
532    // gives instance and server-only construction the same spawn settings.
533    fn spawn_server(self, started: Instant) -> Result<ManagedServer, PocketIcStartupError> {
534        let Self::Spawn {
535            server_binary,
536            server_hard_ttl,
537            server_idle_ttl,
538            server_output_files,
539            ..
540        } = self
541        else {
542            unreachable!("connect configurations do not spawn a server")
543        };
544        ManagedServer::spawn(
545            server_binary,
546            server_hard_ttl,
547            server_idle_ttl,
548            server_output_files,
549            started,
550        )
551    }
552
553    /// Run a command with `IC_TESTKIT_POCKET_IC_URL` set to this server.
554    ///
555    /// Spawn mode retains the managed server until command completion; connect
556    /// mode borrows the external server and never terminates it. The command's
557    /// IO and other environment selections remain caller-owned. Cancellation
558    /// is polled after bounded startup and every 20 ms while the command runs.
559    /// It returns an [`io::ErrorKind::Interrupted`] error after cleanup.
560    /// If the owned server exits while the command is pending, the command is
561    /// terminated and the server's status and bounded diagnostics are returned
562    /// as [`PocketIcStartupFailure::ServerExited`]. External servers are not monitored.
563    ///
564    /// On Unix the command starts in a new owned process group. Completion,
565    /// cancellation and observation failures terminate remaining group members
566    /// before reaping the leader, using the same lifecycle engine as managed
567    /// servers. This does not impose a command deadline or sandbox descendants
568    /// that deliberately leave the owned group. Other hosts own the direct child.
569    ///
570    /// # Errors
571    /// Returns startup, command execution, cancellation or server cleanup failures.
572    pub fn run_command(
573        self,
574        command: &mut Command,
575        mut cancelled: impl FnMut() -> bool,
576    ) -> Result<ExitStatus, PocketIcStartupError> {
577        self.validate()?;
578        let program = PathBuf::from(command.get_program());
579        let command_error = |source| {
580            PocketIcStartupError::new(PocketIcStartupFailure::CommandRun {
581                program: program.clone(),
582                source,
583            })
584        };
585        if cancelled() {
586            return Err(command_error(io::Error::from(io::ErrorKind::Interrupted)));
587        }
588        let (server, url) = match self {
589            Self::Connect { server_url, .. } => (None, server_url),
590            spawn @ Self::Spawn { .. } => {
591                let server = spawn.start_managed_server()?;
592                let url = server.url().to_owned();
593                (Some(server.server), url)
594            }
595        };
596        let mut server = server;
597        if cancelled() {
598            return Err(finalize_failure(
599                command_error(io::Error::from(io::ErrorKind::Interrupted)),
600                None,
601                server,
602            ));
603        }
604        command.env("IC_TESTKIT_POCKET_IC_URL", url);
605        let mut owned_child = match OwnedChild::spawn(command, CleanupPolicy::KillAndWait) {
606            Ok(child) => child,
607            Err(source) => {
608                return Err(finalize_failure(
609                    PocketIcStartupError::new(PocketIcStartupFailure::Io {
610                        operation: "spawn command with PocketIC server",
611                        path: PathBuf::from(command.get_program()),
612                        source,
613                    }),
614                    None,
615                    server,
616                ));
617            }
618        };
619        let result = loop {
620            if cancelled() {
621                break Err(command_error(io::Error::from(io::ErrorKind::Interrupted)));
622            }
623            match owned_child.try_wait() {
624                Ok(Some(status)) => break Ok(status),
625                Ok(None) => {
626                    if let Some(managed) = server.as_mut()
627                        && let Err(error) = managed.check_running()
628                    {
629                        break Err(error);
630                    }
631                    thread::sleep(STARTUP_POLL_INTERVAL);
632                }
633                Err(source) => break Err(command_error(source)),
634            }
635        };
636        match result {
637            Err(error) => Err(finalize_failure(error, Some(&mut owned_child), server)),
638            Ok(status) => {
639                if let Some(server) = server {
640                    let (output, cleanup) = server.terminate_and_capture();
641                    if let Some(cleanup) = cleanup {
642                        return Err(PocketIcStartupError::new(
643                            PocketIcStartupFailure::ServerCleanup {
644                                command_status: status,
645                            },
646                        )
647                        .with_cleanup(None, Some((output, Some(cleanup)))));
648                    }
649                }
650                Ok(status)
651            }
652        }
653    }
654
655    fn validate(&self) -> Result<(), PocketIcStartupError> {
656        if self.timeout().is_zero() {
657            return Err(PocketIcStartupError::new(
658                PocketIcStartupFailure::InvalidConfiguration {
659                    message: "PocketIC startup timeout must be greater than zero".to_owned(),
660                },
661            ));
662        }
663        if let Self::Spawn {
664            server_hard_ttl,
665            server_idle_ttl,
666            ..
667        } = self
668        {
669            for (ttl, message) in [
670                (
671                    server_hard_ttl,
672                    "PocketIC server hard TTL must be at least one second",
673                ),
674                (
675                    server_idle_ttl,
676                    "PocketIC server idle TTL must be at least one second",
677                ),
678            ] {
679                if ttl.is_some_and(|ttl| ttl.as_secs() == 0) {
680                    return Err(PocketIcStartupError::new(
681                        PocketIcStartupFailure::InvalidConfiguration {
682                            message: message.to_owned(),
683                        },
684                    ));
685                }
686            }
687        }
688        Ok(())
689    }
690}
691
692impl PocketIcManagedServer {
693    /// OS process ID of the owned server child, for caller-managed monitoring.
694    ///
695    /// This identifies the server process, not its descendants, and does not
696    /// establish that it is still running. The OS may reuse the ID after the
697    /// child exits and is reaped. Dropping this handle terminates and waits for
698    /// the child; retaining the ID does not retain server ownership.
699    #[must_use]
700    pub fn process_id(&self) -> u32 {
701        self.server.child.id()
702    }
703
704    /// Loopback URL published by the managed server.
705    #[must_use]
706    pub fn url(&self) -> &str {
707        &self.url
708    }
709
710    /// Current bounded stdout and stderr captured from the managed server.
711    ///
712    /// This reads at most the first 16 KiB from each retained output file,
713    /// renders it as lossy UTF-8, and adds an omitted-byte suffix when truncated.
714    /// The diagnostic read is bounded; files can grow while the server runs.
715    #[must_use]
716    pub fn output(&self) -> PocketIcManagedServerOutput {
717        self.server.files.capture()
718    }
719}
720
721impl PocketIcManagedServerOutput {
722    /// Bounded lossy UTF-8 standard output.
723    #[must_use]
724    pub fn stdout(&self) -> &str {
725        &self.stdout
726    }
727
728    /// Bounded lossy UTF-8 standard error.
729    #[must_use]
730    pub fn stderr(&self) -> &str {
731        &self.stderr
732    }
733}
734
735struct StartupDeadline {
736    started: Instant,
737    timeout: Duration,
738    deadline: Instant,
739}
740
741impl StartupDeadline {
742    fn new(config: &PocketIcStartupConfig) -> Result<Self, PocketIcStartupError> {
743        config.validate()?;
744        let started = Instant::now();
745        let timeout = config.timeout();
746        Ok(Self {
747            started,
748            timeout,
749            deadline: startup_deadline(started, timeout)?,
750        })
751    }
752
753    fn check_instance_deadline(
754        &self,
755        server: &mut Option<ManagedServer>,
756    ) -> Result<Instant, PocketIcStartupError> {
757        let now = Instant::now();
758        if now >= self.deadline {
759            return Err(finalize_failure(
760                PocketIcStartupError::new(PocketIcStartupFailure::InstanceCreationTimeout {
761                    timeout: self.timeout,
762                }),
763                None,
764                server.take(),
765            ));
766        }
767        Ok(now)
768    }
769
770    fn wait_interval(&self, now: Instant, monitor_server: bool) -> Duration {
771        let remaining = self.deadline.saturating_duration_since(now);
772        if monitor_server {
773            remaining.min(STARTUP_POLL_INTERVAL)
774        } else {
775            remaining
776        }
777    }
778
779    fn admit_instance<T>(
780        &self,
781        instance: T,
782        server_url: &str,
783        server: &mut Option<ManagedServer>,
784    ) -> Result<PocketIcInstance<T>, PocketIcStartupError> {
785        self.check_instance_deadline(server)?;
786        check_server(server)?;
787        Ok(PocketIcInstance {
788            instance,
789            server: server.take().map(|server| PocketIcManagedServer {
790                server,
791                url: server_url.to_owned(),
792            }),
793        })
794    }
795}
796
797fn startup_deadline(started: Instant, timeout: Duration) -> Result<Instant, PocketIcStartupError> {
798    started.checked_add(timeout).ok_or_else(|| {
799        PocketIcStartupError::new(PocketIcStartupFailure::InvalidConfiguration {
800            message: "PocketIC startup timeout exceeds the platform clock range".to_owned(),
801        })
802    })
803}
804
805/// Instance construction owns server custody until admission or failure.
806struct InstanceStartup<'a> {
807    deadline: &'a StartupDeadline,
808    server_url: &'a str,
809    server: Option<ManagedServer>,
810}
811
812impl InstanceStartup<'_> {
813    fn configure_builder(
814        &mut self,
815        builder: PocketIcBuilder,
816    ) -> Result<PocketIcBuilder, PocketIcStartupError> {
817        configured_builder(builder, self.server_url).map_err(|error| self.fail(error))
818    }
819
820    fn check_deadline(&mut self) -> Result<Instant, PocketIcStartupError> {
821        self.deadline.check_instance_deadline(&mut self.server)
822    }
823
824    fn wait_interval(&self, now: Instant) -> Duration {
825        self.deadline.wait_interval(now, self.server.is_some())
826    }
827
828    fn check_server(&mut self) -> Result<(), PocketIcStartupError> {
829        check_server(&mut self.server)
830    }
831
832    fn admit<T>(&mut self, instance: T) -> Result<PocketIcInstance<T>, PocketIcStartupError> {
833        self.deadline
834            .admit_instance(instance, self.server_url, &mut self.server)
835    }
836
837    fn fail(&mut self, error: PocketIcStartupError) -> PocketIcStartupError {
838        finalize_failure(error, None, self.server.take())
839    }
840}
841
842fn configured_builder(
843    builder: PocketIcBuilder,
844    server_url: &str,
845) -> Result<PocketIcBuilder, PocketIcStartupError> {
846    server_url
847        .parse()
848        .map(|url| builder.with_server_url(url))
849        .map_err(|error| {
850            PocketIcStartupError::new(PocketIcStartupFailure::InvalidServerUrl {
851                server_url: server_url.to_owned(),
852                message: error.to_string(),
853            })
854        })
855}
856
857fn check_server(server: &mut Option<ManagedServer>) -> Result<(), PocketIcStartupError> {
858    if let Some(managed) = server.as_mut()
859        && let Err(error) = managed.check_running()
860    {
861        return Err(finalize_failure(error, None, server.take()));
862    }
863    Ok(())
864}
865
866async fn build_bounded(
867    builder: PocketIcBuilder,
868    server_url: &str,
869    startup: &StartupDeadline,
870    server: Option<ManagedServer>,
871) -> Result<PocketIcInstance<PocketIc>, PocketIcStartupError> {
872    let mut startup = InstanceStartup {
873        deadline: startup,
874        server_url,
875        server,
876    };
877    let builder = startup.configure_builder(builder)?;
878    let mut construction = std::pin::pin!(builder.build());
879    // Drop this local before the pending upstream future on cancellation.
880    let mut startup = startup;
881    loop {
882        let now = startup.check_deadline()?;
883        startup.check_server()?;
884        let next_check = now + startup.wait_interval(now);
885        match tokio::time::timeout_at(next_check.into(), construction.as_mut()).await {
886            Ok(Ok(instance)) => {
887                return startup.admit(instance);
888            }
889            Ok(Err(source)) => {
890                return Err(startup.fail(PocketIcStartupError::new(
891                    PocketIcStartupFailure::BuilderFailed { source },
892                )));
893            }
894            Err(_) => {}
895        }
896    }
897}
898
899/// Finalize an owned operation once, preserving the primary failure.
900fn finalize_failure(
901    error: PocketIcStartupError,
902    command: Option<&mut OwnedChild>,
903    server: Option<ManagedServer>,
904) -> PocketIcStartupError {
905    error.with_cleanup(
906        command.and_then(|child| child.terminate().err()),
907        server.map(ManagedServer::terminate_and_capture),
908    )
909}
910
911struct ManagedServer {
912    child: OwnedChild,
913    binary: PathBuf,
914    files: StartupFiles,
915    started: Instant,
916}
917
918enum PortFileState {
919    Pending,
920    Ready(u16),
921    Invalid(String),
922}
923
924impl ManagedServer {
925    fn wait_ready(
926        mut self,
927        startup: &StartupDeadline,
928    ) -> Result<(Self, String), PocketIcStartupError> {
929        loop {
930            match self.poll_ready(startup) {
931                Ok(Some(url)) => return Ok((self, url)),
932                Ok(None) => thread::sleep(startup.wait_interval(Instant::now(), true)),
933                Err(error) => return Err(finalize_failure(error, None, Some(self))),
934            }
935        }
936    }
937
938    fn spawn(
939        binary: PathBuf,
940        hard_ttl: Option<Duration>,
941        idle_ttl: Option<Duration>,
942        output_files: Option<(PathBuf, PathBuf)>,
943        started: Instant,
944    ) -> Result<Self, PocketIcStartupError> {
945        let (files, stdout, stderr) = StartupFiles::create(output_files)?;
946        let mut command = Command::new(&binary);
947        if let Some(hard_ttl) = hard_ttl {
948            command
949                .arg("--hard-ttl")
950                .arg(hard_ttl.as_secs().to_string());
951        }
952        if let Some(idle_ttl) = idle_ttl {
953            command.arg("--ttl").arg(idle_ttl.as_secs().to_string());
954        }
955        command
956            .arg("--port-file")
957            .arg(&files.port)
958            .stdout(Stdio::from(stdout))
959            .stderr(Stdio::from(stderr));
960        let child =
961            OwnedChild::spawn(&mut command, CleanupPolicy::KillAndWait).map_err(|source| {
962                PocketIcStartupError::new(PocketIcStartupFailure::ServerSpawn {
963                    server_binary: binary.clone(),
964                    source,
965                })
966            })?;
967        Ok(Self {
968            child,
969            binary,
970            files,
971            started,
972        })
973    }
974
975    fn poll_ready(
976        &mut self,
977        startup: &StartupDeadline,
978    ) -> Result<Option<String>, PocketIcStartupError> {
979        self.check_running()?;
980        if Instant::now() >= startup.deadline {
981            return Err(PocketIcStartupError::new(
982                PocketIcStartupFailure::ReadinessTimeout {
983                    server_binary: self.binary.clone(),
984                    timeout: startup.timeout,
985                },
986            ));
987        }
988        match self.files.read_port()? {
989            PortFileState::Pending => Ok(None),
990            PortFileState::Ready(port) => Ok(Some(format!("http://127.0.0.1:{port}/"))),
991            PortFileState::Invalid(value) => Err(PocketIcStartupError::new(
992                PocketIcStartupFailure::InvalidServerPort {
993                    server_binary: self.binary.clone(),
994                    value,
995                },
996            )),
997        }
998    }
999
1000    fn check_running(&mut self) -> Result<(), PocketIcStartupError> {
1001        if let Some(status) = self.try_wait()? {
1002            return Err(self.exit_failure(status));
1003        }
1004        Ok(())
1005    }
1006
1007    fn try_wait(&mut self) -> Result<Option<ExitStatus>, PocketIcStartupError> {
1008        self.child.try_wait().map_err(|source| {
1009            PocketIcStartupError::new(PocketIcStartupFailure::Io {
1010                operation: "inspect PocketIC server child",
1011                path: self.binary.clone(),
1012                source,
1013            })
1014        })
1015    }
1016
1017    fn exit_failure(&self, status: ExitStatus) -> PocketIcStartupError {
1018        PocketIcStartupError::new(PocketIcStartupFailure::ServerExited {
1019            server_binary: self.binary.clone(),
1020            status,
1021            elapsed: self.started.elapsed(),
1022        })
1023    }
1024
1025    fn terminate_and_capture(self) -> (PocketIcManagedServerOutput, Option<CleanupError>) {
1026        let Self {
1027            mut child, files, ..
1028        } = self;
1029        let cleanup = child.terminate().err();
1030        // Finish the child's Drop cleanup before reading the final diagnostics.
1031        drop(child);
1032        (files.capture(), cleanup)
1033    }
1034}
1035
1036struct StartupFiles {
1037    #[cfg_attr(
1038        not(test),
1039        expect(
1040            dead_code,
1041            reason = "retains directory ownership until cleanup or drop"
1042        )
1043    )]
1044    directory: OwnedDirectory,
1045    port: PathBuf,
1046    stdout: PathBuf,
1047    stderr: PathBuf,
1048}
1049
1050impl StartupFiles {
1051    fn read_port(&self) -> Result<PortFileState, PocketIcStartupError> {
1052        let port_path = &self.port;
1053        let contents =
1054            match ic_host_fs::read::read_file_prefix(port_path, SERVER_PORT_FILE_LIMIT + 1)
1055                .map_err(|error| match error {
1056                    ic_host_artifacts::artifact::ArtifactError::Io(source) => source,
1057                    error => io::Error::new(io::ErrorKind::InvalidData, error),
1058                })
1059                .and_then(|(bytes, _)| {
1060                    String::from_utf8(bytes)
1061                        .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
1062                }) {
1063                Ok(contents) => contents,
1064                Err(error) if error.kind() == io::ErrorKind::NotFound => {
1065                    return Ok(PortFileState::Pending);
1066                }
1067                Err(source) => {
1068                    return Err(PocketIcStartupError::new(PocketIcStartupFailure::Io {
1069                        operation: "read PocketIC server port file",
1070                        path: port_path.clone(),
1071                        source,
1072                    }));
1073                }
1074            };
1075        if contents.len() > SERVER_PORT_FILE_LIMIT {
1076            return Ok(PortFileState::Invalid(format!(
1077                "{} (port file exceeds {SERVER_PORT_FILE_LIMIT} bytes)",
1078                contents.trim()
1079            )));
1080        }
1081        if !contents.contains('\n') {
1082            return Ok(PortFileState::Pending);
1083        }
1084        let value = contents.trim();
1085        match value.parse::<u16>() {
1086            Ok(port) if port != 0 => Ok(PortFileState::Ready(port)),
1087            _ => Ok(PortFileState::Invalid(value.to_owned())),
1088        }
1089    }
1090
1091    fn capture(&self) -> PocketIcManagedServerOutput {
1092        PocketIcManagedServerOutput {
1093            stdout: read_bounded_lossy(&self.stdout),
1094            stderr: read_bounded_lossy(&self.stderr),
1095        }
1096    }
1097
1098    fn create(
1099        output_files: Option<(PathBuf, PathBuf)>,
1100    ) -> Result<(Self, File, File), PocketIcStartupError> {
1101        let output_files = output_files
1102            .map(|(stdout, stderr)| {
1103                Ok::<_, PocketIcStartupError>((
1104                    std::path::absolute(&stdout)
1105                        .map_err(|source| startup_file_error("resolve", &stdout, source))?,
1106                    std::path::absolute(&stderr)
1107                        .map_err(|source| startup_file_error("resolve", &stderr, source))?,
1108                ))
1109            })
1110            .transpose()?;
1111        let directory = OwnedDirectory::new(
1112            create_unique_directory(
1113                &std::env::temp_dir(),
1114                "ic-testkit-pocket-ic-startup",
1115                0o700,
1116                None,
1117            )
1118            .map_err(|source| startup_file_error("create", &std::env::temp_dir(), source))?,
1119        );
1120        let base = directory.path();
1121        let (stdout_path, stderr_path) =
1122            output_files.unwrap_or_else(|| (base.join("stdout"), base.join("stderr")));
1123        let files = Self {
1124            port: base.join("port"),
1125            stdout: stdout_path,
1126            stderr: stderr_path,
1127            directory,
1128        };
1129        let stdout = create_new_file(&files.stdout)
1130            .map_err(|source| startup_file_error("create", &files.stdout, source))?;
1131        let stderr = create_new_file(&files.stderr)
1132            .map_err(|source| startup_file_error("create", &files.stderr, source))?;
1133        Ok((files, stdout, stderr))
1134    }
1135}
1136
1137fn create_new_file(path: &Path) -> io::Result<File> {
1138    let mut options = OpenOptions::new();
1139    options.write(true).create_new(true);
1140    #[cfg(unix)]
1141    options.mode(0o600);
1142    options.open(path)
1143}
1144
1145fn startup_file_error(
1146    operation: &'static str,
1147    path: &Path,
1148    source: io::Error,
1149) -> PocketIcStartupError {
1150    PocketIcStartupError::new(PocketIcStartupFailure::Io {
1151        operation,
1152        path: path.to_owned(),
1153        source,
1154    })
1155}
1156
1157fn read_bounded_lossy(path: &Path) -> String {
1158    let Ok((bytes, length)) = ic_host_fs::read::read_file_prefix(path, SERVER_OUTPUT_LIMIT) else {
1159        return String::new();
1160    };
1161    let mut output = String::from_utf8_lossy(&bytes).into_owned();
1162    let omitted = length.saturating_sub(bytes.len() as u64);
1163    if omitted > 0 {
1164        let _ = write!(output, "\n<truncated {omitted} bytes>");
1165    }
1166    output
1167}
1168
1169impl std::fmt::Display for PocketIcStartupError {
1170    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1171        std::fmt::Display::fmt(self.failure.as_ref(), formatter)?;
1172        if !self.output.stdout.is_empty() {
1173            write!(formatter, "; server stdout: {}", self.output.stdout)?;
1174        }
1175        if !self.output.stderr.is_empty() {
1176            write!(formatter, "; server stderr: {}", self.output.stderr)?;
1177        }
1178        for (owner, cleanup) in [
1179            ("command", self.command_cleanup()),
1180            ("server", self.server_cleanup()),
1181        ] {
1182            if let Some(error) = cleanup {
1183                write!(formatter, "; {owner} cleanup also failed: {error}")?;
1184            }
1185        }
1186        Ok(())
1187    }
1188}
1189
1190impl std::error::Error for PocketIcStartupError {
1191    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1192        std::error::Error::source(self.failure.as_ref())
1193    }
1194}
1195
1196#[cfg(test)]
1197mod tests;