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