Skip to main content

ic_testkit/pic/
startup.rs

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