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