Skip to main content

ic_testkit/pic/
startup.rs

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