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
793fn startup_deadline(started: Instant, timeout: Duration) -> Result<Instant, PocketIcStartupError> {
794    started.checked_add(timeout).ok_or_else(|| {
795        PocketIcStartupError::new(PocketIcStartupFailure::InvalidConfiguration {
796            message: "PocketIC startup timeout exceeds the platform clock range".to_owned(),
797        })
798    })
799}
800
801fn build_bounded(
802    builder: PocketIcBuilder,
803    server_url: &str,
804    startup: &StartupDeadline,
805    mut server: Option<ManagedServer>,
806) -> Result<PocketIcInstance<PocketIc>, PocketIcStartupError> {
807    let builder = configured_builder(builder, server_url)
808        .map_err(|error| finalize_failure(error, None, server.take()))?;
809    let (sender, receiver) = mpsc::sync_channel(1);
810    if let Err(source) = thread::Builder::new()
811        .name("ic-testkit-pocket-ic-startup".to_owned())
812        .spawn(move || {
813            let result = transport::capture_operation(|| builder.build());
814            let _ = sender.send(result);
815        })
816    {
817        return Err(finalize_failure(
818            PocketIcStartupError::new(PocketIcStartupFailure::BuilderThreadSpawn { source }),
819            None,
820            server,
821        ));
822    }
823    await_builder(receiver, server_url, startup, &mut server)
824}
825
826#[expect(
827    clippy::needless_pass_by_value,
828    reason = "consume the receiver so failed startup disconnects late builders"
829)]
830fn await_builder(
831    receiver: mpsc::Receiver<Result<PocketIc, super::PocketIcOperationError>>,
832    server_url: &str,
833    startup: &StartupDeadline,
834    server: &mut Option<ManagedServer>,
835) -> Result<PocketIcInstance<PocketIc>, PocketIcStartupError> {
836    loop {
837        let now = Instant::now();
838        if now >= startup.deadline {
839            return Err(finalize_failure(
840                PocketIcStartupError::new(PocketIcStartupFailure::InstanceCreationTimeout {
841                    timeout: startup.timeout,
842                }),
843                None,
844                server.take(),
845            ));
846        }
847        let remaining = startup.deadline.saturating_duration_since(now);
848        let wait = if server.is_some() {
849            remaining.min(STARTUP_POLL_INTERVAL)
850        } else {
851            remaining
852        };
853        match receiver.recv_timeout(wait) {
854            Ok(Ok(pocket_ic)) => {
855                check_server(server)?;
856                return Ok(PocketIcInstance {
857                    instance: pocket_ic,
858                    server: server.take().map(|server| PocketIcManagedServer {
859                        server,
860                        url: server_url.to_owned(),
861                    }),
862                });
863            }
864            Ok(Err(source)) => {
865                return Err(finalize_failure(
866                    PocketIcStartupError::new(PocketIcStartupFailure::BuilderPanicked { source }),
867                    None,
868                    server.take(),
869                ));
870            }
871            Err(RecvTimeoutError::Disconnected) => {
872                return Err(finalize_failure(
873                    PocketIcStartupError::new(PocketIcStartupFailure::BuilderDisconnected),
874                    None,
875                    server.take(),
876                ));
877            }
878            Err(RecvTimeoutError::Timeout) => {
879                check_server(server)?;
880            }
881        }
882    }
883}
884
885fn configured_builder(
886    builder: PocketIcBuilder,
887    server_url: &str,
888) -> Result<PocketIcBuilder, PocketIcStartupError> {
889    server_url
890        .parse()
891        .map(|url| builder.with_server_url(url))
892        .map_err(|error| {
893            PocketIcStartupError::new(PocketIcStartupFailure::InvalidServerUrl {
894                server_url: server_url.to_owned(),
895                message: error.to_string(),
896            })
897        })
898}
899
900fn check_server(server: &mut Option<ManagedServer>) -> Result<(), PocketIcStartupError> {
901    if let Some(managed) = server.as_mut() {
902        let error = match managed.try_wait() {
903            Ok(Some(status)) => Some(managed.exit_failure(status)),
904            Ok(None) => None,
905            Err(error) => Some(error),
906        };
907        if let Some(error) = error {
908            return Err(finalize_failure(error, None, server.take()));
909        }
910    }
911    Ok(())
912}
913
914async fn build_async_bounded(
915    builder: PocketIcBuilder,
916    server_url: &str,
917    startup: &StartupDeadline,
918    mut server: Option<ManagedServer>,
919) -> Result<PocketIcInstance<ic_platform::pocket_ic::nonblocking::PocketIc>, PocketIcStartupError> {
920    let builder = configured_builder(builder, server_url)
921        .map_err(|error| finalize_failure(error, None, server.take()))?;
922    let mut construction = std::pin::pin!(builder.build_async());
923    let mut construction = std::pin::pin!(std::future::poll_fn(|context| {
924        match transport::capture_operation(|| construction.as_mut().poll(context)) {
925            Ok(poll) => poll.map(Ok),
926            Err(source) => std::task::Poll::Ready(Err(PocketIcStartupError::new(
927                PocketIcStartupFailure::BuilderPanicked { source },
928            ))),
929        }
930    }));
931    // Drop this local before the pending upstream future on cancellation.
932    let mut server = server;
933    loop {
934        let now = Instant::now();
935        if now >= startup.deadline {
936            return Err(finalize_failure(
937                PocketIcStartupError::new(PocketIcStartupFailure::InstanceCreationTimeout {
938                    timeout: startup.timeout,
939                }),
940                None,
941                server,
942            ));
943        }
944        check_server(&mut server)?;
945        let next_check = if server.is_some() {
946            now + startup
947                .deadline
948                .saturating_duration_since(now)
949                .min(STARTUP_POLL_INTERVAL)
950        } else {
951            startup.deadline
952        };
953        match tokio::time::timeout_at(next_check.into(), construction.as_mut()).await {
954            Ok(Ok(instance)) => {
955                if Instant::now() >= startup.deadline {
956                    return Err(finalize_failure(
957                        PocketIcStartupError::new(
958                            PocketIcStartupFailure::InstanceCreationTimeout {
959                                timeout: startup.timeout,
960                            },
961                        ),
962                        None,
963                        server,
964                    ));
965                }
966                check_server(&mut server)?;
967                return Ok(PocketIcInstance {
968                    instance,
969                    server: server.map(|server| PocketIcManagedServer {
970                        server,
971                        url: server_url.to_owned(),
972                    }),
973                });
974            }
975            Ok(Err(error)) => return Err(finalize_failure(error, None, server)),
976            Err(_) => {}
977        }
978    }
979}
980
981/// Finalize an owned operation once, preserving the primary failure.
982fn finalize_failure(
983    error: PocketIcStartupError,
984    command: Option<&mut OwnedChild>,
985    server: Option<ManagedServer>,
986) -> PocketIcStartupError {
987    error.with_cleanup(
988        command.and_then(|child| child.terminate().err()),
989        server.map(ManagedServer::terminate_and_capture),
990    )
991}
992
993struct ManagedServer {
994    child: OwnedChild,
995    binary: PathBuf,
996    files: StartupFiles,
997    started: Instant,
998}
999
1000enum PortFileState {
1001    Pending,
1002    Ready(u16),
1003    Invalid(String),
1004}
1005
1006impl ManagedServer {
1007    fn wait_ready(
1008        mut self,
1009        startup: &StartupDeadline,
1010    ) -> Result<(Self, String), PocketIcStartupError> {
1011        loop {
1012            match self.poll_ready(startup) {
1013                Ok(Some(url)) => return Ok((self, url)),
1014                Ok(None) => thread::sleep(
1015                    startup
1016                        .deadline
1017                        .saturating_duration_since(Instant::now())
1018                        .min(STARTUP_POLL_INTERVAL),
1019                ),
1020                Err(error) => return Err(finalize_failure(error, None, Some(self))),
1021            }
1022        }
1023    }
1024
1025    fn spawn(
1026        binary: PathBuf,
1027        hard_ttl: Option<Duration>,
1028        idle_ttl: Option<Duration>,
1029        output_files: Option<(PathBuf, PathBuf)>,
1030        started: Instant,
1031    ) -> Result<Self, PocketIcStartupError> {
1032        let (files, stdout, stderr) = StartupFiles::create(output_files)?;
1033        let mut command = Command::new(&binary);
1034        if let Some(hard_ttl) = hard_ttl {
1035            command
1036                .arg("--hard-ttl")
1037                .arg(hard_ttl.as_secs().to_string());
1038        }
1039        if let Some(idle_ttl) = idle_ttl {
1040            command.arg("--ttl").arg(idle_ttl.as_secs().to_string());
1041        }
1042        command
1043            .arg("--port-file")
1044            .arg(&files.port)
1045            .stdout(Stdio::from(stdout))
1046            .stderr(Stdio::from(stderr));
1047        let child =
1048            OwnedChild::spawn(&mut command, CleanupPolicy::KillAndWait).map_err(|source| {
1049                PocketIcStartupError::new(PocketIcStartupFailure::ServerSpawn {
1050                    server_binary: binary.clone(),
1051                    source,
1052                })
1053            })?;
1054        Ok(Self {
1055            child,
1056            binary,
1057            files,
1058            started,
1059        })
1060    }
1061
1062    fn poll_ready(
1063        &mut self,
1064        startup: &StartupDeadline,
1065    ) -> Result<Option<String>, PocketIcStartupError> {
1066        if let Some(status) = self.try_wait()? {
1067            return Err(self.exit_failure(status));
1068        }
1069        if Instant::now() >= startup.deadline {
1070            return Err(PocketIcStartupError::new(
1071                PocketIcStartupFailure::ReadinessTimeout {
1072                    server_binary: self.binary.clone(),
1073                    timeout: startup.timeout,
1074                },
1075            ));
1076        }
1077        match self.files.read_port()? {
1078            PortFileState::Pending => Ok(None),
1079            PortFileState::Ready(port) => Ok(Some(format!("http://127.0.0.1:{port}/"))),
1080            PortFileState::Invalid(value) => Err(PocketIcStartupError::new(
1081                PocketIcStartupFailure::InvalidServerPort {
1082                    server_binary: self.binary.clone(),
1083                    value,
1084                },
1085            )),
1086        }
1087    }
1088
1089    fn try_wait(&mut self) -> Result<Option<ExitStatus>, PocketIcStartupError> {
1090        self.child.try_wait().map_err(|source| {
1091            PocketIcStartupError::new(PocketIcStartupFailure::Io {
1092                operation: "inspect PocketIC server child",
1093                path: self.binary.clone(),
1094                source,
1095            })
1096        })
1097    }
1098
1099    fn exit_failure(&self, status: ExitStatus) -> PocketIcStartupError {
1100        PocketIcStartupError::new(PocketIcStartupFailure::ServerExited {
1101            server_binary: self.binary.clone(),
1102            status,
1103            elapsed: self.started.elapsed(),
1104        })
1105    }
1106
1107    fn terminate_and_capture(self) -> (PocketIcManagedServerOutput, Option<CleanupError>) {
1108        let Self {
1109            mut child, files, ..
1110        } = self;
1111        let cleanup = child.terminate().err();
1112        // Finish the child's Drop cleanup before reading the final diagnostics.
1113        drop(child);
1114        (files.capture(), cleanup)
1115    }
1116}
1117
1118struct StartupFiles {
1119    #[cfg_attr(
1120        not(test),
1121        expect(
1122            dead_code,
1123            reason = "retains directory ownership until cleanup or drop"
1124        )
1125    )]
1126    directory: OwnedDirectory,
1127    port: PathBuf,
1128    stdout: PathBuf,
1129    stderr: PathBuf,
1130}
1131
1132impl StartupFiles {
1133    fn read_port(&self) -> Result<PortFileState, PocketIcStartupError> {
1134        let port_path = &self.port;
1135        let mut contents = String::new();
1136        match ic_host_fs::read::open_regular_file(port_path)
1137            .map_err(|error| match error {
1138                ic_host_artifacts::artifact::ArtifactError::Io(source) => source,
1139                error => io::Error::new(io::ErrorKind::InvalidData, error),
1140            })
1141            .and_then(|file| {
1142                file.take((SERVER_PORT_FILE_LIMIT + 1) as u64)
1143                    .read_to_string(&mut contents)
1144            }) {
1145            Ok(_) => {}
1146            Err(error) if error.kind() == io::ErrorKind::NotFound => {
1147                return Ok(PortFileState::Pending);
1148            }
1149            Err(source) => {
1150                return Err(PocketIcStartupError::new(PocketIcStartupFailure::Io {
1151                    operation: "read PocketIC server port file",
1152                    path: port_path.clone(),
1153                    source,
1154                }));
1155            }
1156        }
1157        if contents.len() > SERVER_PORT_FILE_LIMIT {
1158            return Ok(PortFileState::Invalid(format!(
1159                "{} (port file exceeds {SERVER_PORT_FILE_LIMIT} bytes)",
1160                contents.trim()
1161            )));
1162        }
1163        if !contents.contains('\n') {
1164            return Ok(PortFileState::Pending);
1165        }
1166        let value = contents.trim().to_owned();
1167        match value.parse::<u16>() {
1168            Ok(port) if port != 0 => Ok(PortFileState::Ready(port)),
1169            _ => Ok(PortFileState::Invalid(value)),
1170        }
1171    }
1172
1173    fn capture(&self) -> PocketIcManagedServerOutput {
1174        PocketIcManagedServerOutput {
1175            stdout: read_bounded_lossy(&self.stdout),
1176            stderr: read_bounded_lossy(&self.stderr),
1177        }
1178    }
1179
1180    fn create(
1181        output_files: Option<(PathBuf, PathBuf)>,
1182    ) -> Result<(Self, File, File), PocketIcStartupError> {
1183        let output_files = output_files
1184            .map(|(stdout, stderr)| {
1185                Ok::<_, PocketIcStartupError>((
1186                    std::path::absolute(&stdout)
1187                        .map_err(|source| startup_file_error("resolve", &stdout, source))?,
1188                    std::path::absolute(&stderr)
1189                        .map_err(|source| startup_file_error("resolve", &stderr, source))?,
1190                ))
1191            })
1192            .transpose()?;
1193        let directory = OwnedDirectory::new(
1194            create_unique_directory(
1195                &std::env::temp_dir(),
1196                "ic-testkit-pocket-ic-startup",
1197                0o700,
1198                None,
1199            )
1200            .map_err(|source| startup_file_error("create", &std::env::temp_dir(), source))?,
1201        );
1202        let base = directory.path();
1203        let (stdout_path, stderr_path) =
1204            output_files.unwrap_or_else(|| (base.join("stdout"), base.join("stderr")));
1205        let files = Self {
1206            port: base.join("port"),
1207            stdout: stdout_path,
1208            stderr: stderr_path,
1209            directory,
1210        };
1211        let stdout = create_new_file(&files.stdout)
1212            .map_err(|source| startup_file_error("create", &files.stdout, source))?;
1213        let stderr = create_new_file(&files.stderr)
1214            .map_err(|source| startup_file_error("create", &files.stderr, source))?;
1215        Ok((files, stdout, stderr))
1216    }
1217}
1218
1219fn create_new_file(path: &Path) -> io::Result<File> {
1220    let mut options = OpenOptions::new();
1221    options.write(true).create_new(true);
1222    #[cfg(unix)]
1223    options.mode(0o600);
1224    options.open(path)
1225}
1226
1227fn startup_file_error(
1228    operation: &'static str,
1229    path: &Path,
1230    source: io::Error,
1231) -> PocketIcStartupError {
1232    PocketIcStartupError::new(PocketIcStartupFailure::Io {
1233        operation,
1234        path: path.to_owned(),
1235        source,
1236    })
1237}
1238
1239fn read_bounded_lossy(path: &Path) -> String {
1240    let Ok((bytes, length)) = ic_host_fs::read::read_file_prefix(path, SERVER_OUTPUT_LIMIT) else {
1241        return String::new();
1242    };
1243    let mut output = String::from_utf8_lossy(&bytes).into_owned();
1244    let omitted = length.saturating_sub(bytes.len() as u64);
1245    if omitted > 0 {
1246        let _ = write!(output, "\n<truncated {omitted} bytes>");
1247    }
1248    output
1249}
1250
1251impl std::fmt::Display for PocketIcStartupError {
1252    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1253        std::fmt::Display::fmt(self.failure.as_ref(), formatter)?;
1254        if !self.output.stdout.is_empty() {
1255            write!(formatter, "; server stdout: {}", self.output.stdout)?;
1256        }
1257        if !self.output.stderr.is_empty() {
1258            write!(formatter, "; server stderr: {}", self.output.stderr)?;
1259        }
1260        for (owner, cleanup) in [
1261            ("command", self.command_cleanup()),
1262            ("server", self.server_cleanup()),
1263        ] {
1264            if let Some(error) = cleanup {
1265                write!(formatter, "; {owner} cleanup also failed: {error}")?;
1266            }
1267        }
1268        Ok(())
1269    }
1270}
1271
1272impl std::error::Error for PocketIcStartupError {
1273    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1274        std::error::Error::source(self.failure.as_ref())
1275    }
1276}
1277
1278#[cfg(test)]
1279mod tests {
1280    mod instances;
1281
1282    use std::{
1283        fs,
1284        path::PathBuf,
1285        time::{Duration, Instant},
1286    };
1287
1288    use super::{
1289        OwnedDirectory, PocketIcStartupConfig, PocketIcStartupError, PocketIcStartupFailure,
1290        StartupFiles, create_unique_directory,
1291    };
1292    use ic_platform::pocket_ic::PocketIcBuilder;
1293
1294    #[cfg(unix)]
1295    use ic_host_process::testing::write_executable_script;
1296    #[cfg(unix)]
1297    use std::{
1298        io::Write as _,
1299        os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _},
1300        process::Command,
1301        sync::mpsc,
1302    };
1303
1304    #[test]
1305    fn original_failure_output_and_both_typed_cleanup_reports_survive_projection() {
1306        use std::error::Error as _;
1307        for failure in [
1308            PocketIcStartupFailure::ReadinessTimeout {
1309                server_binary: "server".into(),
1310                timeout: Duration::from_secs(1),
1311            },
1312            PocketIcStartupFailure::InstanceCreationTimeout {
1313                timeout: Duration::from_secs(1),
1314            },
1315            PocketIcStartupFailure::InvalidServerPort {
1316                server_binary: "server".into(),
1317                value: "invalid".into(),
1318            },
1319            PocketIcStartupFailure::BuilderPanicked {
1320                source: super::super::PocketIcOperationError::new("original-panic"),
1321            },
1322            PocketIcStartupFailure::BuilderDisconnected,
1323            PocketIcStartupFailure::BuilderThreadSpawn {
1324                source: std::io::Error::from_raw_os_error(9),
1325            },
1326            PocketIcStartupFailure::Io {
1327                operation: "original-io",
1328                path: "port".into(),
1329                source: std::io::Error::from_raw_os_error(9),
1330            },
1331        ] {
1332            let primary_text = failure.to_string();
1333            let output = super::PocketIcManagedServerOutput {
1334                stdout: "original-stdout".into(),
1335                stderr: "original-stderr".into(),
1336            };
1337            let command = super::CleanupError {
1338                status: None,
1339                group_error: None,
1340                term_error: Some(std::io::Error::from_raw_os_error(4)),
1341                kill_error: Some(std::io::Error::from_raw_os_error(5)),
1342                wait_error: None,
1343            };
1344            let server = super::CleanupError {
1345                status: None,
1346                group_error: Some(std::io::Error::from_raw_os_error(1)),
1347                term_error: None,
1348                kill_error: None,
1349                wait_error: Some(std::io::Error::from_raw_os_error(10)),
1350            };
1351            let error = PocketIcStartupError::new(failure)
1352                .with_cleanup(Some(command), Some((output, Some(server))));
1353            assert_eq!(error.failure().to_string(), primary_text);
1354            assert_eq!(error.output().stdout(), "original-stdout");
1355            assert_eq!(error.output().stderr(), "original-stderr");
1356            let command = error.command_cleanup().unwrap();
1357            let server = error.server_cleanup().unwrap();
1358            assert_eq!(command.kill_error.as_ref().unwrap().raw_os_error(), Some(5));
1359            assert_eq!(command.term_error.as_ref().unwrap().raw_os_error(), Some(4));
1360            assert_eq!(server.group_error.as_ref().unwrap().raw_os_error(), Some(1));
1361            assert_eq!(server.wait_error.as_ref().unwrap().raw_os_error(), Some(10));
1362            if matches!(
1363                error.failure(),
1364                PocketIcStartupFailure::Io { .. }
1365                    | PocketIcStartupFailure::BuilderThreadSpawn { .. }
1366            ) {
1367                assert_eq!(
1368                    error
1369                        .source()
1370                        .unwrap()
1371                        .downcast_ref::<std::io::Error>()
1372                        .unwrap()
1373                        .raw_os_error(),
1374                    Some(9)
1375                );
1376            }
1377            if let PocketIcStartupFailure::BuilderPanicked { source } = error.failure() {
1378                assert_eq!(
1379                    error
1380                        .source()
1381                        .unwrap()
1382                        .downcast_ref::<super::super::PocketIcOperationError>(),
1383                    Some(source)
1384                );
1385            }
1386            let displayed = error.to_string();
1387            assert!(displayed.starts_with(&primary_text));
1388            for marker in [
1389                "original-stdout",
1390                "original-stderr",
1391                "command cleanup also failed",
1392                "server cleanup also failed",
1393            ] {
1394                assert!(displayed.contains(marker));
1395            }
1396        }
1397        let error = PocketIcStartupError::new(PocketIcStartupFailure::NotConfigured);
1398        assert_eq!(error.to_string(), error.failure().to_string());
1399        assert!(error.command_cleanup().is_none());
1400        assert!(error.server_cleanup().is_none());
1401    }
1402
1403    #[cfg(unix)]
1404    #[test]
1405    fn builder_failures_finalize_the_owned_server_and_keep_output() {
1406        for disconnected in [false, true] {
1407            let script = TestServerScript::new(
1408                "builder-failure",
1409                "#!/bin/sh\nprintf '%s\n%s\n' \"$$\" \"$2\"\nprintf 'builder-diagnostic' >&2\nprintf '34567\n' > \"$2\"\nexec sleep 30\n",
1410            );
1411            let managed = PocketIcStartupConfig::Spawn {
1412                server_binary: script.path(),
1413                timeout: Duration::from_secs(2),
1414                server_hard_ttl: None,
1415                server_idle_ttl: None,
1416                server_output_files: None,
1417            }
1418            .start_managed_server()
1419            .unwrap();
1420            let pid = managed.process_id();
1421            let directory = managed.server.files.directory.path().to_path_buf();
1422            let (sender, receiver) = mpsc::channel();
1423            if !disconnected {
1424                sender
1425                    .send(Err(super::super::PocketIcOperationError::new(
1426                        "original-builder-panic",
1427                    )))
1428                    .unwrap();
1429            }
1430            drop(sender);
1431            let error = super::await_builder(
1432                receiver,
1433                "http://127.0.0.1:34567/",
1434                &super::StartupDeadline {
1435                    started: Instant::now(),
1436                    deadline: Instant::now() + Duration::from_secs(2),
1437                    timeout: Duration::from_secs(2),
1438                },
1439                &mut Some(managed.server),
1440            )
1441            .err()
1442            .unwrap();
1443            assert!(matches!(
1444                (disconnected, error.failure()),
1445                (true, PocketIcStartupFailure::BuilderDisconnected)
1446                    | (false, PocketIcStartupFailure::BuilderPanicked { .. })
1447            ));
1448            assert_eq!(error.output().stderr(), "builder-diagnostic");
1449            assert!(error.server_cleanup().is_none());
1450            assert!(!directory.exists());
1451            assert_eq!(process_state(pid), None);
1452        }
1453    }
1454
1455    #[cfg(unix)]
1456    #[test]
1457    fn port_io_and_command_spawn_failures_finalize_the_owned_server() {
1458        for port_io in [true, false] {
1459            let port = if port_io {
1460                "mkfifo \"$2\""
1461            } else {
1462                "printf '34567\n' > \"$2\""
1463            };
1464            let script = TestServerScript::new(
1465                "owned-io-failure",
1466                &format!(
1467                    "#!/bin/sh\nprintf '%s\n%s\n' \"$$\" \"$2\"\nprintf 'original-server-diagnostic' >&2\n{port}\nexec sleep 30\n"
1468                ),
1469            );
1470            let config = PocketIcStartupConfig::Spawn {
1471                server_binary: script.path(),
1472                timeout: Duration::from_secs(2),
1473                server_hard_ttl: None,
1474                server_idle_ttl: None,
1475                server_output_files: None,
1476            };
1477            let error = if port_io {
1478                config.start_managed_server().err().unwrap()
1479            } else {
1480                config
1481                    .run_command(&mut Command::new("/missing/ic-testkit-command"), || false)
1482                    .unwrap_err()
1483            };
1484            let PocketIcStartupFailure::Io { source, .. } = error.failure() else {
1485                panic!("expected original IO failure: {error:?}");
1486            };
1487            assert_eq!(
1488                source.kind(),
1489                if port_io {
1490                    std::io::ErrorKind::InvalidData
1491                } else {
1492                    std::io::ErrorKind::NotFound
1493                }
1494            );
1495            assert_eq!(error.output().stderr(), "original-server-diagnostic");
1496            let mut lines = error.output().stdout().lines();
1497            let pid = lines.next().unwrap().parse().unwrap();
1498            let port = PathBuf::from(lines.next().unwrap());
1499            assert_eq!(process_state(pid), None);
1500            assert!(!port.parent().unwrap().exists());
1501            assert!(error.server_cleanup().is_none());
1502        }
1503    }
1504
1505    #[cfg(unix)]
1506    #[test]
1507    fn command_cancellation_before_startup_has_no_spawn_effects() {
1508        let error = PocketIcStartupConfig::Spawn {
1509            server_binary: ("/missing/server").into(),
1510            timeout: Duration::from_secs(1),
1511            server_hard_ttl: None,
1512            server_idle_ttl: None,
1513            server_output_files: None,
1514        }
1515        .run_command(&mut Command::new("/missing/command"), || true)
1516        .unwrap_err();
1517        assert!(
1518            matches!(error.failure(), PocketIcStartupFailure::CommandRun { source, .. } if source.kind() == std::io::ErrorKind::Interrupted)
1519        );
1520        assert_eq!(error.output().stdout(), "");
1521        assert_eq!(error.output().stderr(), "");
1522        assert!(error.command_cleanup().is_none());
1523        assert!(error.server_cleanup().is_none());
1524    }
1525
1526    #[cfg(unix)]
1527    #[test]
1528    fn cancellation_callback_panic_still_reaps_the_owned_command() {
1529        let script = TestServerScript::new(
1530            "cancel-panic",
1531            "#!/bin/sh\nprintf '%s' \"$$\" > \"$1\"\nexec sleep 30\n",
1532        );
1533        let pid_file = script.path().with_extension("pid");
1534        let result = std::panic::catch_unwind(|| {
1535            PocketIcStartupConfig::Connect {
1536                server_url: ("http://127.0.0.1:12345/").into(),
1537                timeout: Duration::from_secs(1),
1538            }
1539            .run_command(Command::new(script.path()).arg(&pid_file), || {
1540                assert!(
1541                    !fs::read_to_string(&pid_file).is_ok_and(|value| !value.is_empty()),
1542                    "caller cancellation failed"
1543                );
1544                false
1545            })
1546        });
1547        assert!(result.is_err());
1548        let pid = fs::read_to_string(&pid_file).unwrap().parse().unwrap();
1549        assert!(process_state(pid).is_none_or(|state| state == 'Z'));
1550        fs::remove_file(pid_file).unwrap();
1551    }
1552
1553    #[cfg(unix)]
1554    #[test]
1555    fn environment_selection_prefers_urls_and_fails_closed() {
1556        use std::os::unix::ffi::OsStringExt as _;
1557
1558        let timeout = Duration::from_secs(1);
1559        let config = PocketIcStartupConfig::from_environment(timeout, |name| {
1560            Some(
1561                if name == "IC_TESTKIT_POCKET_IC_URL" {
1562                    "http://127.0.0.1:12345/"
1563                } else {
1564                    "/missing/server"
1565                }
1566                .into(),
1567            )
1568        })
1569        .unwrap();
1570        assert!(
1571            matches!(config, PocketIcStartupConfig::Connect { ref server_url, .. } if server_url == "http://127.0.0.1:12345/")
1572        );
1573        assert!(matches!(
1574            PocketIcStartupConfig::from_environment(timeout, |_| None)
1575                .as_ref()
1576                .map_err(PocketIcStartupError::failure),
1577            Err(PocketIcStartupFailure::NotConfigured)
1578        ));
1579        assert!(matches!(
1580            PocketIcStartupConfig::from_environment(timeout, |_| Some("".into()))
1581                .as_ref()
1582                .map_err(PocketIcStartupError::failure),
1583            Err(PocketIcStartupFailure::InvalidEnvironment {
1584                variable: "IC_TESTKIT_POCKET_IC_URL"
1585            })
1586        ));
1587        assert!(matches!(
1588            PocketIcStartupConfig::from_environment(timeout, |_| Some("bad URL".into()))
1589                .as_ref()
1590                .map_err(PocketIcStartupError::failure),
1591            Err(PocketIcStartupFailure::InvalidServerUrl { .. })
1592        ));
1593        assert!(matches!(
1594            PocketIcStartupConfig::from_environment(timeout, |_| Some(
1595                std::ffi::OsString::from_vec(vec![0xff])
1596            ))
1597            .as_ref()
1598            .map_err(PocketIcStartupError::failure),
1599            Err(PocketIcStartupFailure::InvalidEnvironment { .. })
1600        ));
1601    }
1602
1603    #[cfg(unix)]
1604    #[test]
1605    fn version_capture_cleans_wrapper_descendants_on_exit_and_timeout() {
1606        for timeout in [false, true] {
1607            let qualified = format!(
1608                "printf 'pocket-ic-server {}\\n'",
1609                crate::pic::POCKET_IC_SERVER_VERSION
1610            );
1611            let ending = if timeout { "wait" } else { &qualified };
1612            let script = TestServerScript::new(
1613                "version-group",
1614                &format!(
1615                    "#!/bin/sh\nsleep 30 >/dev/null 2>&1 &\nprintf '%s' \"$!\" > \"$0.pid\"\n{ending}\n"
1616                ),
1617            );
1618            let result = PocketIcStartupConfig::from_environment(Duration::from_secs(1), |name| {
1619                (name == "POCKET_IC_BIN").then(|| script.path().into_os_string())
1620            });
1621            if timeout {
1622                assert!(matches!(
1623                    result.as_ref().map_err(PocketIcStartupError::failure),
1624                    Err(PocketIcStartupFailure::ServerVersionProbe { .. })
1625                ));
1626            } else {
1627                assert!(result.is_ok());
1628            }
1629            let pid_file = script.path().with_extension("pid");
1630            let pid = fs::read_to_string(&pid_file).unwrap().parse().unwrap();
1631            let deadline = Instant::now() + Duration::from_secs(3);
1632            while process_state(pid).is_some_and(|state| state != 'Z') {
1633                assert!(
1634                    Instant::now() < deadline,
1635                    "version wrapper descendant remained alive"
1636                );
1637                std::thread::sleep(Duration::from_millis(20));
1638            }
1639            fs::remove_file(pid_file).unwrap();
1640        }
1641    }
1642
1643    #[cfg(unix)]
1644    #[test]
1645    fn owned_builder_panic_retains_server_output_and_reaps_child() {
1646        let script = TestServerScript::new(
1647            "builder-panic",
1648            "#!/bin/sh\nprintf '%s' \"$$\" > \"$0.pid\"\nprintf 'builder stdout'\nprintf 'builder stderr' >&2\nprintf '34567\\n' > \"$2\"\nexec sleep 30\n",
1649        );
1650        let error = PocketIcStartupConfig::Spawn {
1651            server_binary: script.path(),
1652            timeout: Duration::from_secs(2),
1653            server_hard_ttl: None,
1654            server_idle_ttl: None,
1655            server_output_files: None,
1656        }
1657        .build_instance(PocketIcBuilder::new())
1658        .err()
1659        .unwrap();
1660        let PocketIcStartupFailure::BuilderPanicked { source } = error.failure() else {
1661            panic!("expected builder failure, got {error:?}");
1662        };
1663        assert_ne!(source.message(), "");
1664        assert_eq!(
1665            std::error::Error::source(&error)
1666                .unwrap()
1667                .downcast_ref::<super::super::PocketIcOperationError>(),
1668            Some(source)
1669        );
1670        assert_eq!(error.output().stdout(), "builder stdout");
1671        assert_eq!(error.output().stderr(), "builder stderr");
1672        assert!(error.server_cleanup().is_none());
1673        let pid_file = script.path().with_extension("pid");
1674        let pid = fs::read_to_string(&pid_file).unwrap().parse().unwrap();
1675        assert_eq!(process_state(pid), None);
1676        fs::remove_file(pid_file).unwrap();
1677    }
1678
1679    #[cfg(unix)]
1680    #[test]
1681    fn environment_binary_selection_uses_bounded_shared_version_capture() {
1682        let qualified = format!(
1683            "printf 'pocket-ic-server {}\\n'",
1684            crate::pic::POCKET_IC_SERVER_VERSION
1685        );
1686        let failed = format!("{qualified}; exit 23");
1687        for (label, body, expected) in [
1688            ("qualified", qualified.as_str(), 0),
1689            ("compatible-16.0", "printf 'pocket-ic-server 16.0.0\\n'", 0),
1690            ("compatible-patch", "printf 'pocket-ic-server 16.0.1\\n'", 0),
1691            ("wrong-version", "printf 'pocket-ic-server 15.0.0\\n'", 1),
1692            ("future-major", "printf 'pocket-ic-server 17.0.0\\n'", 1),
1693            ("malformed-version", "printf 'pocket-ic-server 16.1\\n'", 1),
1694            ("failed-version", failed.as_str(), 2),
1695            ("invalid-utf8", "printf '\\377'", 1),
1696            ("version-timeout", "exec sleep 30", 2),
1697        ] {
1698            let script = TestServerScript::new(
1699                label,
1700                &format!("#!/bin/sh\n[ \"$1\" = --version ] || exit 99\n{body}\n"),
1701            );
1702            // A successful process launch needs scheduling headroom on loaded CI
1703            // hosts; only the deliberately stalled probe needs a short deadline.
1704            let timeout = if label == "version-timeout" {
1705                Duration::from_millis(200)
1706            } else {
1707                Duration::from_secs(5)
1708            };
1709            let result = PocketIcStartupConfig::from_environment(timeout, |name| {
1710                (name == "POCKET_IC_BIN").then(|| script.path().into_os_string())
1711            });
1712            match (
1713                expected,
1714                result.as_ref().map_err(PocketIcStartupError::failure),
1715            ) {
1716                (0, Ok(config)) => {
1717                    let binary = script.path().canonicalize().unwrap();
1718                    assert!(
1719                        matches!(config, PocketIcStartupConfig::Spawn { server_binary, .. } if server_binary == &binary)
1720                    );
1721                }
1722                (1, Err(PocketIcStartupFailure::ServerVersionMismatch { .. }))
1723                | (2, Err(PocketIcStartupFailure::ServerVersionProbe { .. })) => {}
1724                (_, result) => panic!("unexpected {label} result: {result:?}"),
1725            }
1726        }
1727    }
1728
1729    #[cfg(unix)]
1730    fn process_state(pid: u32) -> Option<char> {
1731        // Both supported Unix hosts provide this ps field. A zombie has stopped
1732        // running but may remain visible until its parent reaps it.
1733        let output = Command::new("/bin/ps")
1734            .args(["-p", &pid.to_string(), "-o", "stat="])
1735            .output()
1736            .expect("inspect managed test process state");
1737        assert!(
1738            output.status.success()
1739                || (output.status.code() == Some(1)
1740                    && output.stdout.is_empty()
1741                    && output.stderr.is_empty()),
1742            "process-state inspection failed: {}: {}",
1743            output.status,
1744            String::from_utf8_lossy(&output.stderr),
1745        );
1746        String::from_utf8(output.stdout)
1747            .expect("process state is ASCII")
1748            .trim()
1749            .chars()
1750            .next()
1751    }
1752
1753    #[cfg(unix)]
1754    #[test]
1755    fn reading_large_sparse_server_output_is_bounded() {
1756        let (files, _, _) = StartupFiles::create(None).expect("allocate startup files");
1757        let mut file = fs::File::create(&files.stdout).expect("create sparse log");
1758        file.write_all(b"server started\n").expect("write prefix");
1759        let size = 8_u64 * 1024 * 1024 * 1024;
1760        file.set_len(size).expect("extend sparse log");
1761        let output = super::read_bounded_lossy(&files.stdout);
1762        assert!(output.starts_with("server started\n"));
1763        assert!(output.ends_with(&format!(
1764            "<truncated {} bytes>",
1765            size - super::SERVER_OUTPUT_LIMIT as u64
1766        )));
1767        assert!(output.len() < super::SERVER_OUTPUT_LIMIT + 100);
1768    }
1769
1770    #[cfg(unix)]
1771    #[test]
1772    fn startup_readers_reject_fifos_without_waiting_for_a_writer() {
1773        let (files, stdout, stderr) = StartupFiles::create(None).expect("allocate startup files");
1774        drop((stdout, stderr));
1775        fs::remove_file(&files.stdout).unwrap();
1776        fs::remove_file(&files.stderr).unwrap();
1777        assert!(
1778            Command::new("mkfifo")
1779                .args([&files.port, &files.stdout, &files.stderr])
1780                .status()
1781                .expect("create FIFO startup files")
1782                .success()
1783        );
1784        for path in [&files.port, &files.stdout, &files.stderr] {
1785            // A delayed writer bounds a blocked read and records whether the
1786            // reader needed it. Completion cancels the writer; with no reader,
1787            // a nonblocking open fails and is retried if the reader starts late.
1788            let fifo = path.clone();
1789            let (stop_writer, stopped) = mpsc::channel();
1790            let writer = std::thread::spawn(move || {
1791                loop {
1792                    match stopped.recv_timeout(Duration::from_millis(200)) {
1793                        Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return false,
1794                        Err(mpsc::RecvTimeoutError::Timeout) => {}
1795                    }
1796                    if fs::OpenOptions::new()
1797                        .write(true)
1798                        .custom_flags(libc::O_NONBLOCK)
1799                        .open(&fifo)
1800                        .is_ok()
1801                    {
1802                        return true;
1803                    }
1804                }
1805            });
1806            let result = if path == &files.port {
1807                Some(files.read_port())
1808            } else {
1809                assert_eq!(super::read_bounded_lossy(path), "");
1810                None
1811            };
1812            let _ = stop_writer.send(());
1813            assert!(
1814                !writer.join().expect("join delayed FIFO writer"),
1815                "startup reader waited for a writer: {}",
1816                path.display(),
1817            );
1818            if let Some(result) = result {
1819                assert!(matches!(
1820                    result.as_ref().map_err(PocketIcStartupError::failure),
1821                    Err(PocketIcStartupFailure::Io { source, .. })
1822                        if source.kind() == std::io::ErrorKind::InvalidData
1823                ));
1824            }
1825        }
1826    }
1827
1828    #[test]
1829    fn port_file_readiness_preserves_partial_writes_and_rejects_oversized_contents() {
1830        let (files, _, _) = StartupFiles::create(None).expect("allocate startup files");
1831        assert!(matches!(
1832            files.read_port().unwrap(),
1833            super::PortFileState::Pending
1834        ));
1835        for contents in ["", "34567"] {
1836            fs::write(&files.port, contents).unwrap();
1837            assert!(matches!(
1838                files.read_port().unwrap(),
1839                super::PortFileState::Pending
1840            ));
1841        }
1842        for (contents, expected) in [("1\n", 1), ("65535\n", 65535), (" 34567\r\n", 34567)] {
1843            fs::write(&files.port, contents).unwrap();
1844            assert!(matches!(
1845                files.read_port().unwrap(),
1846                super::PortFileState::Ready(port) if port == expected
1847            ));
1848        }
1849        for contents in ["0\n", "65536\n", "invalid\n", "1\n2\n"] {
1850            fs::write(&files.port, contents).unwrap();
1851            assert!(matches!(
1852                files.read_port().unwrap(),
1853                super::PortFileState::Invalid(_)
1854            ));
1855        }
1856        fs::write(&files.port, [0xff, b'\n']).unwrap();
1857        assert!(matches!(
1858            files.read_port().as_ref().map_err(PocketIcStartupError::failure),
1859            Err(PocketIcStartupFailure::Io { source, .. })
1860                if source.kind() == std::io::ErrorKind::InvalidData
1861        ));
1862        for contents in ["1\n".to_owned() + &" ".repeat(128), "0".repeat(128)] {
1863            fs::write(&files.port, contents).unwrap();
1864            assert!(
1865                matches!(files.read_port().unwrap(), super::PortFileState::Invalid(_)),
1866                "oversized port contents must fail even without a newline",
1867            );
1868        }
1869        // A large backing file must not enlarge the returned diagnostic.
1870        fs::File::options()
1871            .write(true)
1872            .open(&files.port)
1873            .unwrap()
1874            .set_len(1024 * 1024)
1875            .unwrap();
1876        assert!(matches!(
1877            files.read_port().unwrap(),
1878            super::PortFileState::Invalid(value) if value.len() < 256
1879        ));
1880    }
1881
1882    #[test]
1883    fn startup_config_requires_positive_bounds() {
1884        let config = PocketIcStartupConfig::Spawn {
1885            server_binary: "pocket-ic".into(),
1886            timeout: Duration::from_secs(1),
1887            server_hard_ttl: None,
1888            server_idle_ttl: Some(Duration::from_millis(1)),
1889            server_output_files: None,
1890        };
1891        assert!(matches!(
1892            config.validate().unwrap_err().failure(),
1893            PocketIcStartupFailure::InvalidConfiguration { .. }
1894        ));
1895        let error = PocketIcStartupConfig::Connect {
1896            server_url: ("http://127.0.0.1:1/").into(),
1897            timeout: Duration::ZERO,
1898        }
1899        .validate()
1900        .expect_err("zero startup timeout must fail");
1901        assert!(matches!(
1902            error.failure(),
1903            PocketIcStartupFailure::InvalidConfiguration { .. }
1904        ));
1905
1906        let error = PocketIcStartupConfig::Spawn {
1907            server_binary: ("pocket-ic").into(),
1908            timeout: Duration::from_secs(1),
1909            server_hard_ttl: Some(Duration::from_millis(1)),
1910            server_idle_ttl: None,
1911            server_output_files: None,
1912        }
1913        .validate()
1914        .expect_err("subsecond server hard TTL must fail");
1915        assert!(matches!(
1916            error.failure(),
1917            PocketIcStartupFailure::InvalidConfiguration { .. }
1918        ));
1919    }
1920
1921    #[test]
1922    fn startup_files_leave_the_server_owned_port_path_absent() {
1923        let (files, stdout, stderr) = StartupFiles::create(None).expect("allocate startup files");
1924        let directory = files.directory.path().to_path_buf();
1925
1926        assert!(directory.is_dir());
1927        assert!(!files.port.exists());
1928        assert!(files.stdout.is_file());
1929        assert!(files.stderr.is_file());
1930        #[cfg(unix)]
1931        {
1932            let mode = fs::metadata(&directory)
1933                .expect("inspect private startup directory")
1934                .permissions()
1935                .mode();
1936            assert_eq!(mode & 0o077, 0);
1937        }
1938
1939        drop(stdout);
1940        drop(stderr);
1941        drop(files);
1942        assert!(!directory.exists());
1943    }
1944
1945    #[cfg(unix)]
1946    #[test]
1947    fn caller_output_files_survive_startup_command_and_cancellation_cleanup() {
1948        for outcome in [
1949            "timeout",
1950            "startup-exit",
1951            "server-exit",
1952            "command-exit",
1953            "cancel",
1954            "success",
1955        ] {
1956            let (owner, _, _) = StartupFiles::create(None).unwrap();
1957            let stdout = owner.directory.path().join("retained-stdout");
1958            let stderr = owner.directory.path().join("retained-stderr");
1959            let ending = match outcome {
1960                "timeout" => "exec sleep 30",
1961                "startup-exit" => "exit 41",
1962                "server-exit" => {
1963                    "printf '34567\\n' > \"$2\"; while [ ! -s \"$0.command\" ]; do sleep 0.02; done; exit 42"
1964                }
1965                _ => "printf '34567\\n' > \"$2\"; exec sleep 30",
1966            };
1967            let script = TestServerScript::new(
1968                outcome,
1969                &format!(
1970                    "#!/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"
1971                ),
1972            );
1973            let pid_file = script.path().with_extension("pid");
1974            let command_file = script.path().with_extension("command");
1975            let config = PocketIcStartupConfig::Spawn {
1976                server_binary: script.path(),
1977                timeout: Duration::from_millis(if outcome == "timeout" { 1000 } else { 2000 }),
1978                server_hard_ttl: None,
1979                server_idle_ttl: None,
1980                server_output_files: Some(((&stdout).into(), (&stderr).into())),
1981            };
1982            if outcome == "timeout" || outcome == "startup-exit" {
1983                let error = config.start_managed_server().err().unwrap();
1984                match (outcome, error.failure()) {
1985                    ("timeout", PocketIcStartupFailure::ReadinessTimeout { .. }) => {
1986                        assert!(error.output().stdout().contains("truncated"));
1987                        assert!(!error.output().stderr().contains("raw-stderr-end"));
1988                    }
1989                    ("startup-exit", PocketIcStartupFailure::ServerExited { status, .. }) => {
1990                        assert_eq!(status.code(), Some(41));
1991                    }
1992                    (_, error) => panic!("unexpected startup result: {error:?}"),
1993                }
1994            } else {
1995                let command_end = match outcome {
1996                    "command-exit" => "exit 37",
1997                    "success" => "exit 0",
1998                    _ => "exec sleep 30",
1999                };
2000                let result = config.run_command(
2001                    Command::new("/bin/sh")
2002                        .args([
2003                            "-c",
2004                            &format!("printf '%s' \"$$\" > \"$1\"; {command_end}"),
2005                            "fixture",
2006                        ])
2007                        .arg(&command_file),
2008                    || {
2009                        outcome == "cancel"
2010                            && fs::metadata(&command_file).is_ok_and(|m| m.len() > 0)
2011                    },
2012                );
2013                let result = result.as_ref().map_err(PocketIcStartupError::failure);
2014                match (outcome, result) {
2015                    ("command-exit", Ok(status)) => assert_eq!(status.code(), Some(37)),
2016                    ("success", Ok(status)) => assert!(status.success()),
2017                    ("server-exit", Err(PocketIcStartupFailure::ServerExited { status, .. })) => {
2018                        assert_eq!(status.code(), Some(42));
2019                    }
2020                    ("cancel", Err(PocketIcStartupFailure::CommandRun { source, .. })) => {
2021                        assert_eq!(source.kind(), std::io::ErrorKind::Interrupted);
2022                    }
2023                    (_, result) => panic!("unexpected command result: {result:?}"),
2024                }
2025                let pid = fs::read_to_string(&command_file).unwrap().parse().unwrap();
2026                assert!(process_state(pid).is_none_or(|state| state == 'Z'));
2027            }
2028            for (path, suffix) in [(&stdout, b"raw-stdout-end"), (&stderr, b"raw-stderr-end")] {
2029                let bytes = fs::read(path).unwrap();
2030                assert_eq!(bytes.len(), 20 * 1024 + suffix.len());
2031                assert!(bytes.ends_with(suffix));
2032                assert_eq!(
2033                    fs::metadata(path).unwrap().permissions().mode() & 0o777,
2034                    0o600
2035                );
2036            }
2037            let report = fs::read_to_string(&pid_file).unwrap();
2038            let mut lines = report.lines();
2039            assert_eq!(process_state(lines.next().unwrap().parse().unwrap()), None);
2040            assert!(
2041                !PathBuf::from(lines.next().unwrap())
2042                    .parent()
2043                    .unwrap()
2044                    .exists()
2045            );
2046            fs::remove_file(pid_file).unwrap();
2047        }
2048    }
2049
2050    #[cfg(unix)]
2051    #[test]
2052    fn caller_output_files_refuse_existing_entries_and_keep_partial_preparation() {
2053        use std::os::unix::fs::symlink;
2054
2055        let (owner, _, _) = StartupFiles::create(None).unwrap();
2056        let stdout = owner.directory.path().join("retained-stdout");
2057        let stderr = owner.directory.path().join("retained-stderr");
2058        let unrelated = owner.directory.path().join("unrelated");
2059        fs::write(&unrelated, b"original").unwrap();
2060        symlink(&unrelated, &stderr).unwrap();
2061        let error = StartupFiles::create(Some((stdout.clone(), stderr.clone())))
2062            .err()
2063            .unwrap();
2064        assert!(
2065            matches!(error.failure(), PocketIcStartupFailure::Io { source, .. } if source.kind() == std::io::ErrorKind::AlreadyExists)
2066        );
2067        assert!(stdout.is_file());
2068        assert_eq!(fs::read(&unrelated).unwrap(), b"original");
2069        fs::write(&stdout, b"retained attempt").unwrap();
2070        assert!(StartupFiles::create(Some((stdout.clone(), stderr.clone()))).is_err());
2071        assert_eq!(fs::read(&stdout).unwrap(), b"retained attempt");
2072        fs::remove_file(stderr.clone()).unwrap();
2073        assert!(
2074            Command::new("mkfifo")
2075                .arg(&stderr)
2076                .status()
2077                .unwrap()
2078                .success()
2079        );
2080        fs::remove_file(stdout.clone()).unwrap();
2081        assert!(StartupFiles::create(Some((stdout.clone(), stderr))).is_err());
2082        assert!(stdout.is_file());
2083    }
2084
2085    #[cfg(unix)]
2086    #[test]
2087    fn managed_startup_reports_an_exited_server_with_bounded_output() {
2088        let script = TestServerScript::new(
2089            "exit",
2090            "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nprintf 'synthetic server stdout'\nprintf 'synthetic bind failure' >&2\nexit 23\n",
2091        );
2092
2093        let result = PocketIcStartupConfig::Spawn {
2094            server_binary: script.path(),
2095            timeout: Duration::from_secs(2),
2096            server_hard_ttl: None,
2097            server_idle_ttl: None,
2098            server_output_files: None,
2099        }
2100        .build_instance(PocketIcBuilder::new().with_application_subnet());
2101
2102        let error = result.err().unwrap();
2103        let PocketIcStartupFailure::ServerExited {
2104            server_binary,
2105            status,
2106            ..
2107        } = error.failure()
2108        else {
2109            panic!("expected a structured server exit, got {error:?}");
2110        };
2111        assert_eq!(server_binary, &script.path());
2112        assert_eq!(status.code(), Some(23));
2113        assert_eq!(error.output().stdout(), "synthetic server stdout");
2114        assert_eq!(error.output().stderr(), "synthetic bind failure");
2115    }
2116
2117    #[cfg(unix)]
2118    #[test]
2119    fn managed_startup_rejects_oversized_port_files_and_cleans_up() {
2120        let script = TestServerScript::new(
2121            "oversized-port",
2122            "#!/bin/sh\nprintf '%s\\n%s\\n' \"$$\" \"$2\"\nprintf '34567\\n%064s' '' > \"$2.pending\"\nmv \"$2.pending\" \"$2\"\nexec sleep 30\n",
2123        );
2124        let result = PocketIcStartupConfig::Spawn {
2125            server_binary: script.path(),
2126            timeout: Duration::from_secs(2),
2127            server_hard_ttl: None,
2128            server_idle_ttl: None,
2129            server_output_files: None,
2130        }
2131        .start_managed_server();
2132        let error = result.err().unwrap();
2133        let PocketIcStartupFailure::InvalidServerPort { value, .. } = error.failure() else {
2134            panic!("oversized port publication must fail readiness");
2135        };
2136        assert!(value.contains("port file exceeds"));
2137        assert!(value.len() < 256);
2138        let mut lines = error.output().stdout().lines();
2139        let pid = lines.next().unwrap().parse::<u32>().unwrap();
2140        let port_path = PathBuf::from(lines.next().unwrap());
2141        assert!(!port_path.parent().unwrap().exists());
2142        assert_eq!(process_state(pid), None, "failed server must be reaped");
2143    }
2144
2145    #[cfg(unix)]
2146    #[test]
2147    fn managed_startup_terminates_a_server_that_never_becomes_ready() {
2148        let script = TestServerScript::new(
2149            "timeout",
2150            "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nexec sleep 30\n",
2151        );
2152        let timeout = Duration::from_millis(100);
2153        let started = Instant::now();
2154
2155        let result = PocketIcStartupConfig::Spawn {
2156            server_binary: script.path(),
2157            timeout,
2158            server_hard_ttl: None,
2159            server_idle_ttl: None,
2160            server_output_files: None,
2161        }
2162        .build_instance(PocketIcBuilder::new().with_application_subnet());
2163
2164        assert!(
2165            started.elapsed() < Duration::from_secs(2),
2166            "bounded startup should not wait for the sleeping child"
2167        );
2168        let error = result.err().unwrap();
2169        assert!(
2170            matches!(error.failure(), PocketIcStartupFailure::ReadinessTimeout {
2171            server_binary, timeout: actual_timeout,
2172        } if server_binary == &script.path() && *actual_timeout == timeout)
2173        );
2174        assert!(error.server_cleanup().is_none());
2175    }
2176
2177    #[cfg(unix)]
2178    #[test]
2179    fn managed_server_handle_exposes_process_id_url_output_and_raii_ownership() {
2180        let script = TestServerScript::new(
2181            "handle",
2182            "#!/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",
2183        );
2184
2185        let server = PocketIcStartupConfig::Spawn {
2186            server_binary: script.path(),
2187            timeout: Duration::from_secs(2),
2188            server_hard_ttl: None,
2189            server_idle_ttl: None,
2190            server_output_files: None,
2191        }
2192        .start_managed_server()
2193        .expect("start caller-owned managed server");
2194
2195        assert_eq!(server.url(), "http://127.0.0.1:34567/");
2196        assert_eq!(
2197            server.output().stdout(),
2198            format!("managed server ready: {}", server.process_id())
2199        );
2200        assert_eq!(server.output().stderr(), "");
2201        let pid = server.process_id();
2202        assert!(process_state(pid).is_some_and(|state| state != 'Z'));
2203        drop(server);
2204        assert_eq!(process_state(pid), None, "owned server must be reaped");
2205    }
2206
2207    #[cfg(unix)]
2208    #[test]
2209    fn managed_server_cleans_descendants_on_drop_timeout_and_exit() {
2210        for mode in ["drop", "timeout", "exit"] {
2211            let publish = if mode == "drop" {
2212                "printf '34567\\n' > \"$2\"\n"
2213            } else {
2214                ""
2215            };
2216            let finish = if mode == "exit" {
2217                "sleep 0.03\nexit 23\n"
2218            } else {
2219                "exec sleep 30\n"
2220            };
2221            let script = TestServerScript::new(
2222                mode,
2223                &format!("#!/bin/sh\nsleep 30 &\nprintf '%s' \"$!\"\n{publish}{finish}"),
2224            );
2225            let result = PocketIcStartupConfig::Spawn {
2226                server_binary: script.path(),
2227                timeout: Duration::from_millis(300),
2228                server_hard_ttl: None,
2229                server_idle_ttl: None,
2230                server_output_files: None,
2231            }
2232            .start_managed_server();
2233            let output = match result {
2234                Ok(server) => {
2235                    let output = server.output().stdout().to_owned();
2236                    drop(server);
2237                    output
2238                }
2239                Err(error) => {
2240                    match error.failure() {
2241                        PocketIcStartupFailure::ReadinessTimeout { .. } => {
2242                            assert_eq!(mode, "timeout");
2243                            assert!(error.server_cleanup().is_none());
2244                        }
2245                        PocketIcStartupFailure::ServerExited { status, .. } => {
2246                            assert_eq!(mode, "exit");
2247                            assert_eq!(status.code(), Some(23));
2248                        }
2249                        _ => panic!("unexpected {mode} startup result: {error}"),
2250                    }
2251                    error.output().stdout().to_owned()
2252                }
2253            };
2254            let pid = output
2255                .parse::<u32>()
2256                .expect("server published its descendant PID");
2257            let deadline = Instant::now() + Duration::from_secs(2);
2258            while process_state(pid).is_some_and(|state| state != 'Z') {
2259                assert!(
2260                    Instant::now() < deadline,
2261                    "{mode} left its descendant running"
2262                );
2263                std::thread::sleep(Duration::from_millis(10));
2264            }
2265        }
2266    }
2267
2268    #[cfg(unix)]
2269    #[test]
2270    fn managed_server_passes_an_explicit_hard_ttl() {
2271        let script = TestServerScript::new(
2272            "hard-ttl",
2273            "#!/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",
2274        );
2275
2276        let server = PocketIcStartupConfig::Spawn {
2277            server_binary: script.path(),
2278            timeout: Duration::from_secs(2),
2279            server_hard_ttl: Some(Duration::from_secs(17)),
2280            server_idle_ttl: None,
2281            server_output_files: None,
2282        }
2283        .start_managed_server()
2284        .expect("start managed server with an explicit hard TTL");
2285
2286        assert_eq!(server.url(), "http://127.0.0.1:34567/");
2287    }
2288
2289    #[cfg(unix)]
2290    #[test]
2291    #[ignore = "requires POCKET_IC_BIN=<caller-provided PocketIC server binary>"]
2292    fn caller_provided_server_publishes_port_constructs_instance_and_cleans_up() {
2293        let binary = std::env::var_os("POCKET_IC_BIN")
2294            .map(PathBuf::from)
2295            .expect("set POCKET_IC_BIN to the exact server binary");
2296        // Observe this exact child's port path without inspecting other startup
2297        // directories created concurrently.
2298        let wrapper = TestServerScript::new(
2299            "real-owned-instance",
2300            "#!/bin/sh\nprintf '%s\n%s\n' \"$$\" \"$4\" > \"$0.record\"\nexec \"$POCKET_IC_BIN\" \"$@\"\n",
2301        );
2302        let owned = PocketIcStartupConfig::Spawn {
2303            server_binary: wrapper.path(),
2304            timeout: Duration::from_secs(30),
2305            server_hard_ttl: Some(Duration::from_secs(60)),
2306            server_idle_ttl: None,
2307            server_output_files: None,
2308        }
2309        .build_instance(PocketIcBuilder::new().with_application_subnet())
2310        .expect("owned managed spawn must construct an instance");
2311        let record_file = wrapper.path().with_extension("record");
2312        let record = fs::read_to_string(&record_file).unwrap();
2313        let mut fields = record.lines();
2314        let pid = fields.next().unwrap().parse().unwrap();
2315        let port_path = PathBuf::from(fields.next().unwrap());
2316        let startup_directory = port_path.parent().unwrap();
2317        drop(owned);
2318        assert!(!startup_directory.exists());
2319        assert_eq!(process_state(pid), None);
2320        fs::remove_file(record_file).unwrap();
2321
2322        let server = PocketIcStartupConfig::Spawn {
2323            server_binary: (&binary).into(),
2324            timeout: Duration::from_secs(30),
2325            server_hard_ttl: Some(Duration::from_secs(60)),
2326            server_idle_ttl: None,
2327            server_output_files: None,
2328        }
2329        .start_managed_server()
2330        .expect("caller-provided PocketIC server must publish its port");
2331        let files = &server.server.files;
2332        let startup_directory = files.directory.path().to_path_buf();
2333
2334        assert!(files.port.is_file());
2335        let pocket_ic = PocketIcStartupConfig::Connect {
2336            server_url: (server.url()).into(),
2337            timeout: Duration::from_secs(30),
2338        }
2339        .build_instance(PocketIcBuilder::new().with_application_subnet())
2340        .expect("construct instance through caller-provided server");
2341
2342        drop(pocket_ic);
2343        drop(server);
2344        assert!(!startup_directory.exists());
2345    }
2346
2347    #[cfg(unix)]
2348    struct TestServerScript {
2349        path: PathBuf,
2350        #[expect(
2351            dead_code,
2352            reason = "retains directory ownership until cleanup or drop"
2353        )]
2354        directory: OwnedDirectory,
2355    }
2356
2357    #[cfg(unix)]
2358    impl TestServerScript {
2359        fn new(label: &str, contents: &str) -> Self {
2360            let directory = OwnedDirectory::new(
2361                create_unique_directory(
2362                    &std::env::temp_dir(),
2363                    &format!("ic-testkit-pocket-ic-{label}"),
2364                    0o700,
2365                    None,
2366                )
2367                .expect("create test server directory"),
2368            );
2369            let path = directory.path().join("server");
2370            write_executable_script(&path, contents).expect("write executable test script");
2371            Self { path, directory }
2372        }
2373
2374        fn path(&self) -> PathBuf {
2375            self.path.clone()
2376        }
2377    }
2378}