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