Skip to main content

ic_testkit/pic/
startup.rs

1use std::{
2    fmt::Write as _,
3    fs::{self, File, OpenOptions},
4    future::Future as _,
5    io::{self, Read as _},
6    path::{Path, PathBuf},
7    process::{Command, ExitStatus, Stdio},
8    sync::mpsc::{self, RecvTimeoutError},
9    thread,
10    time::{Duration, Instant},
11};
12
13#[cfg(unix)]
14use std::os::unix::fs::OpenOptionsExt as _;
15
16use ic_host_fs::{directory::create_unique_directory, owned_directory::OwnedDirectory};
17use ic_host_process::child::{CleanupError, CleanupPolicy, OwnedChild};
18use ic_platform::pocket_ic::{PocketIc, PocketIcBuilder};
19
20use super::transport;
21
22const STARTUP_POLL_INTERVAL: Duration = Duration::from_millis(20);
23const SERVER_OUTPUT_LIMIT: usize = 16 * 1024;
24// PocketIC publishes a decimal u16 and a newline. Leave whitespace room
25// without allowing readiness polling to read an arbitrary-size file.
26const SERVER_PORT_FILE_LIMIT: usize = 64;
27
28/// Explicit bounded source and policy for one `PocketIC` startup.
29///
30/// Spawn-only settings cannot be attached to a borrowed connection. Callers own
31/// binary admission, output retention and namespace custody.
32#[derive(Clone, Debug, Eq, PartialEq)]
33pub enum PocketIcStartupConfig {
34    /// Spawn and own one exact server executable.
35    Spawn {
36        /// Caller-selected, already prepared server binary.
37        server_binary: PathBuf,
38        /// Complete startup deadline; must be positive.
39        timeout: Duration,
40        /// Optional hard lifetime, in whole seconds; `None` disables it.
41        server_hard_ttl: Option<Duration>,
42        /// Optional operation-idle lifetime; `None` uses `PocketIC`'s default.
43        server_idle_ttl: Option<Duration>,
44        /// New caller-owned stdout/stderr files, or temporary output when absent.
45        /// Existing entries are refused. Created files survive failure and teardown.
46        server_output_files: Option<(PathBuf, PathBuf)>,
47    },
48    /// Borrow an existing server without claiming its process lifetime.
49    Connect {
50        /// Existing server URL, admitted by the upstream builder.
51        server_url: String,
52        /// Complete startup deadline; must be positive.
53        timeout: Duration,
54    },
55}
56
57/// Caller-owned `PocketIC` server process with bounded startup and output capture.
58///
59/// Dropping the handle terminates and waits for the managed child. On Unix,
60/// teardown also terminates descendants remaining in its owned process group.
61/// Callers may create several instances through [`Self::url`] and
62/// [`PocketIcStartupConfig::Connect`] while retaining explicit server ownership.
63/// The handle is process-local and does not coordinate ownership across Cargo
64/// or test-runner processes; use an externally owned server with bounded
65/// connect mode for that topology.
66/// The handle owns no binary discovery, download, cache, or compatibility policy.
67pub struct PocketIcManagedServer {
68    server: ManagedServer,
69    url: String,
70}
71
72/// An upstream instance together with the server owned by its construction.
73///
74/// Fields are ordered so a synchronous [`PocketIc`] is deleted before the server
75/// is terminated. Connect mode borrows the caller's server and returns `None`.
76/// Native `PocketIC` operations remain available through [`Self::instance`].
77/// [`Self::into_parts`] explicitly transfers teardown ordering to the caller.
78///
79/// `PocketIC` 16 synchronous deletion has no request deadline and can block drop.
80/// Its nonblocking instance has no automatic deletion: call
81/// [`Self::into_parts`], then `instance.drop().await` while retaining `server`,
82/// when deletion matters, especially with a borrowed server. Dropping an owned
83/// server stops all its instances.
84///
85/// Field projection cannot silently detach the client from its server:
86/// ```compile_fail
87/// use ic_testkit::pic::PocketIcInstance;
88/// fn detach<T>(owned: PocketIcInstance<T>) -> T {
89///     owned.instance
90/// }
91/// ```
92/// Nor can a client borrow outlive a temporary owner:
93/// ```compile_fail
94/// use ic_testkit::pic::PocketIcInstance;
95/// let client = PocketIcInstance::from_borrowed_server(String::new()).instance();
96/// assert!(client.is_empty());
97/// ```
98#[must_use]
99pub struct PocketIcInstance<T> {
100    instance: T,
101    server: Option<PocketIcManagedServer>,
102}
103
104impl<T> PocketIcInstance<T> {
105    /// Retain an existing client whose server lifetime is owned elsewhere.
106    ///
107    /// This creates no server or instance and claims no server process authority.
108    /// The client retains its upstream deletion behavior; its external server
109    /// must outlive it. Use a startup configuration for a managed server owner.
110    pub const fn from_borrowed_server(instance: T) -> Self {
111        Self {
112            instance,
113            server: None,
114        }
115    }
116
117    /// Borrow the client while retaining this record's managed-server ownership.
118    #[must_use]
119    pub const fn instance(&self) -> &T {
120        &self.instance
121    }
122
123    /// Borrow the exact managed server, or `None` for an externally owned server.
124    #[must_use]
125    pub const fn server(&self) -> Option<&PocketIcManagedServer> {
126        self.server.as_ref()
127    }
128
129    /// Transfer the client and optional server, without deleting or stopping either.
130    ///
131    /// The caller must retain the server until client deletion is complete. For
132    /// a nonblocking client, explicitly await `instance.drop()` before dropping
133    /// the returned server. No implicit asynchronous or blocking cleanup is added.
134    #[must_use]
135    pub fn into_parts(self) -> (T, Option<PocketIcManagedServer>) {
136        (self.instance, self.server)
137    }
138}
139
140/// Bounded lossy UTF-8 output captured from a managed `PocketIC` server.
141///
142/// Each stream retains at most the first 16 KiB. A textual suffix reports the
143/// number of omitted bytes when truncation occurred. Unreadable streams and
144/// paths replaced with non-regular files are omitted.
145#[derive(Clone, Debug, Default, Eq, PartialEq)]
146pub struct PocketIcManagedServerOutput {
147    stdout: String,
148    stderr: String,
149}
150
151/// Original startup failure, bounded server output and typed cleanup failures.
152///
153/// Match [`Self::failure`] to classify the original cause. Cleanup diagnostics
154/// never replace it, and command/server failures retain separate ownership.
155/// Output is an inert bounded snapshot; it grants no process authority.
156#[derive(Debug)]
157pub struct PocketIcStartupError {
158    failure: Box<PocketIcStartupFailure>,
159    output: PocketIcManagedServerOutput,
160    command_cleanup: Option<Box<CleanupError>>,
161    server_cleanup: Option<Box<CleanupError>>,
162}
163
164impl PocketIcStartupError {
165    fn new(failure: PocketIcStartupFailure) -> Self {
166        Self {
167            failure: Box::new(failure),
168            output: PocketIcManagedServerOutput::default(),
169            command_cleanup: None,
170            server_cleanup: None,
171        }
172    }
173
174    /// Original cause, unaffected by subsequent cleanup failure.
175    #[must_use]
176    pub fn failure(&self) -> &PocketIcStartupFailure {
177        &self.failure
178    }
179
180    /// Bounded output from the server owned by this operation, if any.
181    #[must_use]
182    pub const fn output(&self) -> &PocketIcManagedServerOutput {
183        &self.output
184    }
185
186    /// Failure cleaning the command's owned process group/direct child.
187    #[must_use]
188    pub fn command_cleanup(&self) -> Option<&CleanupError> {
189        self.command_cleanup.as_deref()
190    }
191
192    /// Failure cleaning the managed server's owned process group/direct child.
193    #[must_use]
194    pub fn server_cleanup(&self) -> Option<&CleanupError> {
195        self.server_cleanup.as_deref()
196    }
197
198    fn with_cleanup(
199        mut self,
200        command: Option<CleanupError>,
201        server: Option<(PocketIcManagedServerOutput, Option<CleanupError>)>,
202    ) -> Self {
203        self.command_cleanup = command.map(Box::new);
204        if let Some((output, cleanup)) = server {
205            self.output = output;
206            self.server_cleanup = cleanup.map(Box::new);
207        }
208        self
209    }
210}
211
212/// Original failure, independent of output capture and cleanup outcomes.
213#[non_exhaustive]
214#[derive(Debug, thiserror::Error)]
215pub enum PocketIcStartupFailure {
216    /// Neither the shared server URL nor an explicit executable was configured.
217    #[error(
218        "configure IC_TESTKIT_POCKET_IC_URL or POCKET_IC_BIN; prepare a verified binary with make install-server"
219    )]
220    NotConfigured,
221    /// A selected environment value was empty or was not valid Unicode.
222    #[error("invalid selected environment value: {variable}")]
223    InvalidEnvironment {
224        /// Selected environment variable name.
225        variable: &'static str,
226    },
227    /// The bounded version probe failed, including nonzero exit or timeout.
228    #[error("PocketIC version probe failed: {source}")]
229    ServerVersionProbe {
230        /// Underlying operation failure.
231        source: ic_host_process::tool::ToolError,
232    },
233    /// The selected executable does not report the qualified server identity.
234    #[error("PocketIC version mismatch: expected {expected:?}, observed {:?}", String::from_utf8_lossy(.observed))]
235    ServerVersionMismatch {
236        /// Required server identity.
237        expected: String,
238        /// Captured version-probe output.
239        observed: Vec<u8>,
240    },
241    /// Command execution failed.
242    #[error("command {} failed: {source}", .program.display())]
243    CommandRun {
244        /// Selected command executable.
245        program: PathBuf,
246        /// Underlying operation failure.
247        source: io::Error,
248    },
249    /// The caller supplied a zero timeout or unusable hard TTL.
250    #[error("{message}")]
251    InvalidConfiguration {
252        /// Configuration diagnostic.
253        message: String,
254    },
255    /// A caller-provided existing server URL could not be parsed.
256    #[error("invalid PocketIC server URL {server_url:?}: {message}")]
257    InvalidServerUrl {
258        /// Caller-provided server URL.
259        server_url: String,
260        /// Configuration diagnostic.
261        message: String,
262    },
263    /// Preparing or inspecting bounded startup files failed.
264    #[error("failed to {operation} at {}: {source}", .path.display())]
265    Io {
266        /// Failed filesystem operation.
267        operation: &'static str,
268        /// Affected filesystem path.
269        path: PathBuf,
270        /// Underlying operation failure.
271        source: io::Error,
272    },
273    /// The configured `PocketIC` server process could not be spawned.
274    #[error("failed to spawn PocketIC server {}: {source}", .server_binary.display())]
275    ServerSpawn {
276        /// Selected server executable.
277        server_binary: PathBuf,
278        /// Underlying operation failure.
279        source: io::Error,
280    },
281    /// The managed `PocketIC` server exited during construction or command execution.
282    #[error("PocketIC server {} exited with {status} after {elapsed:?}", .server_binary.display())]
283    ServerExited {
284        /// Selected server executable.
285        server_binary: PathBuf,
286        /// Observed server exit status.
287        status: ExitStatus,
288        /// Time spent before the server exited.
289        elapsed: Duration,
290    },
291    /// The managed server did not publish a usable port before the deadline.
292    #[error("PocketIC server {} was not ready within {timeout:?}", .server_binary.display())]
293    ReadinessTimeout {
294        /// Selected server executable.
295        server_binary: PathBuf,
296        /// Caller-selected deadline budget.
297        timeout: Duration,
298    },
299    /// The managed server published an invalid or oversized port-file value.
300    #[error("PocketIC server {} published invalid port {value:?}", .server_binary.display())]
301    InvalidServerPort {
302        /// Selected server executable.
303        server_binary: PathBuf,
304        /// Rejected port-file contents.
305        value: String,
306    },
307    /// `PocketIC` instance creation did not finish before the startup deadline.
308    #[error("PocketIC instance creation exceeded {timeout:?}")]
309    InstanceCreationTimeout {
310        /// Caller-selected deadline budget.
311        timeout: Duration,
312    },
313    /// Spawning the bounded builder worker failed.
314    #[error("failed to spawn PocketIC builder worker: {source}")]
315    BuilderThreadSpawn {
316        /// Underlying operation failure.
317        source: io::Error,
318    },
319    /// Upstream `PocketIC` construction panicked before returning an instance.
320    #[error("PocketIC startup panicked: {source}")]
321    BuilderPanicked {
322        /// Underlying operation failure.
323        source: super::PocketIcOperationError,
324    },
325    /// The bounded builder worker ended without returning a result.
326    #[error("PocketIC builder worker disconnected without a result")]
327    BuilderDisconnected,
328    /// The command completed, but the owned server could not be cleaned up.
329    #[error("PocketIC server cleanup failed after command exited with {command_status}")]
330    ServerCleanup {
331        /// Exit status of the completed command.
332        command_status: ExitStatus,
333    },
334}
335
336impl PocketIcStartupConfig {
337    /// Select the shared environment contract without discovery or downloads.
338    ///
339    /// `IC_TESTKIT_POCKET_IC_URL` takes precedence over `POCKET_IC_BIN`. An
340    /// explicitly empty or invalid selected value fails rather than falling
341    /// back. URL mode never launches a version probe or claims server ownership.
342    /// Binary mode resolves the explicit path and checks `--version` against
343    /// Testkit's supported protocol range using the shared bounded capture
344    /// engine. This is version qualification, not executable-byte admission;
345    /// prepare and verify the binary with `ic-testkit-server setup` / `check`.
346    /// The probe has its own `timeout`; subsequent startup has the same budget.
347    ///
348    /// # Errors
349    /// Rejects missing or invalid configuration and failed or incompatible version probes.
350    pub fn from_env(timeout: Duration) -> Result<Self, PocketIcStartupError> {
351        Self::from_environment(timeout, |name| std::env::var_os(name))
352    }
353
354    fn from_environment(
355        timeout: Duration,
356        mut variable: impl FnMut(&str) -> Option<std::ffi::OsString>,
357    ) -> Result<Self, PocketIcStartupError> {
358        if let Some(value) = variable("IC_TESTKIT_POCKET_IC_URL") {
359            let server_url = value
360                .into_string()
361                .ok()
362                .filter(|value| !value.is_empty())
363                .ok_or_else(|| {
364                    PocketIcStartupError::new(PocketIcStartupFailure::InvalidEnvironment {
365                        variable: "IC_TESTKIT_POCKET_IC_URL",
366                    })
367                })?;
368            let config = Self::Connect {
369                server_url: (&server_url).into(),
370                timeout,
371            };
372            config.validate()?;
373            let _ = configured_builder(PocketIcBuilder::new(), &server_url)?;
374            return Ok(config);
375        }
376        let value = variable("POCKET_IC_BIN")
377            .ok_or_else(|| PocketIcStartupError::new(PocketIcStartupFailure::NotConfigured))?;
378        if value.is_empty() {
379            return Err(PocketIcStartupError::new(
380                PocketIcStartupFailure::InvalidEnvironment {
381                    variable: "POCKET_IC_BIN",
382                },
383            ));
384        }
385        let path = PathBuf::from(value);
386        let binary = fs::canonicalize(&path).map_err(|source| {
387            PocketIcStartupError::new(PocketIcStartupFailure::Io {
388                operation: "resolve configured PocketIC executable",
389                path,
390                source,
391            })
392        })?;
393        let config = Self::Spawn {
394            server_binary: (&binary).into(),
395            timeout,
396            server_hard_ttl: None,
397            server_idle_ttl: None,
398            server_output_files: None,
399        };
400        config.validate()?;
401        let evidence = ic_host_process::tool::capture_group_command(
402            Command::new(&binary).arg("--version"),
403            ic_host_process::tool::OutputLimits {
404                stdout: ic_host_process::tool::OutputLimit::Terminate(SERVER_OUTPUT_LIMIT),
405                stderr: ic_host_process::tool::OutputLimit::Terminate(SERVER_OUTPUT_LIMIT),
406                timeout: Some(timeout),
407            },
408        )
409        .and_then(ic_host_process::tool::CompletedExecution::require_success)
410        .map_err(|source| {
411            PocketIcStartupError::new(PocketIcStartupFailure::ServerVersionProbe { source })
412        })?;
413        let expected = super::server::expected_server_version();
414        if !super::supports_pocket_ic_server(&evidence.stdout) {
415            return Err(PocketIcStartupError::new(
416                PocketIcStartupFailure::ServerVersionMismatch {
417                    expected,
418                    observed: evidence.stdout,
419                },
420            ));
421        }
422        Ok(config)
423    }
424
425    /// Complete startup deadline for either source.
426    #[must_use]
427    pub const fn timeout(&self) -> Duration {
428        match self {
429            Self::Spawn { timeout, .. } | Self::Connect { timeout, .. } => *timeout,
430        }
431    }
432
433    /// Construct a synchronous instance and retain its exact managed server.
434    ///
435    /// One deadline covers server readiness and instance creation. Topology,
436    /// state paths and request policy remain on the caller's builder. Spawn mode
437    /// terminates the server on failure and retains it on success; connect mode
438    /// never claims the external server. See [`PocketIcInstance`] for drop limits.
439    ///
440    /// ```no_run
441    /// use ic_platform::pocket_ic::PocketIcBuilder;
442    /// use ic_testkit::pic::PocketIcStartupConfig;
443    /// use std::{path::Path, time::Duration};
444    /// # fn run(binary: &Path) -> Result<(), ic_testkit::pic::PocketIcStartupError> {
445    /// 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 }
446    ///     .build_instance(PocketIcBuilder::new().with_application_subnet())?;
447    /// let canister = owned.instance().create_canister();
448    /// assert!(owned.instance().canister_exists(canister));
449    /// // Drop deletes the instance, then terminates and reaps the owned server.
450    /// # Ok(())
451    /// # }
452    /// ```
453    ///
454    /// # Errors
455    /// Returns configuration, startup, deadline or instance-creation failures with cleanup evidence.
456    pub fn build_instance(
457        self,
458        builder: PocketIcBuilder,
459    ) -> Result<PocketIcInstance<PocketIc>, PocketIcStartupError> {
460        let startup = StartupDeadline::new(&self)?;
461        let (server, url) = match self {
462            Self::Connect { server_url, .. } => (None, server_url),
463            spawn @ Self::Spawn { .. } => {
464                let (server, url) = spawn.spawn_server(startup.started)?.wait_ready(&startup)?;
465                (Some(server), url)
466            }
467        };
468        build_bounded(builder, &url, &startup, server)
469    }
470
471    /// Construct an upstream nonblocking instance on the caller's Tokio runtime.
472    ///
473    /// Uses the same deadline, readiness checks, typed panic/exit failures and
474    /// bounded output as [`Self::build_instance`], without running the synchronous
475    /// instance constructor. Requires a Tokio runtime with its time driver enabled.
476    /// Readiness and instance waits yield; process spawning and small file reads
477    /// remain synchronous, as does exact owned-child cleanup. The deadline cannot
478    /// preempt synchronous work inside an upstream future poll (such as copying
479    /// a read-only state directory); it is enforced when that poll returns.
480    ///
481    /// Dropping the future cancels local construction and terminates/reaps its
482    /// owned server, including during readiness. Cancellation has no returned error;
483    /// select output files to retain diagnostics. A borrowed server is untouched.
484    /// If a borrowed server created an instance before timeout or a lost reply, local
485    /// cancellation cannot identify or delete that instance; the caller owns this
486    /// reconciliation. The deadline covers construction, not subsequent deletion.
487    ///
488    /// ```no_run
489    /// use ic_platform::pocket_ic::PocketIcBuilder;
490    /// use ic_testkit::pic::PocketIcStartupConfig;
491    /// use std::{path::Path, time::Duration};
492    /// # async fn run(binary: &Path) -> Result<(), ic_testkit::pic::PocketIcStartupError> {
493    /// 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 }
494    ///     .build_instance_async(PocketIcBuilder::new().with_application_subnet()).await?;
495    /// let canister = owned.instance().create_canister().await;
496    /// assert!(owned.instance().canister_exists(canister).await);
497    /// let (instance, server) = owned.into_parts();
498    /// instance.drop().await;
499    /// drop(server);
500    /// # Ok(())
501    /// # }
502    /// ```
503    ///
504    /// # Errors
505    /// Returns configuration, startup, deadline or instance-creation failures with cleanup evidence.
506    pub async fn build_instance_async(
507        self,
508        builder: PocketIcBuilder,
509    ) -> Result<PocketIcInstance<ic_platform::pocket_ic::nonblocking::PocketIc>, PocketIcStartupError>
510    {
511        let startup = StartupDeadline::new(&self)?;
512        let (server, server_url) = match self {
513            Self::Connect { server_url, .. } => (None, server_url),
514            spawn @ Self::Spawn { .. } => {
515                let mut server = spawn.spawn_server(startup.started)?;
516                let server_url = loop {
517                    match server.poll_ready(&startup) {
518                        Ok(Some(url)) => break url,
519                        Ok(None) => {
520                            tokio::time::sleep(
521                                startup
522                                    .deadline
523                                    .saturating_duration_since(Instant::now())
524                                    .min(STARTUP_POLL_INTERVAL),
525                            )
526                            .await;
527                        }
528                        Err(error) => return Err(finalize_failure(error, None, Some(server))),
529                    }
530                };
531                (Some(server), server_url)
532            }
533        };
534        build_async_bounded(builder, &server_url, &startup, server).await
535    }
536
537    /// Start a caller-owned managed server without constructing an instance.
538    ///
539    /// This requires a configuration created by [`Self::Spawn`]. Readiness is
540    /// bounded by [`Self::timeout`]. No hard TTL is passed by default; an
541    /// explicit `server_hard_ttl` value is passed to the child.
542    /// `server_idle_ttl` independently selects the operation-idle
543    /// lifetime; otherwise `PocketIC` retains its own idle default.
544    /// Readiness requires a nonzero decimal port followed by a newline in a
545    /// regular UTF-8 file of at most 64 bytes; oversized files fail with bounded
546    /// diagnostics. Non-regular port files fail with [`PocketIcStartupFailure::Io`]
547    /// and [`io::ErrorKind::InvalidData`] without waiting for a FIFO writer.
548    /// The returned handle terminates the child on drop; use its URL with
549    /// [`Self::Connect`] to construct bounded instances.
550    ///
551    /// # Errors
552    /// Rejects invalid configuration and server startup or readiness failures.
553    pub fn start_managed_server(self) -> Result<PocketIcManagedServer, PocketIcStartupError> {
554        if matches!(self, Self::Connect { .. }) {
555            self.validate()?;
556            return Err(PocketIcStartupError::new(
557                PocketIcStartupFailure::InvalidConfiguration {
558                    message: "starting a managed PocketIC server requires a spawn configuration"
559                        .to_owned(),
560                },
561            ));
562        }
563        let startup = StartupDeadline::new(&self)?;
564        let (server, url) = self.spawn_server(startup.started)?.wait_ready(&startup)?;
565        Ok(PocketIcManagedServer { server, url })
566    }
567
568    // Only validated spawn configurations reach this projection. Keeping it here
569    // gives sync, async and server-only construction the same spawn settings.
570    fn spawn_server(self, started: Instant) -> Result<ManagedServer, PocketIcStartupError> {
571        let Self::Spawn {
572            server_binary,
573            server_hard_ttl,
574            server_idle_ttl,
575            server_output_files,
576            ..
577        } = self
578        else {
579            unreachable!("connect configurations do not spawn a server")
580        };
581        ManagedServer::spawn(
582            server_binary,
583            server_hard_ttl,
584            server_idle_ttl,
585            server_output_files,
586            started,
587        )
588    }
589
590    /// Run a command with `IC_TESTKIT_POCKET_IC_URL` set to this server.
591    ///
592    /// Spawn mode retains the managed server until command completion; connect
593    /// mode borrows the external server and never terminates it. The command's
594    /// IO and other environment selections remain caller-owned. Cancellation
595    /// is polled after bounded startup and every 20 ms while the command runs.
596    /// It returns an [`io::ErrorKind::Interrupted`] error after cleanup.
597    /// If the owned server exits while the command is pending, the command is
598    /// terminated and the server's status and bounded diagnostics are returned
599    /// as [`PocketIcStartupFailure::ServerExited`]. External servers are not monitored.
600    ///
601    /// On Unix the command starts in a new owned process group. Completion,
602    /// cancellation and observation failures terminate remaining group members
603    /// before reaping the leader, using the same lifecycle engine as managed
604    /// servers. This does not impose a command deadline or sandbox descendants
605    /// that deliberately leave the owned group. Other hosts own the direct child.
606    ///
607    /// # Errors
608    /// Returns startup, command execution, cancellation or server cleanup failures.
609    pub fn run_command(
610        self,
611        command: &mut Command,
612        mut cancelled: impl FnMut() -> bool,
613    ) -> Result<ExitStatus, PocketIcStartupError> {
614        self.validate()?;
615        let program = PathBuf::from(command.get_program());
616        let command_error = |source| {
617            PocketIcStartupError::new(PocketIcStartupFailure::CommandRun {
618                program: program.clone(),
619                source,
620            })
621        };
622        if cancelled() {
623            return Err(command_error(io::Error::from(io::ErrorKind::Interrupted)));
624        }
625        let (server, url) = match self {
626            Self::Connect { server_url, .. } => (None, server_url),
627            spawn @ Self::Spawn { .. } => {
628                let server = spawn.start_managed_server()?;
629                let url = server.url().to_owned();
630                (Some(server.server), url)
631            }
632        };
633        let mut server = server;
634        if cancelled() {
635            return Err(finalize_failure(
636                command_error(io::Error::from(io::ErrorKind::Interrupted)),
637                None,
638                server,
639            ));
640        }
641        command.env("IC_TESTKIT_POCKET_IC_URL", url);
642        let mut owned_child = match OwnedChild::spawn(command, CleanupPolicy::KillAndWait) {
643            Ok(child) => child,
644            Err(source) => {
645                return Err(finalize_failure(
646                    PocketIcStartupError::new(PocketIcStartupFailure::Io {
647                        operation: "spawn command with PocketIC server",
648                        path: PathBuf::from(command.get_program()),
649                        source,
650                    }),
651                    None,
652                    server,
653                ));
654            }
655        };
656        let result = loop {
657            if cancelled() {
658                break Err(command_error(io::Error::from(io::ErrorKind::Interrupted)));
659            }
660            match owned_child.try_wait() {
661                Ok(Some(status)) => break Ok(status),
662                Ok(None) => {
663                    if let Some(managed) = server.as_mut() {
664                        match managed.try_wait() {
665                            Ok(Some(status)) => break Err(managed.exit_failure(status)),
666                            Ok(None) => {}
667                            Err(error) => break Err(error),
668                        }
669                    }
670                    thread::sleep(STARTUP_POLL_INTERVAL);
671                }
672                Err(source) => break Err(command_error(source)),
673            }
674        };
675        match result {
676            Err(error) => Err(finalize_failure(error, Some(&mut owned_child), server)),
677            Ok(status) => {
678                if let Some(server) = server {
679                    let (output, cleanup) = server.terminate_and_capture();
680                    if let Some(cleanup) = cleanup {
681                        return Err(PocketIcStartupError::new(
682                            PocketIcStartupFailure::ServerCleanup {
683                                command_status: status,
684                            },
685                        )
686                        .with_cleanup(None, Some((output, Some(cleanup)))));
687                    }
688                }
689                Ok(status)
690            }
691        }
692    }
693
694    fn validate(&self) -> Result<(), PocketIcStartupError> {
695        if self.timeout().is_zero() {
696            return Err(PocketIcStartupError::new(
697                PocketIcStartupFailure::InvalidConfiguration {
698                    message: "PocketIC startup timeout must be greater than zero".to_owned(),
699                },
700            ));
701        }
702        if let Self::Spawn {
703            server_hard_ttl,
704            server_idle_ttl,
705            ..
706        } = self
707        {
708            for (ttl, message) in [
709                (
710                    server_hard_ttl,
711                    "PocketIC server hard TTL must be at least one second",
712                ),
713                (
714                    server_idle_ttl,
715                    "PocketIC server idle TTL must be at least one second",
716                ),
717            ] {
718                if ttl.is_some_and(|ttl| ttl.as_secs() == 0) {
719                    return Err(PocketIcStartupError::new(
720                        PocketIcStartupFailure::InvalidConfiguration {
721                            message: message.to_owned(),
722                        },
723                    ));
724                }
725            }
726        }
727        Ok(())
728    }
729}
730
731impl PocketIcManagedServer {
732    /// OS process ID of the owned server child, for caller-managed monitoring.
733    ///
734    /// This identifies the server process, not its descendants, and does not
735    /// establish that it is still running. The OS may reuse the ID after the
736    /// child exits and is reaped. Dropping this handle terminates and waits for
737    /// the child; retaining the ID does not retain server ownership.
738    #[must_use]
739    pub fn process_id(&self) -> u32 {
740        self.server.child.id()
741    }
742
743    /// Loopback URL published by the managed server.
744    #[must_use]
745    pub fn url(&self) -> &str {
746        &self.url
747    }
748
749    /// Current bounded stdout and stderr captured from the managed server.
750    ///
751    /// This reads at most the first 16 KiB from each retained output file,
752    /// renders it as lossy UTF-8, and adds an omitted-byte suffix when truncated.
753    /// The diagnostic read is bounded; files can grow while the server runs.
754    #[must_use]
755    pub fn output(&self) -> PocketIcManagedServerOutput {
756        self.server.files.capture()
757    }
758}
759
760impl PocketIcManagedServerOutput {
761    /// Bounded lossy UTF-8 standard output.
762    #[must_use]
763    pub fn stdout(&self) -> &str {
764        &self.stdout
765    }
766
767    /// Bounded lossy UTF-8 standard error.
768    #[must_use]
769    pub fn stderr(&self) -> &str {
770        &self.stderr
771    }
772}
773
774struct StartupDeadline {
775    started: Instant,
776    timeout: Duration,
777    deadline: Instant,
778}
779
780impl StartupDeadline {
781    fn new(config: &PocketIcStartupConfig) -> Result<Self, PocketIcStartupError> {
782        config.validate()?;
783        let started = Instant::now();
784        let timeout = config.timeout();
785        Ok(Self {
786            started,
787            timeout,
788            deadline: startup_deadline(started, timeout)?,
789        })
790    }
791
792    fn check_instance_deadline(
793        &self,
794        server: &mut Option<ManagedServer>,
795    ) -> Result<Instant, PocketIcStartupError> {
796        let now = Instant::now();
797        if now >= self.deadline {
798            return Err(finalize_failure(
799                PocketIcStartupError::new(PocketIcStartupFailure::InstanceCreationTimeout {
800                    timeout: self.timeout,
801                }),
802                None,
803                server.take(),
804            ));
805        }
806        Ok(now)
807    }
808
809    fn admit_instance<T>(
810        &self,
811        instance: T,
812        server_url: &str,
813        server: &mut Option<ManagedServer>,
814    ) -> Result<PocketIcInstance<T>, PocketIcStartupError> {
815        self.check_instance_deadline(server)?;
816        check_server(server)?;
817        Ok(PocketIcInstance {
818            instance,
819            server: server.take().map(|server| PocketIcManagedServer {
820                server,
821                url: server_url.to_owned(),
822            }),
823        })
824    }
825}
826
827fn startup_deadline(started: Instant, timeout: Duration) -> Result<Instant, PocketIcStartupError> {
828    started.checked_add(timeout).ok_or_else(|| {
829        PocketIcStartupError::new(PocketIcStartupFailure::InvalidConfiguration {
830            message: "PocketIC startup timeout exceeds the platform clock range".to_owned(),
831        })
832    })
833}
834
835fn build_bounded(
836    builder: PocketIcBuilder,
837    server_url: &str,
838    startup: &StartupDeadline,
839    mut server: Option<ManagedServer>,
840) -> Result<PocketIcInstance<PocketIc>, PocketIcStartupError> {
841    let builder = configured_builder(builder, server_url)
842        .map_err(|error| finalize_failure(error, None, server.take()))?;
843    let (sender, receiver) = mpsc::sync_channel(1);
844    if let Err(source) = thread::Builder::new()
845        .name("ic-testkit-pocket-ic-startup".to_owned())
846        .spawn(move || {
847            let result = transport::capture_operation(|| builder.build());
848            let _ = sender.send(result);
849        })
850    {
851        return Err(finalize_failure(
852            PocketIcStartupError::new(PocketIcStartupFailure::BuilderThreadSpawn { source }),
853            None,
854            server,
855        ));
856    }
857    await_builder(receiver, server_url, startup, &mut server)
858}
859
860#[expect(
861    clippy::needless_pass_by_value,
862    reason = "consume the receiver so failed startup disconnects late builders"
863)]
864fn await_builder(
865    receiver: mpsc::Receiver<Result<PocketIc, super::PocketIcOperationError>>,
866    server_url: &str,
867    startup: &StartupDeadline,
868    server: &mut Option<ManagedServer>,
869) -> Result<PocketIcInstance<PocketIc>, PocketIcStartupError> {
870    loop {
871        let now = startup.check_instance_deadline(server)?;
872        let remaining = startup.deadline.saturating_duration_since(now);
873        let wait = if server.is_some() {
874            remaining.min(STARTUP_POLL_INTERVAL)
875        } else {
876            remaining
877        };
878        match receiver.recv_timeout(wait) {
879            Ok(Ok(pocket_ic)) => {
880                return startup.admit_instance(pocket_ic, server_url, server);
881            }
882            Ok(Err(source)) => {
883                return Err(finalize_failure(
884                    PocketIcStartupError::new(PocketIcStartupFailure::BuilderPanicked { source }),
885                    None,
886                    server.take(),
887                ));
888            }
889            Err(RecvTimeoutError::Disconnected) => {
890                return Err(finalize_failure(
891                    PocketIcStartupError::new(PocketIcStartupFailure::BuilderDisconnected),
892                    None,
893                    server.take(),
894                ));
895            }
896            Err(RecvTimeoutError::Timeout) => {
897                check_server(server)?;
898            }
899        }
900    }
901}
902
903fn configured_builder(
904    builder: PocketIcBuilder,
905    server_url: &str,
906) -> Result<PocketIcBuilder, PocketIcStartupError> {
907    server_url
908        .parse()
909        .map(|url| builder.with_server_url(url))
910        .map_err(|error| {
911            PocketIcStartupError::new(PocketIcStartupFailure::InvalidServerUrl {
912                server_url: server_url.to_owned(),
913                message: error.to_string(),
914            })
915        })
916}
917
918fn check_server(server: &mut Option<ManagedServer>) -> Result<(), PocketIcStartupError> {
919    if let Some(managed) = server.as_mut() {
920        let error = match managed.try_wait() {
921            Ok(Some(status)) => Some(managed.exit_failure(status)),
922            Ok(None) => None,
923            Err(error) => Some(error),
924        };
925        if let Some(error) = error {
926            return Err(finalize_failure(error, None, server.take()));
927        }
928    }
929    Ok(())
930}
931
932async fn build_async_bounded(
933    builder: PocketIcBuilder,
934    server_url: &str,
935    startup: &StartupDeadline,
936    mut server: Option<ManagedServer>,
937) -> Result<PocketIcInstance<ic_platform::pocket_ic::nonblocking::PocketIc>, PocketIcStartupError> {
938    let builder = configured_builder(builder, server_url)
939        .map_err(|error| finalize_failure(error, None, server.take()))?;
940    let mut construction = std::pin::pin!(builder.build_async());
941    let mut construction = std::pin::pin!(std::future::poll_fn(|context| {
942        match transport::capture_operation(|| construction.as_mut().poll(context)) {
943            Ok(poll) => poll.map(Ok),
944            Err(source) => std::task::Poll::Ready(Err(PocketIcStartupError::new(
945                PocketIcStartupFailure::BuilderPanicked { source },
946            ))),
947        }
948    }));
949    // Drop this local before the pending upstream future on cancellation.
950    let mut server = server;
951    loop {
952        let now = startup.check_instance_deadline(&mut server)?;
953        check_server(&mut server)?;
954        let next_check = if server.is_some() {
955            now + startup
956                .deadline
957                .saturating_duration_since(now)
958                .min(STARTUP_POLL_INTERVAL)
959        } else {
960            startup.deadline
961        };
962        match tokio::time::timeout_at(next_check.into(), construction.as_mut()).await {
963            Ok(Ok(instance)) => {
964                return startup.admit_instance(instance, server_url, &mut server);
965            }
966            Ok(Err(error)) => return Err(finalize_failure(error, None, server)),
967            Err(_) => {}
968        }
969    }
970}
971
972/// Finalize an owned operation once, preserving the primary failure.
973fn finalize_failure(
974    error: PocketIcStartupError,
975    command: Option<&mut OwnedChild>,
976    server: Option<ManagedServer>,
977) -> PocketIcStartupError {
978    error.with_cleanup(
979        command.and_then(|child| child.terminate().err()),
980        server.map(ManagedServer::terminate_and_capture),
981    )
982}
983
984struct ManagedServer {
985    child: OwnedChild,
986    binary: PathBuf,
987    files: StartupFiles,
988    started: Instant,
989}
990
991enum PortFileState {
992    Pending,
993    Ready(u16),
994    Invalid(String),
995}
996
997impl ManagedServer {
998    fn wait_ready(
999        mut self,
1000        startup: &StartupDeadline,
1001    ) -> Result<(Self, String), PocketIcStartupError> {
1002        loop {
1003            match self.poll_ready(startup) {
1004                Ok(Some(url)) => return Ok((self, url)),
1005                Ok(None) => thread::sleep(
1006                    startup
1007                        .deadline
1008                        .saturating_duration_since(Instant::now())
1009                        .min(STARTUP_POLL_INTERVAL),
1010                ),
1011                Err(error) => return Err(finalize_failure(error, None, Some(self))),
1012            }
1013        }
1014    }
1015
1016    fn spawn(
1017        binary: PathBuf,
1018        hard_ttl: Option<Duration>,
1019        idle_ttl: Option<Duration>,
1020        output_files: Option<(PathBuf, PathBuf)>,
1021        started: Instant,
1022    ) -> Result<Self, PocketIcStartupError> {
1023        let (files, stdout, stderr) = StartupFiles::create(output_files)?;
1024        let mut command = Command::new(&binary);
1025        if let Some(hard_ttl) = hard_ttl {
1026            command
1027                .arg("--hard-ttl")
1028                .arg(hard_ttl.as_secs().to_string());
1029        }
1030        if let Some(idle_ttl) = idle_ttl {
1031            command.arg("--ttl").arg(idle_ttl.as_secs().to_string());
1032        }
1033        command
1034            .arg("--port-file")
1035            .arg(&files.port)
1036            .stdout(Stdio::from(stdout))
1037            .stderr(Stdio::from(stderr));
1038        let child =
1039            OwnedChild::spawn(&mut command, CleanupPolicy::KillAndWait).map_err(|source| {
1040                PocketIcStartupError::new(PocketIcStartupFailure::ServerSpawn {
1041                    server_binary: binary.clone(),
1042                    source,
1043                })
1044            })?;
1045        Ok(Self {
1046            child,
1047            binary,
1048            files,
1049            started,
1050        })
1051    }
1052
1053    fn poll_ready(
1054        &mut self,
1055        startup: &StartupDeadline,
1056    ) -> Result<Option<String>, PocketIcStartupError> {
1057        if let Some(status) = self.try_wait()? {
1058            return Err(self.exit_failure(status));
1059        }
1060        if Instant::now() >= startup.deadline {
1061            return Err(PocketIcStartupError::new(
1062                PocketIcStartupFailure::ReadinessTimeout {
1063                    server_binary: self.binary.clone(),
1064                    timeout: startup.timeout,
1065                },
1066            ));
1067        }
1068        match self.files.read_port()? {
1069            PortFileState::Pending => Ok(None),
1070            PortFileState::Ready(port) => Ok(Some(format!("http://127.0.0.1:{port}/"))),
1071            PortFileState::Invalid(value) => Err(PocketIcStartupError::new(
1072                PocketIcStartupFailure::InvalidServerPort {
1073                    server_binary: self.binary.clone(),
1074                    value,
1075                },
1076            )),
1077        }
1078    }
1079
1080    fn try_wait(&mut self) -> Result<Option<ExitStatus>, PocketIcStartupError> {
1081        self.child.try_wait().map_err(|source| {
1082            PocketIcStartupError::new(PocketIcStartupFailure::Io {
1083                operation: "inspect PocketIC server child",
1084                path: self.binary.clone(),
1085                source,
1086            })
1087        })
1088    }
1089
1090    fn exit_failure(&self, status: ExitStatus) -> PocketIcStartupError {
1091        PocketIcStartupError::new(PocketIcStartupFailure::ServerExited {
1092            server_binary: self.binary.clone(),
1093            status,
1094            elapsed: self.started.elapsed(),
1095        })
1096    }
1097
1098    fn terminate_and_capture(self) -> (PocketIcManagedServerOutput, Option<CleanupError>) {
1099        let Self {
1100            mut child, files, ..
1101        } = self;
1102        let cleanup = child.terminate().err();
1103        // Finish the child's Drop cleanup before reading the final diagnostics.
1104        drop(child);
1105        (files.capture(), cleanup)
1106    }
1107}
1108
1109struct StartupFiles {
1110    #[cfg_attr(
1111        not(test),
1112        expect(
1113            dead_code,
1114            reason = "retains directory ownership until cleanup or drop"
1115        )
1116    )]
1117    directory: OwnedDirectory,
1118    port: PathBuf,
1119    stdout: PathBuf,
1120    stderr: PathBuf,
1121}
1122
1123impl StartupFiles {
1124    fn read_port(&self) -> Result<PortFileState, PocketIcStartupError> {
1125        let port_path = &self.port;
1126        let mut contents = String::new();
1127        match ic_host_fs::read::open_regular_file(port_path)
1128            .map_err(|error| match error {
1129                ic_host_artifacts::artifact::ArtifactError::Io(source) => source,
1130                error => io::Error::new(io::ErrorKind::InvalidData, error),
1131            })
1132            .and_then(|file| {
1133                file.take((SERVER_PORT_FILE_LIMIT + 1) as u64)
1134                    .read_to_string(&mut contents)
1135            }) {
1136            Ok(_) => {}
1137            Err(error) if error.kind() == io::ErrorKind::NotFound => {
1138                return Ok(PortFileState::Pending);
1139            }
1140            Err(source) => {
1141                return Err(PocketIcStartupError::new(PocketIcStartupFailure::Io {
1142                    operation: "read PocketIC server port file",
1143                    path: port_path.clone(),
1144                    source,
1145                }));
1146            }
1147        }
1148        if contents.len() > SERVER_PORT_FILE_LIMIT {
1149            return Ok(PortFileState::Invalid(format!(
1150                "{} (port file exceeds {SERVER_PORT_FILE_LIMIT} bytes)",
1151                contents.trim()
1152            )));
1153        }
1154        if !contents.contains('\n') {
1155            return Ok(PortFileState::Pending);
1156        }
1157        let value = contents.trim().to_owned();
1158        match value.parse::<u16>() {
1159            Ok(port) if port != 0 => Ok(PortFileState::Ready(port)),
1160            _ => Ok(PortFileState::Invalid(value)),
1161        }
1162    }
1163
1164    fn capture(&self) -> PocketIcManagedServerOutput {
1165        PocketIcManagedServerOutput {
1166            stdout: read_bounded_lossy(&self.stdout),
1167            stderr: read_bounded_lossy(&self.stderr),
1168        }
1169    }
1170
1171    fn create(
1172        output_files: Option<(PathBuf, PathBuf)>,
1173    ) -> Result<(Self, File, File), PocketIcStartupError> {
1174        let output_files = output_files
1175            .map(|(stdout, stderr)| {
1176                Ok::<_, PocketIcStartupError>((
1177                    std::path::absolute(&stdout)
1178                        .map_err(|source| startup_file_error("resolve", &stdout, source))?,
1179                    std::path::absolute(&stderr)
1180                        .map_err(|source| startup_file_error("resolve", &stderr, source))?,
1181                ))
1182            })
1183            .transpose()?;
1184        let directory = OwnedDirectory::new(
1185            create_unique_directory(
1186                &std::env::temp_dir(),
1187                "ic-testkit-pocket-ic-startup",
1188                0o700,
1189                None,
1190            )
1191            .map_err(|source| startup_file_error("create", &std::env::temp_dir(), source))?,
1192        );
1193        let base = directory.path();
1194        let (stdout_path, stderr_path) =
1195            output_files.unwrap_or_else(|| (base.join("stdout"), base.join("stderr")));
1196        let files = Self {
1197            port: base.join("port"),
1198            stdout: stdout_path,
1199            stderr: stderr_path,
1200            directory,
1201        };
1202        let stdout = create_new_file(&files.stdout)
1203            .map_err(|source| startup_file_error("create", &files.stdout, source))?;
1204        let stderr = create_new_file(&files.stderr)
1205            .map_err(|source| startup_file_error("create", &files.stderr, source))?;
1206        Ok((files, stdout, stderr))
1207    }
1208}
1209
1210fn create_new_file(path: &Path) -> io::Result<File> {
1211    let mut options = OpenOptions::new();
1212    options.write(true).create_new(true);
1213    #[cfg(unix)]
1214    options.mode(0o600);
1215    options.open(path)
1216}
1217
1218fn startup_file_error(
1219    operation: &'static str,
1220    path: &Path,
1221    source: io::Error,
1222) -> PocketIcStartupError {
1223    PocketIcStartupError::new(PocketIcStartupFailure::Io {
1224        operation,
1225        path: path.to_owned(),
1226        source,
1227    })
1228}
1229
1230fn read_bounded_lossy(path: &Path) -> String {
1231    let Ok((bytes, length)) = ic_host_fs::read::read_file_prefix(path, SERVER_OUTPUT_LIMIT) else {
1232        return String::new();
1233    };
1234    let mut output = String::from_utf8_lossy(&bytes).into_owned();
1235    let omitted = length.saturating_sub(bytes.len() as u64);
1236    if omitted > 0 {
1237        let _ = write!(output, "\n<truncated {omitted} bytes>");
1238    }
1239    output
1240}
1241
1242impl std::fmt::Display for PocketIcStartupError {
1243    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1244        std::fmt::Display::fmt(self.failure.as_ref(), formatter)?;
1245        if !self.output.stdout.is_empty() {
1246            write!(formatter, "; server stdout: {}", self.output.stdout)?;
1247        }
1248        if !self.output.stderr.is_empty() {
1249            write!(formatter, "; server stderr: {}", self.output.stderr)?;
1250        }
1251        for (owner, cleanup) in [
1252            ("command", self.command_cleanup()),
1253            ("server", self.server_cleanup()),
1254        ] {
1255            if let Some(error) = cleanup {
1256                write!(formatter, "; {owner} cleanup also failed: {error}")?;
1257            }
1258        }
1259        Ok(())
1260    }
1261}
1262
1263impl std::error::Error for PocketIcStartupError {
1264    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1265        std::error::Error::source(self.failure.as_ref())
1266    }
1267}
1268
1269#[cfg(test)]
1270mod tests {
1271    mod instances;
1272
1273    use std::{
1274        fs,
1275        path::PathBuf,
1276        time::{Duration, Instant},
1277    };
1278
1279    use super::{
1280        OwnedDirectory, PocketIcStartupConfig, PocketIcStartupError, PocketIcStartupFailure,
1281        StartupFiles, create_unique_directory,
1282    };
1283    use ic_platform::pocket_ic::PocketIcBuilder;
1284
1285    #[cfg(unix)]
1286    use ic_host_process::testing::write_executable_script;
1287    #[cfg(unix)]
1288    use std::{
1289        io::Write as _,
1290        os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _},
1291        process::Command,
1292        sync::mpsc,
1293    };
1294
1295    #[test]
1296    fn completed_instances_must_still_meet_the_startup_deadline() {
1297        let started = Instant::now();
1298        let startup = super::StartupDeadline {
1299            started,
1300            timeout: Duration::from_secs(60),
1301            deadline: started + Duration::from_secs(60),
1302        };
1303        let mut server = None;
1304        let instance = startup
1305            .admit_instance(7_u8, "http://localhost/", &mut server)
1306            .unwrap();
1307        assert_eq!(*instance.instance(), 7);
1308        assert!(server.is_none());
1309        let expired = super::StartupDeadline {
1310            deadline: started,
1311            ..startup
1312        };
1313        let error = expired
1314            .admit_instance(7_u8, "http://localhost/", &mut server)
1315            .err()
1316            .expect("completed instance after the deadline must refuse admission");
1317        assert!(
1318            matches!(error.failure(), PocketIcStartupFailure::InstanceCreationTimeout { timeout } if *timeout == Duration::from_secs(60))
1319        );
1320        assert!(server.is_none());
1321    }
1322
1323    #[test]
1324    fn original_failure_output_and_both_typed_cleanup_reports_survive_projection() {
1325        use std::error::Error as _;
1326        for failure in [
1327            PocketIcStartupFailure::ReadinessTimeout {
1328                server_binary: "server".into(),
1329                timeout: Duration::from_secs(1),
1330            },
1331            PocketIcStartupFailure::InstanceCreationTimeout {
1332                timeout: Duration::from_secs(1),
1333            },
1334            PocketIcStartupFailure::InvalidServerPort {
1335                server_binary: "server".into(),
1336                value: "invalid".into(),
1337            },
1338            PocketIcStartupFailure::BuilderPanicked {
1339                source: super::super::PocketIcOperationError::new("original-panic"),
1340            },
1341            PocketIcStartupFailure::BuilderDisconnected,
1342            PocketIcStartupFailure::BuilderThreadSpawn {
1343                source: std::io::Error::from_raw_os_error(9),
1344            },
1345            PocketIcStartupFailure::Io {
1346                operation: "original-io",
1347                path: "port".into(),
1348                source: std::io::Error::from_raw_os_error(9),
1349            },
1350        ] {
1351            let primary_text = failure.to_string();
1352            let output = super::PocketIcManagedServerOutput {
1353                stdout: "original-stdout".into(),
1354                stderr: "original-stderr".into(),
1355            };
1356            let command = super::CleanupError {
1357                status: None,
1358                group_error: None,
1359                term_error: Some(std::io::Error::from_raw_os_error(4)),
1360                kill_error: Some(std::io::Error::from_raw_os_error(5)),
1361                wait_error: None,
1362            };
1363            let server = super::CleanupError {
1364                status: None,
1365                group_error: Some(std::io::Error::from_raw_os_error(1)),
1366                term_error: None,
1367                kill_error: None,
1368                wait_error: Some(std::io::Error::from_raw_os_error(10)),
1369            };
1370            let error = PocketIcStartupError::new(failure)
1371                .with_cleanup(Some(command), Some((output, Some(server))));
1372            assert_eq!(error.failure().to_string(), primary_text);
1373            assert_eq!(error.output().stdout(), "original-stdout");
1374            assert_eq!(error.output().stderr(), "original-stderr");
1375            let command = error.command_cleanup().unwrap();
1376            let server = error.server_cleanup().unwrap();
1377            assert_eq!(command.kill_error.as_ref().unwrap().raw_os_error(), Some(5));
1378            assert_eq!(command.term_error.as_ref().unwrap().raw_os_error(), Some(4));
1379            assert_eq!(server.group_error.as_ref().unwrap().raw_os_error(), Some(1));
1380            assert_eq!(server.wait_error.as_ref().unwrap().raw_os_error(), Some(10));
1381            if matches!(
1382                error.failure(),
1383                PocketIcStartupFailure::Io { .. }
1384                    | PocketIcStartupFailure::BuilderThreadSpawn { .. }
1385            ) {
1386                assert_eq!(
1387                    error
1388                        .source()
1389                        .unwrap()
1390                        .downcast_ref::<std::io::Error>()
1391                        .unwrap()
1392                        .raw_os_error(),
1393                    Some(9)
1394                );
1395            }
1396            if let PocketIcStartupFailure::BuilderPanicked { source } = error.failure() {
1397                assert_eq!(
1398                    error
1399                        .source()
1400                        .unwrap()
1401                        .downcast_ref::<super::super::PocketIcOperationError>(),
1402                    Some(source)
1403                );
1404            }
1405            let displayed = error.to_string();
1406            assert!(displayed.starts_with(&primary_text));
1407            for marker in [
1408                "original-stdout",
1409                "original-stderr",
1410                "command cleanup also failed",
1411                "server cleanup also failed",
1412            ] {
1413                assert!(displayed.contains(marker));
1414            }
1415        }
1416        let error = PocketIcStartupError::new(PocketIcStartupFailure::NotConfigured);
1417        assert_eq!(error.to_string(), error.failure().to_string());
1418        assert!(error.command_cleanup().is_none());
1419        assert!(error.server_cleanup().is_none());
1420    }
1421
1422    #[cfg(unix)]
1423    #[test]
1424    fn builder_failures_finalize_the_owned_server_and_keep_output() {
1425        for disconnected in [false, true] {
1426            let script = TestServerScript::new(
1427                "builder-failure",
1428                "#!/bin/sh\nprintf '%s\n%s\n' \"$$\" \"$2\"\nprintf 'builder-diagnostic' >&2\nprintf '34567\n' > \"$2\"\nexec sleep 30\n",
1429            );
1430            let managed = PocketIcStartupConfig::Spawn {
1431                server_binary: script.path(),
1432                timeout: Duration::from_secs(2),
1433                server_hard_ttl: None,
1434                server_idle_ttl: None,
1435                server_output_files: None,
1436            }
1437            .start_managed_server()
1438            .unwrap();
1439            let pid = managed.process_id();
1440            let directory = managed.server.files.directory.path().to_path_buf();
1441            let (sender, receiver) = mpsc::channel();
1442            if !disconnected {
1443                sender
1444                    .send(Err(super::super::PocketIcOperationError::new(
1445                        "original-builder-panic",
1446                    )))
1447                    .unwrap();
1448            }
1449            drop(sender);
1450            let error = super::await_builder(
1451                receiver,
1452                "http://127.0.0.1:34567/",
1453                &super::StartupDeadline {
1454                    started: Instant::now(),
1455                    deadline: Instant::now() + Duration::from_secs(2),
1456                    timeout: Duration::from_secs(2),
1457                },
1458                &mut Some(managed.server),
1459            )
1460            .err()
1461            .unwrap();
1462            assert!(matches!(
1463                (disconnected, error.failure()),
1464                (true, PocketIcStartupFailure::BuilderDisconnected)
1465                    | (false, PocketIcStartupFailure::BuilderPanicked { .. })
1466            ));
1467            assert_eq!(error.output().stderr(), "builder-diagnostic");
1468            assert!(error.server_cleanup().is_none());
1469            assert!(!directory.exists());
1470            assert_eq!(process_state(pid), None);
1471        }
1472    }
1473
1474    #[cfg(unix)]
1475    #[test]
1476    fn port_io_and_command_spawn_failures_finalize_the_owned_server() {
1477        for port_io in [true, false] {
1478            let port = if port_io {
1479                "mkfifo \"$2\""
1480            } else {
1481                "printf '34567\n' > \"$2\""
1482            };
1483            let script = TestServerScript::new(
1484                "owned-io-failure",
1485                &format!(
1486                    "#!/bin/sh\nprintf '%s\n%s\n' \"$$\" \"$2\"\nprintf 'original-server-diagnostic' >&2\n{port}\nexec sleep 30\n"
1487                ),
1488            );
1489            let config = PocketIcStartupConfig::Spawn {
1490                server_binary: script.path(),
1491                timeout: Duration::from_secs(2),
1492                server_hard_ttl: None,
1493                server_idle_ttl: None,
1494                server_output_files: None,
1495            };
1496            let error = if port_io {
1497                config.start_managed_server().err().unwrap()
1498            } else {
1499                config
1500                    .run_command(&mut Command::new("/missing/ic-testkit-command"), || false)
1501                    .unwrap_err()
1502            };
1503            let PocketIcStartupFailure::Io { source, .. } = error.failure() else {
1504                panic!("expected original IO failure: {error:?}");
1505            };
1506            assert_eq!(
1507                source.kind(),
1508                if port_io {
1509                    std::io::ErrorKind::InvalidData
1510                } else {
1511                    std::io::ErrorKind::NotFound
1512                }
1513            );
1514            assert_eq!(error.output().stderr(), "original-server-diagnostic");
1515            let mut lines = error.output().stdout().lines();
1516            let pid = lines.next().unwrap().parse().unwrap();
1517            let port = PathBuf::from(lines.next().unwrap());
1518            assert_eq!(process_state(pid), None);
1519            assert!(!port.parent().unwrap().exists());
1520            assert!(error.server_cleanup().is_none());
1521        }
1522    }
1523
1524    #[cfg(unix)]
1525    #[test]
1526    fn command_cancellation_before_startup_has_no_spawn_effects() {
1527        let error = PocketIcStartupConfig::Spawn {
1528            server_binary: ("/missing/server").into(),
1529            timeout: Duration::from_secs(1),
1530            server_hard_ttl: None,
1531            server_idle_ttl: None,
1532            server_output_files: None,
1533        }
1534        .run_command(&mut Command::new("/missing/command"), || true)
1535        .unwrap_err();
1536        assert!(
1537            matches!(error.failure(), PocketIcStartupFailure::CommandRun { source, .. } if source.kind() == std::io::ErrorKind::Interrupted)
1538        );
1539        assert_eq!(error.output().stdout(), "");
1540        assert_eq!(error.output().stderr(), "");
1541        assert!(error.command_cleanup().is_none());
1542        assert!(error.server_cleanup().is_none());
1543    }
1544
1545    #[cfg(unix)]
1546    #[test]
1547    fn cancellation_callback_panic_still_reaps_the_owned_command() {
1548        let script = TestServerScript::new(
1549            "cancel-panic",
1550            "#!/bin/sh\nprintf '%s' \"$$\" > \"$1\"\nexec sleep 30\n",
1551        );
1552        let pid_file = script.path().with_extension("pid");
1553        let result = std::panic::catch_unwind(|| {
1554            PocketIcStartupConfig::Connect {
1555                server_url: ("http://127.0.0.1:12345/").into(),
1556                timeout: Duration::from_secs(1),
1557            }
1558            .run_command(Command::new(script.path()).arg(&pid_file), || {
1559                assert!(
1560                    !fs::read_to_string(&pid_file).is_ok_and(|value| !value.is_empty()),
1561                    "caller cancellation failed"
1562                );
1563                false
1564            })
1565        });
1566        assert!(result.is_err());
1567        let pid = fs::read_to_string(&pid_file).unwrap().parse().unwrap();
1568        assert!(process_state(pid).is_none_or(|state| state == 'Z'));
1569        fs::remove_file(pid_file).unwrap();
1570    }
1571
1572    #[cfg(unix)]
1573    #[test]
1574    fn environment_selection_prefers_urls_and_fails_closed() {
1575        use std::os::unix::ffi::OsStringExt as _;
1576
1577        let timeout = Duration::from_secs(1);
1578        let config = PocketIcStartupConfig::from_environment(timeout, |name| {
1579            Some(
1580                if name == "IC_TESTKIT_POCKET_IC_URL" {
1581                    "http://127.0.0.1:12345/"
1582                } else {
1583                    "/missing/server"
1584                }
1585                .into(),
1586            )
1587        })
1588        .unwrap();
1589        assert!(
1590            matches!(config, PocketIcStartupConfig::Connect { ref server_url, .. } if server_url == "http://127.0.0.1:12345/")
1591        );
1592        assert!(matches!(
1593            PocketIcStartupConfig::from_environment(timeout, |_| None)
1594                .as_ref()
1595                .map_err(PocketIcStartupError::failure),
1596            Err(PocketIcStartupFailure::NotConfigured)
1597        ));
1598        assert!(matches!(
1599            PocketIcStartupConfig::from_environment(timeout, |_| Some("".into()))
1600                .as_ref()
1601                .map_err(PocketIcStartupError::failure),
1602            Err(PocketIcStartupFailure::InvalidEnvironment {
1603                variable: "IC_TESTKIT_POCKET_IC_URL"
1604            })
1605        ));
1606        assert!(matches!(
1607            PocketIcStartupConfig::from_environment(timeout, |_| Some("bad URL".into()))
1608                .as_ref()
1609                .map_err(PocketIcStartupError::failure),
1610            Err(PocketIcStartupFailure::InvalidServerUrl { .. })
1611        ));
1612        assert!(matches!(
1613            PocketIcStartupConfig::from_environment(timeout, |_| Some(
1614                std::ffi::OsString::from_vec(vec![0xff])
1615            ))
1616            .as_ref()
1617            .map_err(PocketIcStartupError::failure),
1618            Err(PocketIcStartupFailure::InvalidEnvironment { .. })
1619        ));
1620    }
1621
1622    #[cfg(unix)]
1623    #[test]
1624    fn version_capture_cleans_wrapper_descendants_on_exit_and_timeout() {
1625        for timeout in [false, true] {
1626            let qualified = format!(
1627                "printf 'pocket-ic-server {}\\n'",
1628                crate::pic::POCKET_IC_SERVER_VERSION
1629            );
1630            let ending = if timeout { "wait" } else { &qualified };
1631            let script = TestServerScript::new(
1632                "version-group",
1633                &format!(
1634                    "#!/bin/sh\nsleep 30 >/dev/null 2>&1 &\nprintf '%s' \"$!\" > \"$0.pid\"\n{ending}\n"
1635                ),
1636            );
1637            let result = PocketIcStartupConfig::from_environment(Duration::from_secs(1), |name| {
1638                (name == "POCKET_IC_BIN").then(|| script.path().into_os_string())
1639            });
1640            if timeout {
1641                assert!(matches!(
1642                    result.as_ref().map_err(PocketIcStartupError::failure),
1643                    Err(PocketIcStartupFailure::ServerVersionProbe { .. })
1644                ));
1645            } else {
1646                assert!(result.is_ok());
1647            }
1648            let pid_file = script.path().with_extension("pid");
1649            let pid = fs::read_to_string(&pid_file).unwrap().parse().unwrap();
1650            let deadline = Instant::now() + Duration::from_secs(3);
1651            while process_state(pid).is_some_and(|state| state != 'Z') {
1652                assert!(
1653                    Instant::now() < deadline,
1654                    "version wrapper descendant remained alive"
1655                );
1656                std::thread::sleep(Duration::from_millis(20));
1657            }
1658            fs::remove_file(pid_file).unwrap();
1659        }
1660    }
1661
1662    #[cfg(unix)]
1663    #[test]
1664    fn owned_builder_panic_retains_server_output_and_reaps_child() {
1665        let script = TestServerScript::new(
1666            "builder-panic",
1667            "#!/bin/sh\nprintf '%s' \"$$\" > \"$0.pid\"\nprintf 'builder stdout'\nprintf 'builder stderr' >&2\nprintf '34567\\n' > \"$2\"\nexec sleep 30\n",
1668        );
1669        let error = PocketIcStartupConfig::Spawn {
1670            server_binary: script.path(),
1671            timeout: Duration::from_secs(2),
1672            server_hard_ttl: None,
1673            server_idle_ttl: None,
1674            server_output_files: None,
1675        }
1676        .build_instance(PocketIcBuilder::new())
1677        .err()
1678        .unwrap();
1679        let PocketIcStartupFailure::BuilderPanicked { source } = error.failure() else {
1680            panic!("expected builder failure, got {error:?}");
1681        };
1682        assert_ne!(source.message(), "");
1683        assert_eq!(
1684            std::error::Error::source(&error)
1685                .unwrap()
1686                .downcast_ref::<super::super::PocketIcOperationError>(),
1687            Some(source)
1688        );
1689        assert_eq!(error.output().stdout(), "builder stdout");
1690        assert_eq!(error.output().stderr(), "builder stderr");
1691        assert!(error.server_cleanup().is_none());
1692        let pid_file = script.path().with_extension("pid");
1693        let pid = fs::read_to_string(&pid_file).unwrap().parse().unwrap();
1694        assert_eq!(process_state(pid), None);
1695        fs::remove_file(pid_file).unwrap();
1696    }
1697
1698    #[cfg(unix)]
1699    #[test]
1700    fn environment_binary_selection_uses_bounded_shared_version_capture() {
1701        let qualified = format!(
1702            "printf 'pocket-ic-server {}\\n'",
1703            crate::pic::POCKET_IC_SERVER_VERSION
1704        );
1705        let failed = format!("{qualified}; exit 23");
1706        for (label, body, expected) in [
1707            ("qualified", qualified.as_str(), 0),
1708            ("compatible-16.0", "printf 'pocket-ic-server 16.0.0\\n'", 0),
1709            ("compatible-patch", "printf 'pocket-ic-server 16.0.1\\n'", 0),
1710            ("wrong-version", "printf 'pocket-ic-server 15.0.0\\n'", 1),
1711            ("future-major", "printf 'pocket-ic-server 17.0.0\\n'", 1),
1712            ("malformed-version", "printf 'pocket-ic-server 16.1\\n'", 1),
1713            ("failed-version", failed.as_str(), 2),
1714            ("invalid-utf8", "printf '\\377'", 1),
1715            ("version-timeout", "exec sleep 30", 2),
1716        ] {
1717            let script = TestServerScript::new(
1718                label,
1719                &format!("#!/bin/sh\n[ \"$1\" = --version ] || exit 99\n{body}\n"),
1720            );
1721            // A successful process launch needs scheduling headroom on loaded CI
1722            // hosts; only the deliberately stalled probe needs a short deadline.
1723            let timeout = if label == "version-timeout" {
1724                Duration::from_millis(200)
1725            } else {
1726                Duration::from_secs(5)
1727            };
1728            let result = PocketIcStartupConfig::from_environment(timeout, |name| {
1729                (name == "POCKET_IC_BIN").then(|| script.path().into_os_string())
1730            });
1731            match (
1732                expected,
1733                result.as_ref().map_err(PocketIcStartupError::failure),
1734            ) {
1735                (0, Ok(config)) => {
1736                    let binary = script.path().canonicalize().unwrap();
1737                    assert!(
1738                        matches!(config, PocketIcStartupConfig::Spawn { server_binary, .. } if server_binary == &binary)
1739                    );
1740                }
1741                (1, Err(PocketIcStartupFailure::ServerVersionMismatch { .. }))
1742                | (2, Err(PocketIcStartupFailure::ServerVersionProbe { .. })) => {}
1743                (_, result) => panic!("unexpected {label} result: {result:?}"),
1744            }
1745        }
1746    }
1747
1748    #[cfg(unix)]
1749    fn process_state(pid: u32) -> Option<char> {
1750        // Both supported Unix hosts provide this ps field. A zombie has stopped
1751        // running but may remain visible until its parent reaps it.
1752        let output = Command::new("/bin/ps")
1753            .args(["-p", &pid.to_string(), "-o", "stat="])
1754            .output()
1755            .expect("inspect managed test process state");
1756        assert!(
1757            output.status.success()
1758                || (output.status.code() == Some(1)
1759                    && output.stdout.is_empty()
1760                    && output.stderr.is_empty()),
1761            "process-state inspection failed: {}: {}",
1762            output.status,
1763            String::from_utf8_lossy(&output.stderr),
1764        );
1765        String::from_utf8(output.stdout)
1766            .expect("process state is ASCII")
1767            .trim()
1768            .chars()
1769            .next()
1770    }
1771
1772    #[cfg(unix)]
1773    #[test]
1774    fn reading_large_sparse_server_output_is_bounded() {
1775        let (files, _, _) = StartupFiles::create(None).expect("allocate startup files");
1776        let mut file = fs::File::create(&files.stdout).expect("create sparse log");
1777        file.write_all(b"server started\n").expect("write prefix");
1778        let size = 8_u64 * 1024 * 1024 * 1024;
1779        file.set_len(size).expect("extend sparse log");
1780        let output = super::read_bounded_lossy(&files.stdout);
1781        assert!(output.starts_with("server started\n"));
1782        assert!(output.ends_with(&format!(
1783            "<truncated {} bytes>",
1784            size - super::SERVER_OUTPUT_LIMIT as u64
1785        )));
1786        assert!(output.len() < super::SERVER_OUTPUT_LIMIT + 100);
1787    }
1788
1789    #[cfg(unix)]
1790    #[test]
1791    fn startup_readers_reject_fifos_without_waiting_for_a_writer() {
1792        let (files, stdout, stderr) = StartupFiles::create(None).expect("allocate startup files");
1793        drop((stdout, stderr));
1794        fs::remove_file(&files.stdout).unwrap();
1795        fs::remove_file(&files.stderr).unwrap();
1796        assert!(
1797            Command::new("mkfifo")
1798                .args([&files.port, &files.stdout, &files.stderr])
1799                .status()
1800                .expect("create FIFO startup files")
1801                .success()
1802        );
1803        for path in [&files.port, &files.stdout, &files.stderr] {
1804            // A delayed writer bounds a blocked read and records whether the
1805            // reader needed it. Completion cancels the writer; with no reader,
1806            // a nonblocking open fails and is retried if the reader starts late.
1807            let fifo = path.clone();
1808            let (stop_writer, stopped) = mpsc::channel();
1809            let writer = std::thread::spawn(move || {
1810                loop {
1811                    match stopped.recv_timeout(Duration::from_millis(200)) {
1812                        Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return false,
1813                        Err(mpsc::RecvTimeoutError::Timeout) => {}
1814                    }
1815                    if fs::OpenOptions::new()
1816                        .write(true)
1817                        .custom_flags(libc::O_NONBLOCK)
1818                        .open(&fifo)
1819                        .is_ok()
1820                    {
1821                        return true;
1822                    }
1823                }
1824            });
1825            let result = if path == &files.port {
1826                Some(files.read_port())
1827            } else {
1828                assert_eq!(super::read_bounded_lossy(path), "");
1829                None
1830            };
1831            let _ = stop_writer.send(());
1832            assert!(
1833                !writer.join().expect("join delayed FIFO writer"),
1834                "startup reader waited for a writer: {}",
1835                path.display(),
1836            );
1837            if let Some(result) = result {
1838                assert!(matches!(
1839                    result.as_ref().map_err(PocketIcStartupError::failure),
1840                    Err(PocketIcStartupFailure::Io { source, .. })
1841                        if source.kind() == std::io::ErrorKind::InvalidData
1842                ));
1843            }
1844        }
1845    }
1846
1847    #[test]
1848    fn port_file_readiness_preserves_partial_writes_and_rejects_oversized_contents() {
1849        let (files, _, _) = StartupFiles::create(None).expect("allocate startup files");
1850        assert!(matches!(
1851            files.read_port().unwrap(),
1852            super::PortFileState::Pending
1853        ));
1854        for contents in ["", "34567"] {
1855            fs::write(&files.port, contents).unwrap();
1856            assert!(matches!(
1857                files.read_port().unwrap(),
1858                super::PortFileState::Pending
1859            ));
1860        }
1861        for (contents, expected) in [("1\n", 1), ("65535\n", 65535), (" 34567\r\n", 34567)] {
1862            fs::write(&files.port, contents).unwrap();
1863            assert!(matches!(
1864                files.read_port().unwrap(),
1865                super::PortFileState::Ready(port) if port == expected
1866            ));
1867        }
1868        for contents in ["0\n", "65536\n", "invalid\n", "1\n2\n"] {
1869            fs::write(&files.port, contents).unwrap();
1870            assert!(matches!(
1871                files.read_port().unwrap(),
1872                super::PortFileState::Invalid(_)
1873            ));
1874        }
1875        fs::write(&files.port, [0xff, b'\n']).unwrap();
1876        assert!(matches!(
1877            files.read_port().as_ref().map_err(PocketIcStartupError::failure),
1878            Err(PocketIcStartupFailure::Io { source, .. })
1879                if source.kind() == std::io::ErrorKind::InvalidData
1880        ));
1881        for contents in ["1\n".to_owned() + &" ".repeat(128), "0".repeat(128)] {
1882            fs::write(&files.port, contents).unwrap();
1883            assert!(
1884                matches!(files.read_port().unwrap(), super::PortFileState::Invalid(_)),
1885                "oversized port contents must fail even without a newline",
1886            );
1887        }
1888        // A large backing file must not enlarge the returned diagnostic.
1889        fs::File::options()
1890            .write(true)
1891            .open(&files.port)
1892            .unwrap()
1893            .set_len(1024 * 1024)
1894            .unwrap();
1895        assert!(matches!(
1896            files.read_port().unwrap(),
1897            super::PortFileState::Invalid(value) if value.len() < 256
1898        ));
1899    }
1900
1901    #[test]
1902    fn startup_config_requires_positive_bounds() {
1903        let config = PocketIcStartupConfig::Spawn {
1904            server_binary: "pocket-ic".into(),
1905            timeout: Duration::from_secs(1),
1906            server_hard_ttl: None,
1907            server_idle_ttl: Some(Duration::from_millis(1)),
1908            server_output_files: None,
1909        };
1910        assert!(matches!(
1911            config.validate().unwrap_err().failure(),
1912            PocketIcStartupFailure::InvalidConfiguration { .. }
1913        ));
1914        let error = PocketIcStartupConfig::Connect {
1915            server_url: ("http://127.0.0.1:1/").into(),
1916            timeout: Duration::ZERO,
1917        }
1918        .validate()
1919        .expect_err("zero startup timeout must fail");
1920        assert!(matches!(
1921            error.failure(),
1922            PocketIcStartupFailure::InvalidConfiguration { .. }
1923        ));
1924
1925        let error = PocketIcStartupConfig::Spawn {
1926            server_binary: ("pocket-ic").into(),
1927            timeout: Duration::from_secs(1),
1928            server_hard_ttl: Some(Duration::from_millis(1)),
1929            server_idle_ttl: None,
1930            server_output_files: None,
1931        }
1932        .validate()
1933        .expect_err("subsecond server hard TTL must fail");
1934        assert!(matches!(
1935            error.failure(),
1936            PocketIcStartupFailure::InvalidConfiguration { .. }
1937        ));
1938    }
1939
1940    #[test]
1941    fn startup_files_leave_the_server_owned_port_path_absent() {
1942        let (files, stdout, stderr) = StartupFiles::create(None).expect("allocate startup files");
1943        let directory = files.directory.path().to_path_buf();
1944
1945        assert!(directory.is_dir());
1946        assert!(!files.port.exists());
1947        assert!(files.stdout.is_file());
1948        assert!(files.stderr.is_file());
1949        #[cfg(unix)]
1950        {
1951            let mode = fs::metadata(&directory)
1952                .expect("inspect private startup directory")
1953                .permissions()
1954                .mode();
1955            assert_eq!(mode & 0o077, 0);
1956        }
1957
1958        drop(stdout);
1959        drop(stderr);
1960        drop(files);
1961        assert!(!directory.exists());
1962    }
1963
1964    #[cfg(unix)]
1965    #[test]
1966    fn caller_output_files_survive_startup_command_and_cancellation_cleanup() {
1967        for outcome in [
1968            "timeout",
1969            "startup-exit",
1970            "server-exit",
1971            "command-exit",
1972            "cancel",
1973            "success",
1974        ] {
1975            let (owner, _, _) = StartupFiles::create(None).unwrap();
1976            let stdout = owner.directory.path().join("retained-stdout");
1977            let stderr = owner.directory.path().join("retained-stderr");
1978            let ending = match outcome {
1979                "timeout" => "exec sleep 30",
1980                "startup-exit" => "exit 41",
1981                "server-exit" => {
1982                    "printf '34567\\n' > \"$2\"; while [ ! -s \"$0.command\" ]; do sleep 0.02; done; exit 42"
1983                }
1984                _ => "printf '34567\\n' > \"$2\"; exec sleep 30",
1985            };
1986            let script = TestServerScript::new(
1987                outcome,
1988                &format!(
1989                    "#!/bin/sh\nprintf '%s\\n%s\\n' \"$$\" \"$2\" > \"$0.pid\"\ndd if=/dev/zero bs=1024 count=20 2>/dev/null\nprintf raw-stdout-end\ndd if=/dev/zero bs=1024 count=20 >&2 2>/dev/null\nprintf raw-stderr-end >&2\n{ending}\n"
1990                ),
1991            );
1992            let pid_file = script.path().with_extension("pid");
1993            let command_file = script.path().with_extension("command");
1994            let config = PocketIcStartupConfig::Spawn {
1995                server_binary: script.path(),
1996                timeout: Duration::from_millis(if outcome == "timeout" { 1000 } else { 2000 }),
1997                server_hard_ttl: None,
1998                server_idle_ttl: None,
1999                server_output_files: Some(((&stdout).into(), (&stderr).into())),
2000            };
2001            if outcome == "timeout" || outcome == "startup-exit" {
2002                let error = config.start_managed_server().err().unwrap();
2003                match (outcome, error.failure()) {
2004                    ("timeout", PocketIcStartupFailure::ReadinessTimeout { .. }) => {
2005                        assert!(error.output().stdout().contains("truncated"));
2006                        assert!(!error.output().stderr().contains("raw-stderr-end"));
2007                    }
2008                    ("startup-exit", PocketIcStartupFailure::ServerExited { status, .. }) => {
2009                        assert_eq!(status.code(), Some(41));
2010                    }
2011                    (_, error) => panic!("unexpected startup result: {error:?}"),
2012                }
2013            } else {
2014                let command_end = match outcome {
2015                    "command-exit" => "exit 37",
2016                    "success" => "exit 0",
2017                    _ => "exec sleep 30",
2018                };
2019                let result = config.run_command(
2020                    Command::new("/bin/sh")
2021                        .args([
2022                            "-c",
2023                            &format!("printf '%s' \"$$\" > \"$1\"; {command_end}"),
2024                            "fixture",
2025                        ])
2026                        .arg(&command_file),
2027                    || {
2028                        outcome == "cancel"
2029                            && fs::metadata(&command_file).is_ok_and(|m| m.len() > 0)
2030                    },
2031                );
2032                let result = result.as_ref().map_err(PocketIcStartupError::failure);
2033                match (outcome, result) {
2034                    ("command-exit", Ok(status)) => assert_eq!(status.code(), Some(37)),
2035                    ("success", Ok(status)) => assert!(status.success()),
2036                    ("server-exit", Err(PocketIcStartupFailure::ServerExited { status, .. })) => {
2037                        assert_eq!(status.code(), Some(42));
2038                    }
2039                    ("cancel", Err(PocketIcStartupFailure::CommandRun { source, .. })) => {
2040                        assert_eq!(source.kind(), std::io::ErrorKind::Interrupted);
2041                    }
2042                    (_, result) => panic!("unexpected command result: {result:?}"),
2043                }
2044                let pid = fs::read_to_string(&command_file).unwrap().parse().unwrap();
2045                assert!(process_state(pid).is_none_or(|state| state == 'Z'));
2046            }
2047            for (path, suffix) in [(&stdout, b"raw-stdout-end"), (&stderr, b"raw-stderr-end")] {
2048                let bytes = fs::read(path).unwrap();
2049                assert_eq!(bytes.len(), 20 * 1024 + suffix.len());
2050                assert!(bytes.ends_with(suffix));
2051                assert_eq!(
2052                    fs::metadata(path).unwrap().permissions().mode() & 0o777,
2053                    0o600
2054                );
2055            }
2056            let report = fs::read_to_string(&pid_file).unwrap();
2057            let mut lines = report.lines();
2058            assert_eq!(process_state(lines.next().unwrap().parse().unwrap()), None);
2059            assert!(
2060                !PathBuf::from(lines.next().unwrap())
2061                    .parent()
2062                    .unwrap()
2063                    .exists()
2064            );
2065            fs::remove_file(pid_file).unwrap();
2066        }
2067    }
2068
2069    #[cfg(unix)]
2070    #[test]
2071    fn caller_output_files_refuse_existing_entries_and_keep_partial_preparation() {
2072        use std::os::unix::fs::symlink;
2073
2074        let (owner, _, _) = StartupFiles::create(None).unwrap();
2075        let stdout = owner.directory.path().join("retained-stdout");
2076        let stderr = owner.directory.path().join("retained-stderr");
2077        let unrelated = owner.directory.path().join("unrelated");
2078        fs::write(&unrelated, b"original").unwrap();
2079        symlink(&unrelated, &stderr).unwrap();
2080        let error = StartupFiles::create(Some((stdout.clone(), stderr.clone())))
2081            .err()
2082            .unwrap();
2083        assert!(
2084            matches!(error.failure(), PocketIcStartupFailure::Io { source, .. } if source.kind() == std::io::ErrorKind::AlreadyExists)
2085        );
2086        assert!(stdout.is_file());
2087        assert_eq!(fs::read(&unrelated).unwrap(), b"original");
2088        fs::write(&stdout, b"retained attempt").unwrap();
2089        assert!(StartupFiles::create(Some((stdout.clone(), stderr.clone()))).is_err());
2090        assert_eq!(fs::read(&stdout).unwrap(), b"retained attempt");
2091        fs::remove_file(stderr.clone()).unwrap();
2092        assert!(
2093            Command::new("mkfifo")
2094                .arg(&stderr)
2095                .status()
2096                .unwrap()
2097                .success()
2098        );
2099        fs::remove_file(stdout.clone()).unwrap();
2100        assert!(StartupFiles::create(Some((stdout.clone(), stderr))).is_err());
2101        assert!(stdout.is_file());
2102    }
2103
2104    #[cfg(unix)]
2105    #[test]
2106    fn managed_startup_reports_an_exited_server_with_bounded_output() {
2107        let script = TestServerScript::new(
2108            "exit",
2109            "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nprintf 'synthetic server stdout'\nprintf 'synthetic bind failure' >&2\nexit 23\n",
2110        );
2111
2112        let result = PocketIcStartupConfig::Spawn {
2113            server_binary: script.path(),
2114            timeout: Duration::from_secs(2),
2115            server_hard_ttl: None,
2116            server_idle_ttl: None,
2117            server_output_files: None,
2118        }
2119        .build_instance(PocketIcBuilder::new().with_application_subnet());
2120
2121        let error = result.err().unwrap();
2122        let PocketIcStartupFailure::ServerExited {
2123            server_binary,
2124            status,
2125            ..
2126        } = error.failure()
2127        else {
2128            panic!("expected a structured server exit, got {error:?}");
2129        };
2130        assert_eq!(server_binary, &script.path());
2131        assert_eq!(status.code(), Some(23));
2132        assert_eq!(error.output().stdout(), "synthetic server stdout");
2133        assert_eq!(error.output().stderr(), "synthetic bind failure");
2134    }
2135
2136    #[cfg(unix)]
2137    #[test]
2138    fn managed_startup_rejects_oversized_port_files_and_cleans_up() {
2139        let script = TestServerScript::new(
2140            "oversized-port",
2141            "#!/bin/sh\nprintf '%s\\n%s\\n' \"$$\" \"$2\"\nprintf '34567\\n%064s' '' > \"$2.pending\"\nmv \"$2.pending\" \"$2\"\nexec sleep 30\n",
2142        );
2143        let result = PocketIcStartupConfig::Spawn {
2144            server_binary: script.path(),
2145            timeout: Duration::from_secs(2),
2146            server_hard_ttl: None,
2147            server_idle_ttl: None,
2148            server_output_files: None,
2149        }
2150        .start_managed_server();
2151        let error = result.err().unwrap();
2152        let PocketIcStartupFailure::InvalidServerPort { value, .. } = error.failure() else {
2153            panic!("oversized port publication must fail readiness");
2154        };
2155        assert!(value.contains("port file exceeds"));
2156        assert!(value.len() < 256);
2157        let mut lines = error.output().stdout().lines();
2158        let pid = lines.next().unwrap().parse::<u32>().unwrap();
2159        let port_path = PathBuf::from(lines.next().unwrap());
2160        assert!(!port_path.parent().unwrap().exists());
2161        assert_eq!(process_state(pid), None, "failed server must be reaped");
2162    }
2163
2164    #[cfg(unix)]
2165    #[test]
2166    fn managed_startup_terminates_a_server_that_never_becomes_ready() {
2167        let script = TestServerScript::new(
2168            "timeout",
2169            "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nexec sleep 30\n",
2170        );
2171        let timeout = Duration::from_millis(100);
2172        let started = Instant::now();
2173
2174        let result = PocketIcStartupConfig::Spawn {
2175            server_binary: script.path(),
2176            timeout,
2177            server_hard_ttl: None,
2178            server_idle_ttl: None,
2179            server_output_files: None,
2180        }
2181        .build_instance(PocketIcBuilder::new().with_application_subnet());
2182
2183        assert!(
2184            started.elapsed() < Duration::from_secs(2),
2185            "bounded startup should not wait for the sleeping child"
2186        );
2187        let error = result.err().unwrap();
2188        assert!(
2189            matches!(error.failure(), PocketIcStartupFailure::ReadinessTimeout {
2190            server_binary, timeout: actual_timeout,
2191        } if server_binary == &script.path() && *actual_timeout == timeout)
2192        );
2193        assert!(error.server_cleanup().is_none());
2194    }
2195
2196    #[cfg(unix)]
2197    #[test]
2198    fn managed_server_handle_exposes_process_id_url_output_and_raii_ownership() {
2199        let script = TestServerScript::new(
2200            "handle",
2201            "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then echo 'unexpected managed server arguments' >&2; exit 97; fi\nprintf 'managed server ready: %s' \"$$\"\nprintf '34567\\n' > \"$2\"\nexec sleep 30\n",
2202        );
2203
2204        let server = PocketIcStartupConfig::Spawn {
2205            server_binary: script.path(),
2206            timeout: Duration::from_secs(2),
2207            server_hard_ttl: None,
2208            server_idle_ttl: None,
2209            server_output_files: None,
2210        }
2211        .start_managed_server()
2212        .expect("start caller-owned managed server");
2213
2214        assert_eq!(server.url(), "http://127.0.0.1:34567/");
2215        assert_eq!(
2216            server.output().stdout(),
2217            format!("managed server ready: {}", server.process_id())
2218        );
2219        assert_eq!(server.output().stderr(), "");
2220        let pid = server.process_id();
2221        assert!(process_state(pid).is_some_and(|state| state != 'Z'));
2222        drop(server);
2223        assert_eq!(process_state(pid), None, "owned server must be reaped");
2224    }
2225
2226    #[cfg(unix)]
2227    #[test]
2228    fn managed_server_cleans_descendants_on_drop_timeout_and_exit() {
2229        for mode in ["drop", "timeout", "exit"] {
2230            let publish = if mode == "drop" {
2231                "printf '34567\\n' > \"$2\"\n"
2232            } else {
2233                ""
2234            };
2235            let finish = if mode == "exit" {
2236                "sleep 0.03\nexit 23\n"
2237            } else {
2238                "exec sleep 30\n"
2239            };
2240            let script = TestServerScript::new(
2241                mode,
2242                &format!("#!/bin/sh\nsleep 30 &\nprintf '%s' \"$!\"\n{publish}{finish}"),
2243            );
2244            let result = PocketIcStartupConfig::Spawn {
2245                server_binary: script.path(),
2246                timeout: Duration::from_millis(300),
2247                server_hard_ttl: None,
2248                server_idle_ttl: None,
2249                server_output_files: None,
2250            }
2251            .start_managed_server();
2252            let output = match result {
2253                Ok(server) => {
2254                    let output = server.output().stdout().to_owned();
2255                    drop(server);
2256                    output
2257                }
2258                Err(error) => {
2259                    match error.failure() {
2260                        PocketIcStartupFailure::ReadinessTimeout { .. } => {
2261                            assert_eq!(mode, "timeout");
2262                            assert!(error.server_cleanup().is_none());
2263                        }
2264                        PocketIcStartupFailure::ServerExited { status, .. } => {
2265                            assert_eq!(mode, "exit");
2266                            assert_eq!(status.code(), Some(23));
2267                        }
2268                        _ => panic!("unexpected {mode} startup result: {error}"),
2269                    }
2270                    error.output().stdout().to_owned()
2271                }
2272            };
2273            let pid = output
2274                .parse::<u32>()
2275                .expect("server published its descendant PID");
2276            let deadline = Instant::now() + Duration::from_secs(2);
2277            while process_state(pid).is_some_and(|state| state != 'Z') {
2278                assert!(
2279                    Instant::now() < deadline,
2280                    "{mode} left its descendant running"
2281                );
2282                std::thread::sleep(Duration::from_millis(10));
2283            }
2284        }
2285    }
2286
2287    #[cfg(unix)]
2288    #[test]
2289    fn managed_server_passes_an_explicit_hard_ttl() {
2290        let script = TestServerScript::new(
2291            "hard-ttl",
2292            "#!/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",
2293        );
2294
2295        let server = PocketIcStartupConfig::Spawn {
2296            server_binary: script.path(),
2297            timeout: Duration::from_secs(2),
2298            server_hard_ttl: Some(Duration::from_secs(17)),
2299            server_idle_ttl: None,
2300            server_output_files: None,
2301        }
2302        .start_managed_server()
2303        .expect("start managed server with an explicit hard TTL");
2304
2305        assert_eq!(server.url(), "http://127.0.0.1:34567/");
2306    }
2307
2308    #[cfg(unix)]
2309    #[test]
2310    #[ignore = "requires POCKET_IC_BIN=<caller-provided PocketIC server binary>"]
2311    fn caller_provided_server_publishes_port_constructs_instance_and_cleans_up() {
2312        let binary = std::env::var_os("POCKET_IC_BIN")
2313            .map(PathBuf::from)
2314            .expect("set POCKET_IC_BIN to the exact server binary");
2315        // Observe this exact child's port path without inspecting other startup
2316        // directories created concurrently.
2317        let wrapper = TestServerScript::new(
2318            "real-owned-instance",
2319            "#!/bin/sh\nprintf '%s\n%s\n' \"$$\" \"$4\" > \"$0.record\"\nexec \"$POCKET_IC_BIN\" \"$@\"\n",
2320        );
2321        let owned = PocketIcStartupConfig::Spawn {
2322            server_binary: wrapper.path(),
2323            timeout: Duration::from_secs(30),
2324            server_hard_ttl: Some(Duration::from_secs(60)),
2325            server_idle_ttl: None,
2326            server_output_files: None,
2327        }
2328        .build_instance(PocketIcBuilder::new().with_application_subnet())
2329        .expect("owned managed spawn must construct an instance");
2330        let record_file = wrapper.path().with_extension("record");
2331        let record = fs::read_to_string(&record_file).unwrap();
2332        let mut fields = record.lines();
2333        let pid = fields.next().unwrap().parse().unwrap();
2334        let port_path = PathBuf::from(fields.next().unwrap());
2335        let startup_directory = port_path.parent().unwrap();
2336        drop(owned);
2337        assert!(!startup_directory.exists());
2338        assert_eq!(process_state(pid), None);
2339        fs::remove_file(record_file).unwrap();
2340
2341        let server = PocketIcStartupConfig::Spawn {
2342            server_binary: (&binary).into(),
2343            timeout: Duration::from_secs(30),
2344            server_hard_ttl: Some(Duration::from_secs(60)),
2345            server_idle_ttl: None,
2346            server_output_files: None,
2347        }
2348        .start_managed_server()
2349        .expect("caller-provided PocketIC server must publish its port");
2350        let files = &server.server.files;
2351        let startup_directory = files.directory.path().to_path_buf();
2352
2353        assert!(files.port.is_file());
2354        let pocket_ic = PocketIcStartupConfig::Connect {
2355            server_url: (server.url()).into(),
2356            timeout: Duration::from_secs(30),
2357        }
2358        .build_instance(PocketIcBuilder::new().with_application_subnet())
2359        .expect("construct instance through caller-provided server");
2360
2361        drop(pocket_ic);
2362        drop(server);
2363        assert!(!startup_directory.exists());
2364    }
2365
2366    #[cfg(unix)]
2367    struct TestServerScript {
2368        path: PathBuf,
2369        #[expect(
2370            dead_code,
2371            reason = "retains directory ownership until cleanup or drop"
2372        )]
2373        directory: OwnedDirectory,
2374    }
2375
2376    #[cfg(unix)]
2377    impl TestServerScript {
2378        fn new(label: &str, contents: &str) -> Self {
2379            let directory = OwnedDirectory::new(
2380                create_unique_directory(
2381                    &std::env::temp_dir(),
2382                    &format!("ic-testkit-pocket-ic-{label}"),
2383                    0o700,
2384                    None,
2385                )
2386                .expect("create test server directory"),
2387            );
2388            let path = directory.path().join("server");
2389            write_executable_script(&path, contents).expect("write executable test script");
2390            Self { path, directory }
2391        }
2392
2393        fn path(&self) -> PathBuf {
2394            self.path.clone()
2395        }
2396    }
2397}