Skip to main content

ic_testkit/pic/
startup.rs

1use std::{
2    fmt::Write as _,
3    fs::{self, File, OpenOptions},
4    io::{self, Read as _},
5    path::{Path, PathBuf},
6    process::{Child, Command, ExitStatus, Stdio},
7    sync::{
8        atomic::{AtomicU64, Ordering},
9        mpsc::{self, RecvTimeoutError},
10    },
11    thread,
12    time::{Duration, Instant},
13};
14
15#[cfg(unix)]
16use std::os::unix::{
17    fs::{DirBuilderExt as _, OpenOptionsExt as _},
18    process::CommandExt as _,
19};
20
21use pocket_ic::{PocketIc, PocketIcBuilder};
22
23use super::transport;
24
25const STARTUP_POLL_INTERVAL: Duration = Duration::from_millis(20);
26const SERVER_OUTPUT_LIMIT: usize = 16 * 1024;
27// PocketIC publishes a decimal u16 and a newline. Leave whitespace room
28// without allowing readiness polling to read an arbitrary-size file.
29const SERVER_PORT_FILE_LIMIT: usize = 64;
30
31static STARTUP_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
32
33/// Explicit bounded source and policy for one PocketIC startup.
34#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct PocketIcStartupConfig {
36    source: PocketIcStartupSource,
37    timeout: Duration,
38    server_hard_ttl: Option<Duration>,
39}
40
41/// Caller-owned PocketIC server process with bounded startup and output capture.
42///
43/// Dropping the handle terminates and waits for the managed child. On Unix,
44/// teardown also terminates descendants remaining in its owned process group.
45/// Callers may create several instances through [`Self::url`] and
46/// [`PocketIcStartupConfig::connect`] while retaining explicit server ownership.
47/// The handle is process-local and does not coordinate ownership across Cargo
48/// or test-runner processes; use an externally owned server with bounded
49/// connect mode for that topology.
50/// The handle owns no binary discovery, download, cache, or compatibility policy.
51pub struct PocketIcManagedServer {
52    server: ManagedServer,
53    url: String,
54}
55
56/// Bounded lossy UTF-8 output captured from a managed PocketIC server.
57///
58/// Each stream retains at most the first 16 KiB. A textual suffix reports the
59/// number of omitted bytes when truncation occurred. Unreadable streams and
60/// paths replaced with non-regular files are omitted.
61#[derive(Clone, Debug, Default, Eq, PartialEq)]
62pub struct PocketIcManagedServerOutput {
63    stdout: String,
64    stderr: String,
65}
66
67#[derive(Clone, Debug, Eq, PartialEq)]
68enum PocketIcStartupSource {
69    Spawn { server_binary: PathBuf },
70    Connect { server_url: String },
71}
72
73/// Structured failure from bounded PocketIC construction.
74#[non_exhaustive]
75#[derive(Debug)]
76pub enum PocketIcStartupError {
77    /// The caller supplied a zero timeout or unusable hard TTL.
78    InvalidConfiguration { message: String },
79    /// A caller-provided existing server URL could not be parsed.
80    InvalidServerUrl { server_url: String, message: String },
81    /// Preparing or inspecting bounded startup files failed.
82    Io {
83        operation: &'static str,
84        path: PathBuf,
85        source: io::Error,
86    },
87    /// The configured PocketIC server process could not be spawned.
88    ServerSpawn {
89        server_binary: PathBuf,
90        source: io::Error,
91    },
92    /// The managed PocketIC server exited before instance construction completed.
93    ServerExited {
94        server_binary: PathBuf,
95        status: ExitStatus,
96        elapsed: Duration,
97        stdout: String,
98        stderr: String,
99    },
100    /// The managed server did not publish a usable port before the deadline.
101    ReadinessTimeout {
102        server_binary: PathBuf,
103        timeout: Duration,
104        stdout: String,
105        stderr: String,
106        termination_error: Option<String>,
107    },
108    /// The managed server published an invalid or oversized port-file value.
109    InvalidServerPort {
110        server_binary: PathBuf,
111        value: String,
112        stdout: String,
113        stderr: String,
114    },
115    /// PocketIC instance creation did not finish before the startup deadline.
116    InstanceCreationTimeout {
117        timeout: Duration,
118        stdout: String,
119        stderr: String,
120        termination_error: Option<String>,
121    },
122    /// Spawning the bounded builder worker failed.
123    BuilderThreadSpawn { source: io::Error },
124    /// Upstream PocketIC construction panicked before returning an instance.
125    BuilderPanicked { message: String },
126    /// The bounded builder worker ended without returning a result.
127    BuilderDisconnected,
128}
129
130/// Fallible construction at PocketIC's panicking builder boundary.
131///
132/// Startup is explicit: callers either provide an existing server URL or let
133/// `ic-testkit` spawn and monitor one exact server binary. This prevents the
134/// upstream builder from hiding an unobservable child process.
135pub trait PocketIcBuilderExt {
136    /// Build one PocketIC instance within the configured deadline.
137    ///
138    /// Managed server startup detects child exit while awaiting the port file,
139    /// terminates the child on timeout, and reads bounded stdout/stderr prefixes.
140    /// Instance creation is also bounded. Upstream panics remain structured.
141    ///
142    /// This deadline covers construction only. Dropping the returned instance
143    /// uses PocketIC's synchronous HTTP deletion, which has no request deadline
144    /// in PocketIC 16. An operation's maximum request time does not bound drop.
145    fn try_build(self, config: PocketIcStartupConfig) -> Result<PocketIc, PocketIcStartupError>;
146}
147
148impl PocketIcStartupConfig {
149    /// Spawn and monitor one exact PocketIC server binary.
150    ///
151    /// Startup allocates a unique private temporary directory while leaving
152    /// the `--port-file` path absent for PocketIC to create.
153    #[must_use]
154    pub fn spawn(server_binary: impl Into<PathBuf>, timeout: Duration) -> Self {
155        Self {
156            source: PocketIcStartupSource::Spawn {
157                server_binary: server_binary.into(),
158            },
159            timeout,
160            server_hard_ttl: None,
161        }
162    }
163
164    /// Connect to a caller-owned existing PocketIC server.
165    ///
166    /// The URL is applied to the builder explicitly, so this mode never lets
167    /// the upstream builder spawn a hidden server child.
168    #[must_use]
169    pub fn connect(server_url: impl Into<String>, timeout: Duration) -> Self {
170        Self {
171            source: PocketIcStartupSource::Connect {
172                server_url: server_url.into(),
173            },
174            timeout,
175            server_hard_ttl: None,
176        }
177    }
178
179    /// Set the hard lifetime passed to an `ic-testkit`-managed server.
180    #[must_use]
181    pub const fn with_server_hard_ttl(mut self, hard_ttl: Duration) -> Self {
182        self.server_hard_ttl = Some(hard_ttl);
183        self
184    }
185
186    /// Complete startup deadline.
187    #[must_use]
188    pub const fn timeout(&self) -> Duration {
189        self.timeout
190    }
191
192    /// Explicit managed server hard lifetime, or `None` when disabled.
193    #[must_use]
194    pub const fn server_hard_ttl(&self) -> Option<Duration> {
195        self.server_hard_ttl
196    }
197
198    /// Managed server binary, when this configuration spawns one.
199    #[must_use]
200    pub fn server_binary(&self) -> Option<&Path> {
201        match &self.source {
202            PocketIcStartupSource::Spawn { server_binary } => Some(server_binary),
203            PocketIcStartupSource::Connect { .. } => None,
204        }
205    }
206
207    /// Existing caller-owned server URL, when configured.
208    #[must_use]
209    pub fn server_url(&self) -> Option<&str> {
210        match &self.source {
211            PocketIcStartupSource::Connect { server_url } => Some(server_url),
212            PocketIcStartupSource::Spawn { .. } => None,
213        }
214    }
215
216    /// Start a caller-owned managed server without constructing an instance.
217    ///
218    /// This requires a configuration created by [`Self::spawn`]. Readiness is
219    /// bounded by [`Self::timeout`]. No hard TTL is passed by default; an
220    /// explicit [`Self::with_server_hard_ttl`] value is passed to the child.
221    /// Readiness requires a nonzero decimal port followed by a newline in a
222    /// regular UTF-8 file of at most 64 bytes; oversized files fail with bounded
223    /// diagnostics. Non-regular port files fail with [`PocketIcStartupError::Io`]
224    /// and [`io::ErrorKind::InvalidData`] without waiting for a FIFO writer.
225    /// The returned handle terminates the child on drop; use its URL with
226    /// [`Self::connect`] to construct bounded instances.
227    pub fn start_managed_server(self) -> Result<PocketIcManagedServer, PocketIcStartupError> {
228        self.validate()?;
229        let PocketIcStartupSource::Spawn { server_binary } = self.source else {
230            return Err(PocketIcStartupError::InvalidConfiguration {
231                message: "starting a managed PocketIC server requires a spawn configuration"
232                    .to_owned(),
233            });
234        };
235        let started = Instant::now();
236        let deadline = startup_deadline(started, self.timeout)?;
237        let (server, url) = ManagedServer::start(
238            server_binary,
239            self.server_hard_ttl,
240            deadline,
241            self.timeout,
242            started,
243        )?;
244        Ok(PocketIcManagedServer { server, url })
245    }
246
247    fn validate(&self) -> Result<(), PocketIcStartupError> {
248        if self.timeout.is_zero() {
249            return Err(PocketIcStartupError::InvalidConfiguration {
250                message: "PocketIC startup timeout must be greater than zero".to_owned(),
251            });
252        }
253        if matches!(&self.source, PocketIcStartupSource::Spawn { .. })
254            && self
255                .server_hard_ttl
256                .is_some_and(|hard_ttl| hard_ttl.as_secs() == 0)
257        {
258            return Err(PocketIcStartupError::InvalidConfiguration {
259                message: "PocketIC server hard TTL must be at least one second".to_owned(),
260            });
261        }
262        Ok(())
263    }
264}
265
266impl PocketIcManagedServer {
267    /// OS process ID of the owned server child, for caller-managed monitoring.
268    ///
269    /// This identifies the server process, not its descendants, and does not
270    /// establish that it is still running. The OS may reuse the ID after the
271    /// child exits and is reaped. Dropping this handle terminates and waits for
272    /// the child; retaining the ID does not retain server ownership.
273    #[must_use]
274    pub fn process_id(&self) -> u32 {
275        self.server
276            .child
277            .as_ref()
278            .expect("managed server handle must own its child")
279            .id()
280    }
281
282    /// Loopback URL published by the managed server.
283    #[must_use]
284    pub fn url(&self) -> &str {
285        &self.url
286    }
287
288    /// Current bounded stdout and stderr captured from the managed server.
289    ///
290    /// This reads at most the first 16 KiB from each retained output file,
291    /// renders it as lossy UTF-8, and adds an omitted-byte suffix when truncated.
292    /// The diagnostic read is bounded; files can grow while the server runs.
293    #[must_use]
294    pub fn output(&self) -> PocketIcManagedServerOutput {
295        self.server.capture().into()
296    }
297}
298
299impl PocketIcManagedServerOutput {
300    /// Bounded lossy UTF-8 standard output.
301    #[must_use]
302    pub fn stdout(&self) -> &str {
303        &self.stdout
304    }
305
306    /// Bounded lossy UTF-8 standard error.
307    #[must_use]
308    pub fn stderr(&self) -> &str {
309        &self.stderr
310    }
311}
312
313impl PocketIcBuilderExt for PocketIcBuilder {
314    fn try_build(self, config: PocketIcStartupConfig) -> Result<PocketIc, PocketIcStartupError> {
315        config.validate()?;
316        let started = Instant::now();
317        let deadline = startup_deadline(started, config.timeout)?;
318        match config.source {
319            PocketIcStartupSource::Connect { server_url } => {
320                build_bounded(self, &server_url, deadline, config.timeout, None)
321            }
322            PocketIcStartupSource::Spawn { server_binary } => {
323                let (server, server_url) = ManagedServer::start(
324                    server_binary,
325                    config.server_hard_ttl,
326                    deadline,
327                    config.timeout,
328                    started,
329                )?;
330                build_bounded(self, &server_url, deadline, config.timeout, Some(server))
331            }
332        }
333    }
334}
335
336fn startup_deadline(started: Instant, timeout: Duration) -> Result<Instant, PocketIcStartupError> {
337    started
338        .checked_add(timeout)
339        .ok_or_else(|| PocketIcStartupError::InvalidConfiguration {
340            message: "PocketIC startup timeout exceeds the platform clock range".to_owned(),
341        })
342}
343
344fn build_bounded(
345    builder: PocketIcBuilder,
346    server_url: &str,
347    deadline: Instant,
348    timeout: Duration,
349    mut server: Option<ManagedServer>,
350) -> Result<PocketIc, PocketIcStartupError> {
351    let builder = match server_url.parse() {
352        Ok(server_url) => builder.with_server_url(server_url),
353        Err(error) => {
354            return Err(PocketIcStartupError::InvalidServerUrl {
355                server_url: server_url.to_owned(),
356                message: error.to_string(),
357            });
358        }
359    };
360    let (sender, receiver) = mpsc::sync_channel(1);
361    if let Err(source) = thread::Builder::new()
362        .name("ic-testkit-pocket-ic-startup".to_owned())
363        .spawn(move || {
364            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| builder.build()))
365                .map_err(|payload| transport::panic_payload_to_string(payload.as_ref()));
366            let _ = sender.send(result);
367        })
368    {
369        return Err(PocketIcStartupError::BuilderThreadSpawn { source });
370    }
371
372    loop {
373        let now = Instant::now();
374        if now >= deadline {
375            let captured = server.take().map_or_else(
376                CapturedServer::default,
377                ManagedServer::terminate_and_capture,
378            );
379            return Err(PocketIcStartupError::InstanceCreationTimeout {
380                timeout,
381                stdout: captured.stdout,
382                stderr: captured.stderr,
383                termination_error: captured.termination_error,
384            });
385        }
386        let remaining = deadline.saturating_duration_since(now);
387        let wait = if server.is_some() {
388            remaining.min(STARTUP_POLL_INTERVAL)
389        } else {
390            remaining
391        };
392        match receiver.recv_timeout(wait) {
393            Ok(Ok(pocket_ic)) => {
394                if let Some(mut managed) = server.take() {
395                    if let Some(status) = managed.try_wait()? {
396                        return Err(managed.exited_error(status));
397                    }
398                    managed.reap_in_background();
399                }
400                return Ok(pocket_ic);
401            }
402            Ok(Err(message)) => {
403                if let Some(server) = server.take() {
404                    let _ = server.terminate_and_capture();
405                }
406                return Err(PocketIcStartupError::BuilderPanicked { message });
407            }
408            Err(RecvTimeoutError::Disconnected) => {
409                if let Some(server) = server.take() {
410                    let _ = server.terminate_and_capture();
411                }
412                return Err(PocketIcStartupError::BuilderDisconnected);
413            }
414            Err(RecvTimeoutError::Timeout) => {
415                if let Some(managed) = &mut server
416                    && let Some(status) = managed.try_wait()?
417                {
418                    return Err(server
419                        .take()
420                        .expect("managed server must remain present")
421                        .exited_error(status));
422                }
423            }
424        }
425    }
426}
427
428struct ManagedServer {
429    child: Option<Child>,
430    binary: PathBuf,
431    files: StartupFiles,
432    started: Instant,
433}
434
435enum PortFileState {
436    Pending,
437    Ready(u16),
438    Invalid(String),
439}
440
441impl ManagedServer {
442    fn start(
443        binary: PathBuf,
444        hard_ttl: Option<Duration>,
445        deadline: Instant,
446        timeout: Duration,
447        started: Instant,
448    ) -> Result<(Self, String), PocketIcStartupError> {
449        let (files, stdout, stderr) = StartupFiles::create()?;
450        let mut command = Command::new(&binary);
451        if let Some(hard_ttl) = hard_ttl {
452            command
453                .arg("--hard-ttl")
454                .arg(hard_ttl.as_secs().to_string());
455        }
456        command
457            .arg("--port-file")
458            .arg(&files.port)
459            .stdout(Stdio::from(stdout))
460            .stderr(Stdio::from(stderr));
461        #[cfg(unix)]
462        {
463            command.process_group(0);
464        }
465        let child = command
466            .spawn()
467            .map_err(|source| PocketIcStartupError::ServerSpawn {
468                server_binary: binary.clone(),
469                source,
470            })?;
471        let mut server = Self {
472            child: Some(child),
473            binary,
474            files,
475            started,
476        };
477
478        loop {
479            if let Some(status) = server.try_wait()? {
480                return Err(server.exited_error(status));
481            }
482            let now = Instant::now();
483            if now >= deadline {
484                let binary = server.binary.clone();
485                let captured = server.terminate_and_capture();
486                return Err(PocketIcStartupError::ReadinessTimeout {
487                    server_binary: binary,
488                    timeout,
489                    stdout: captured.stdout,
490                    stderr: captured.stderr,
491                    termination_error: captured.termination_error,
492                });
493            }
494            match server.read_port()? {
495                PortFileState::Pending => {}
496                PortFileState::Ready(port) => {
497                    return Ok((server, format!("http://127.0.0.1:{port}/")));
498                }
499                PortFileState::Invalid(value) => {
500                    let binary = server.binary.clone();
501                    let captured = server.terminate_and_capture();
502                    return Err(PocketIcStartupError::InvalidServerPort {
503                        server_binary: binary,
504                        value,
505                        stdout: captured.stdout,
506                        stderr: captured.stderr,
507                    });
508                }
509            }
510            thread::sleep(
511                deadline
512                    .saturating_duration_since(now)
513                    .min(STARTUP_POLL_INTERVAL),
514            );
515        }
516    }
517
518    fn try_wait(&mut self) -> Result<Option<ExitStatus>, PocketIcStartupError> {
519        let child = self
520            .child
521            .as_mut()
522            .expect("managed server child must remain present");
523        poll_child(child).map_err(|source| PocketIcStartupError::Io {
524            operation: "inspect PocketIC server child",
525            path: self.binary.clone(),
526            source,
527        })
528    }
529
530    fn read_port(&self) -> Result<PortFileState, PocketIcStartupError> {
531        let port_path = &self.files.port;
532        let mut contents = String::new();
533        match open_regular_startup_file(port_path).and_then(|file| {
534            file.take((SERVER_PORT_FILE_LIMIT + 1) as u64)
535                .read_to_string(&mut contents)
536        }) {
537            Ok(_) => {}
538            Err(error) if error.kind() == io::ErrorKind::NotFound => {
539                return Ok(PortFileState::Pending);
540            }
541            Err(source) => {
542                return Err(PocketIcStartupError::Io {
543                    operation: "read PocketIC server port file",
544                    path: port_path.clone(),
545                    source,
546                });
547            }
548        }
549        if contents.len() > SERVER_PORT_FILE_LIMIT {
550            return Ok(PortFileState::Invalid(format!(
551                "{} (port file exceeds {SERVER_PORT_FILE_LIMIT} bytes)",
552                contents.trim()
553            )));
554        }
555        if !contents.contains('\n') {
556            return Ok(PortFileState::Pending);
557        }
558        let value = contents.trim().to_owned();
559        match value.parse::<u16>() {
560            Ok(port) if port != 0 => Ok(PortFileState::Ready(port)),
561            _ => Ok(PortFileState::Invalid(value)),
562        }
563    }
564
565    fn exited_error(mut self, status: ExitStatus) -> PocketIcStartupError {
566        let elapsed = self.started.elapsed();
567        let binary = self.binary.clone();
568        self.child.take();
569        let captured = self.capture();
570        PocketIcStartupError::ServerExited {
571            server_binary: binary,
572            status,
573            elapsed,
574            stdout: captured.stdout,
575            stderr: captured.stderr,
576        }
577    }
578
579    fn terminate_and_capture(mut self) -> CapturedServer {
580        let termination_error = match self.child.take() {
581            Some(mut child) => terminate_child(&mut child),
582            None => None,
583        };
584        let mut captured = self.capture();
585        captured.termination_error = termination_error;
586        captured
587    }
588
589    fn capture(&self) -> CapturedServer {
590        let files = &self.files;
591        CapturedServer {
592            stdout: read_bounded_lossy(&files.stdout),
593            stderr: read_bounded_lossy(&files.stderr),
594            termination_error: None,
595        }
596    }
597
598    fn reap_in_background(self) {
599        let _ = thread::Builder::new()
600            .name("ic-testkit-pocket-ic-server-reaper".to_owned())
601            .spawn(move || {
602                // Keep child and files under one owner, including if spawning
603                // this thread fails. On Unix, Drop terminates the group before reaping.
604                let mut server = self;
605                if let Some(child) = server.child.as_mut() {
606                    #[cfg(unix)]
607                    let _ = wait_for_child_exit(child, false);
608                    #[cfg(not(unix))]
609                    let _ = child.wait();
610                }
611            });
612    }
613}
614
615impl Drop for ManagedServer {
616    fn drop(&mut self) {
617        if let Some(mut child) = self.child.take() {
618            let _ = terminate_child(&mut child);
619        }
620    }
621}
622
623#[cfg(unix)]
624fn wait_for_child_exit(child: &Child, nonblocking: bool) -> io::Result<bool> {
625    // WNOWAIT keeps the leader's PID reserved until group cleanup completes.
626    let flags = libc::WEXITED | libc::WNOWAIT | if nonblocking { libc::WNOHANG } else { 0 };
627    loop {
628        // SAFETY: siginfo_t is a C value for which zero initialization is valid.
629        let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
630        // SAFETY: the PID is an owned, unreaped child and info is writable.
631        let result = unsafe { libc::waitid(libc::P_PID, child.id(), &raw mut info, flags) };
632        if result == 0 {
633            return Ok(info.si_signo != 0);
634        }
635        let error = io::Error::last_os_error();
636        if error.kind() != io::ErrorKind::Interrupted {
637            return Err(error);
638        }
639    }
640}
641
642fn poll_child(child: &mut Child) -> io::Result<Option<ExitStatus>> {
643    #[cfg(unix)]
644    {
645        if !wait_for_child_exit(child, true)? {
646            return Ok(None);
647        }
648        // The leader is still unreaped, so its process group cannot be reused.
649        terminate_process_group(child)?;
650        child.wait().map(Some)
651    }
652    #[cfg(not(unix))]
653    child.try_wait()
654}
655
656#[cfg(unix)]
657fn terminate_process_group(child: &Child) -> io::Result<()> {
658    let group = libc::pid_t::try_from(child.id())
659        .map_err(|_| io::Error::other("managed child PID exceeds the OS process ID range"))?;
660    loop {
661        // SAFETY: spawn sets this child's PGID to its PID, and it remains
662        // unreaped until after this call. No caller/test-runner group is used.
663        let result = unsafe { libc::killpg(group, libc::SIGKILL) };
664        if result == 0 {
665            return Ok(());
666        }
667        let error = io::Error::last_os_error();
668        if error.raw_os_error() == Some(libc::ESRCH) {
669            return Ok(());
670        }
671        #[cfg(target_os = "macos")]
672        if error.raw_os_error() == Some(libc::EPERM) && wait_for_child_exit(child, true)? {
673            // Darwin's group signal path excludes zombies and can report EPERM
674            // for the unreaped leader alone. Verify that exact state; permission
675            // failures for a group with any other member still propagate.
676            // Two slots distinguish the sole leader from a larger/truncated group.
677            let mut members: [libc::pid_t; 2] = [0; 2];
678            let size = libc::c_int::try_from(std::mem::size_of_val(&members))
679                .expect("two process IDs fit in a libproc buffer size");
680            // SAFETY: members is writable for size bytes. The owned unreaped
681            // leader reserves this group ID across the query and later wait.
682            let count =
683                unsafe { libc::proc_listpgrppids(group, members.as_mut_ptr().cast(), size) };
684            if count == 1 && members[0] == group {
685                return Ok(());
686            }
687        }
688        if error.kind() != io::ErrorKind::Interrupted {
689            return Err(error);
690        }
691    }
692}
693
694#[cfg(unix)]
695fn terminate_child(child: &mut Child) -> Option<String> {
696    let termination = terminate_process_group(child);
697    if termination.is_err() {
698        let _ = child.kill();
699    }
700    let wait = child.wait();
701    termination
702        .and_then(|()| wait.map(|_| ()))
703        .err()
704        .map(|error| error.to_string())
705}
706
707#[cfg(not(unix))]
708fn terminate_child(child: &mut Child) -> Option<String> {
709    match child.try_wait() {
710        Ok(Some(_)) => None,
711        Ok(None) => child
712            .kill()
713            .and_then(|()| child.wait().map(|_| ()))
714            .err()
715            .map(|error| error.to_string()),
716        Err(inspect_error) => {
717            let termination_error = child.kill().and_then(|()| child.wait().map(|_| ())).err();
718            termination_error.map(|termination_error| {
719                format!(
720                    "failed to inspect child before termination: {inspect_error}; termination also failed: {termination_error}"
721                )
722            })
723        }
724    }
725}
726
727#[derive(Default)]
728struct CapturedServer {
729    stdout: String,
730    stderr: String,
731    termination_error: Option<String>,
732}
733
734impl From<CapturedServer> for PocketIcManagedServerOutput {
735    fn from(captured: CapturedServer) -> Self {
736        Self {
737            stdout: captured.stdout,
738            stderr: captured.stderr,
739        }
740    }
741}
742
743struct StartupFiles {
744    directory: PathBuf,
745    port: PathBuf,
746    stdout: PathBuf,
747    stderr: PathBuf,
748}
749
750impl StartupFiles {
751    fn create() -> Result<(Self, File, File), PocketIcStartupError> {
752        loop {
753            let sequence = STARTUP_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
754            let base = std::env::temp_dir().join(format!(
755                "ic-testkit-pocket-ic-startup-{}-{sequence}",
756                std::process::id()
757            ));
758            let mut directory = fs::DirBuilder::new();
759            #[cfg(unix)]
760            {
761                directory.mode(0o700);
762            }
763            match directory.create(&base) {
764                Ok(()) => {}
765                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
766                Err(source) => return Err(startup_file_error("create", &base, source)),
767            }
768            let files = Self {
769                port: base.join("port"),
770                stdout: base.join("stdout"),
771                stderr: base.join("stderr"),
772                directory: base,
773            };
774            let stdout = create_new_file(&files.stdout)
775                .map_err(|source| startup_file_error("create", &files.stdout, source))?;
776            let stderr = create_new_file(&files.stderr)
777                .map_err(|source| startup_file_error("create", &files.stderr, source))?;
778            return Ok((files, stdout, stderr));
779        }
780    }
781}
782
783impl Drop for StartupFiles {
784    fn drop(&mut self) {
785        let _ = fs::remove_dir_all(&self.directory);
786    }
787}
788
789fn create_new_file(path: &Path) -> io::Result<File> {
790    OpenOptions::new().write(true).create_new(true).open(path)
791}
792
793fn startup_file_error(
794    operation: &'static str,
795    path: &Path,
796    source: io::Error,
797) -> PocketIcStartupError {
798    PocketIcStartupError::Io {
799        operation,
800        path: path.to_owned(),
801        source,
802    }
803}
804
805fn read_bounded_lossy(path: &Path) -> String {
806    let Ok(file) = open_regular_startup_file(path) else {
807        return String::new();
808    };
809    let length = file.metadata().map_or(0, |metadata| metadata.len());
810    let mut bytes = Vec::with_capacity(SERVER_OUTPUT_LIMIT);
811    if file
812        .take(SERVER_OUTPUT_LIMIT as u64)
813        .read_to_end(&mut bytes)
814        .is_err()
815    {
816        return String::new();
817    }
818    let mut output = String::from_utf8_lossy(&bytes).into_owned();
819    let omitted = length.saturating_sub(bytes.len() as u64);
820    if omitted > 0 {
821        let _ = write!(output, "\n<truncated {omitted} bytes>");
822    }
823    output
824}
825
826fn open_regular_startup_file(path: &Path) -> io::Result<File> {
827    let mut options = OpenOptions::new();
828    options.read(true);
829    // Bound opening a replaced FIFO as well as reading file contents. Inspect
830    // the opened file, rather than a path that can change before open completes.
831    #[cfg(unix)]
832    options.custom_flags(libc::O_NONBLOCK);
833    let file = options.open(path)?;
834    if !file.metadata()?.is_file() {
835        return Err(io::Error::new(
836            io::ErrorKind::InvalidData,
837            "PocketIC startup reader requires a regular file",
838        ));
839    }
840    Ok(file)
841}
842
843impl std::fmt::Display for PocketIcStartupError {
844    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
845        match self {
846            Self::InvalidConfiguration { message } => formatter.write_str(message),
847            Self::InvalidServerUrl {
848                server_url,
849                message,
850            } => write!(
851                formatter,
852                "invalid PocketIC server URL {server_url:?}: {message}"
853            ),
854            Self::Io {
855                operation,
856                path,
857                source,
858            } => write!(
859                formatter,
860                "failed to {operation} at {}: {source}",
861                path.display()
862            ),
863            Self::ServerSpawn {
864                server_binary,
865                source,
866            } => write!(
867                formatter,
868                "failed to spawn PocketIC server {}: {source}",
869                server_binary.display()
870            ),
871            Self::ServerExited {
872                server_binary,
873                status,
874                elapsed,
875                stderr,
876                ..
877            } => write!(
878                formatter,
879                "PocketIC server {} exited with {status} after {elapsed:?}: {stderr}",
880                server_binary.display()
881            ),
882            Self::ReadinessTimeout {
883                server_binary,
884                timeout,
885                ..
886            } => write!(
887                formatter,
888                "PocketIC server {} was not ready within {timeout:?}",
889                server_binary.display()
890            ),
891            Self::InvalidServerPort {
892                server_binary,
893                value,
894                ..
895            } => write!(
896                formatter,
897                "PocketIC server {} published invalid port {value:?}",
898                server_binary.display()
899            ),
900            Self::InstanceCreationTimeout { timeout, .. } => {
901                write!(formatter, "PocketIC instance creation exceeded {timeout:?}")
902            }
903            Self::BuilderThreadSpawn { source } => {
904                write!(
905                    formatter,
906                    "failed to spawn PocketIC builder worker: {source}"
907                )
908            }
909            Self::BuilderPanicked { message } => {
910                write!(formatter, "PocketIC startup panicked: {message}")
911            }
912            Self::BuilderDisconnected => {
913                formatter.write_str("PocketIC builder worker disconnected without a result")
914            }
915        }
916    }
917}
918
919impl std::error::Error for PocketIcStartupError {
920    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
921        match self {
922            Self::Io { source, .. }
923            | Self::ServerSpawn { source, .. }
924            | Self::BuilderThreadSpawn { source } => Some(source),
925            _ => None,
926        }
927    }
928}
929
930#[cfg(test)]
931mod tests {
932    use std::{
933        fs,
934        path::PathBuf,
935        time::{Duration, Instant},
936    };
937
938    use super::{
939        PocketIcBuilderExt as _, PocketIcStartupConfig, PocketIcStartupError, StartupFiles,
940    };
941    use pocket_ic::PocketIcBuilder;
942
943    #[cfg(unix)]
944    use crate::test_executable::write_executable_script;
945    #[cfg(unix)]
946    use std::{
947        io::Write as _,
948        os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _},
949        process::Command,
950        sync::mpsc,
951    };
952
953    #[cfg(unix)]
954    fn process_state(pid: u32) -> Option<char> {
955        // Both supported Unix hosts provide this ps field. A zombie has stopped
956        // running but may remain visible until its parent reaps it.
957        let output = Command::new("/bin/ps")
958            .args(["-p", &pid.to_string(), "-o", "stat="])
959            .output()
960            .expect("inspect managed test process state");
961        assert!(
962            output.status.success()
963                || (output.status.code() == Some(1)
964                    && output.stdout.is_empty()
965                    && output.stderr.is_empty()),
966            "process-state inspection failed: {}: {}",
967            output.status,
968            String::from_utf8_lossy(&output.stderr),
969        );
970        String::from_utf8(output.stdout)
971            .expect("process state is ASCII")
972            .trim()
973            .chars()
974            .next()
975    }
976
977    #[cfg(unix)]
978    #[test]
979    fn reading_large_sparse_server_output_is_bounded() {
980        let (files, _, _) = StartupFiles::create().expect("allocate startup files");
981        let mut file = fs::File::create(&files.stdout).expect("create sparse log");
982        file.write_all(b"server started\n").expect("write prefix");
983        let size = 8_u64 * 1024 * 1024 * 1024;
984        file.set_len(size).expect("extend sparse log");
985        let output = super::read_bounded_lossy(&files.stdout);
986        assert!(output.starts_with("server started\n"));
987        assert!(output.ends_with(&format!(
988            "<truncated {} bytes>",
989            size - super::SERVER_OUTPUT_LIMIT as u64
990        )));
991        assert!(output.len() < super::SERVER_OUTPUT_LIMIT + 100);
992    }
993
994    #[cfg(unix)]
995    #[test]
996    fn startup_readers_reject_fifos_without_waiting_for_a_writer() {
997        let (files, stdout, stderr) = StartupFiles::create().expect("allocate startup files");
998        drop((stdout, stderr));
999        fs::remove_file(&files.stdout).unwrap();
1000        fs::remove_file(&files.stderr).unwrap();
1001        assert!(
1002            Command::new("mkfifo")
1003                .args([&files.port, &files.stdout, &files.stderr])
1004                .status()
1005                .expect("create FIFO startup files")
1006                .success()
1007        );
1008        let server = super::ManagedServer {
1009            child: None,
1010            binary: PathBuf::from("unused-server"),
1011            files,
1012            started: Instant::now(),
1013        };
1014        for path in [
1015            &server.files.port,
1016            &server.files.stdout,
1017            &server.files.stderr,
1018        ] {
1019            // A delayed writer bounds a blocked read and records whether the
1020            // reader needed it. Completion cancels the writer; with no reader,
1021            // a nonblocking open fails and is retried if the reader starts late.
1022            let fifo = path.clone();
1023            let (stop_writer, stopped) = mpsc::channel();
1024            let writer = std::thread::spawn(move || {
1025                loop {
1026                    match stopped.recv_timeout(Duration::from_millis(200)) {
1027                        Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return false,
1028                        Err(mpsc::RecvTimeoutError::Timeout) => {}
1029                    }
1030                    if fs::OpenOptions::new()
1031                        .write(true)
1032                        .custom_flags(libc::O_NONBLOCK)
1033                        .open(&fifo)
1034                        .is_ok()
1035                    {
1036                        return true;
1037                    }
1038                }
1039            });
1040            let result = if path == &server.files.port {
1041                Some(server.read_port())
1042            } else {
1043                assert_eq!(super::read_bounded_lossy(path), "");
1044                None
1045            };
1046            let _ = stop_writer.send(());
1047            assert!(
1048                !writer.join().expect("join delayed FIFO writer"),
1049                "startup reader waited for a writer: {}",
1050                path.display(),
1051            );
1052            if let Some(result) = result {
1053                assert!(matches!(
1054                    result,
1055                    Err(PocketIcStartupError::Io { source, .. })
1056                        if source.kind() == std::io::ErrorKind::InvalidData
1057                ));
1058            }
1059        }
1060    }
1061
1062    #[test]
1063    fn port_file_readiness_preserves_partial_writes_and_rejects_oversized_contents() {
1064        let (files, _, _) = StartupFiles::create().expect("allocate startup files");
1065        let server = super::ManagedServer {
1066            child: None,
1067            binary: PathBuf::from("unused-server"),
1068            files,
1069            started: Instant::now(),
1070        };
1071        assert!(matches!(
1072            server.read_port().unwrap(),
1073            super::PortFileState::Pending
1074        ));
1075        for contents in ["", "34567"] {
1076            fs::write(&server.files.port, contents).unwrap();
1077            assert!(matches!(
1078                server.read_port().unwrap(),
1079                super::PortFileState::Pending
1080            ));
1081        }
1082        for (contents, expected) in [("1\n", 1), ("65535\n", 65535), (" 34567\r\n", 34567)] {
1083            fs::write(&server.files.port, contents).unwrap();
1084            assert!(matches!(
1085                server.read_port().unwrap(),
1086                super::PortFileState::Ready(port) if port == expected
1087            ));
1088        }
1089        for contents in ["0\n", "65536\n", "invalid\n", "1\n2\n"] {
1090            fs::write(&server.files.port, contents).unwrap();
1091            assert!(matches!(
1092                server.read_port().unwrap(),
1093                super::PortFileState::Invalid(_)
1094            ));
1095        }
1096        fs::write(&server.files.port, [0xff, b'\n']).unwrap();
1097        assert!(matches!(
1098            server.read_port(),
1099            Err(PocketIcStartupError::Io { source, .. })
1100                if source.kind() == std::io::ErrorKind::InvalidData
1101        ));
1102        for contents in ["1\n".to_owned() + &" ".repeat(128), "0".repeat(128)] {
1103            fs::write(&server.files.port, contents).unwrap();
1104            assert!(
1105                matches!(
1106                    server.read_port().unwrap(),
1107                    super::PortFileState::Invalid(_)
1108                ),
1109                "oversized port contents must fail even without a newline",
1110            );
1111        }
1112        // A large backing file must not enlarge the returned diagnostic.
1113        fs::File::options()
1114            .write(true)
1115            .open(&server.files.port)
1116            .unwrap()
1117            .set_len(1024 * 1024)
1118            .unwrap();
1119        assert!(matches!(
1120            server.read_port().unwrap(),
1121            super::PortFileState::Invalid(value) if value.len() < 256
1122        ));
1123    }
1124
1125    #[test]
1126    fn startup_config_requires_positive_bounds() {
1127        let error = PocketIcStartupConfig::connect("http://127.0.0.1:1/", Duration::ZERO)
1128            .validate()
1129            .expect_err("zero startup timeout must fail");
1130        assert!(matches!(
1131            error,
1132            PocketIcStartupError::InvalidConfiguration { .. }
1133        ));
1134
1135        let error = PocketIcStartupConfig::spawn("pocket-ic", Duration::from_secs(1))
1136            .with_server_hard_ttl(Duration::from_millis(1))
1137            .validate()
1138            .expect_err("subsecond server hard TTL must fail");
1139        assert!(matches!(
1140            error,
1141            PocketIcStartupError::InvalidConfiguration { .. }
1142        ));
1143    }
1144
1145    #[test]
1146    fn managed_server_hard_ttl_is_opt_in() {
1147        let default = PocketIcStartupConfig::spawn("pocket-ic", Duration::from_secs(1));
1148        assert_eq!(default.server_hard_ttl(), None);
1149
1150        let explicit = default.with_server_hard_ttl(Duration::from_secs(17));
1151        assert_eq!(explicit.server_hard_ttl(), Some(Duration::from_secs(17)));
1152    }
1153
1154    #[test]
1155    fn startup_files_leave_the_server_owned_port_path_absent() {
1156        let (files, stdout, stderr) = StartupFiles::create().expect("allocate startup files");
1157        let directory = files.directory.clone();
1158
1159        assert!(directory.is_dir());
1160        assert!(!files.port.exists());
1161        assert!(files.stdout.is_file());
1162        assert!(files.stderr.is_file());
1163        #[cfg(unix)]
1164        {
1165            let mode = fs::metadata(&directory)
1166                .expect("inspect private startup directory")
1167                .permissions()
1168                .mode();
1169            assert_eq!(mode & 0o077, 0);
1170        }
1171
1172        drop(stdout);
1173        drop(stderr);
1174        drop(files);
1175        assert!(!directory.exists());
1176    }
1177
1178    #[cfg(unix)]
1179    #[test]
1180    fn managed_startup_reports_an_exited_server_with_bounded_output() {
1181        let script = TestServerScript::new(
1182            "exit",
1183            "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nprintf 'synthetic server stdout'\nprintf 'synthetic bind failure' >&2\nexit 23\n",
1184        );
1185
1186        let result = PocketIcBuilder::new().with_application_subnet().try_build(
1187            PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2)),
1188        );
1189
1190        let Err(PocketIcStartupError::ServerExited {
1191            server_binary,
1192            status,
1193            stdout,
1194            stderr,
1195            ..
1196        }) = result
1197        else {
1198            panic!(
1199                "an exited managed server must return a structured exit error; got {:?}",
1200                result.err(),
1201            );
1202        };
1203        assert_eq!(server_binary, script.path());
1204        assert_eq!(status.code(), Some(23));
1205        assert_eq!(stdout, "synthetic server stdout");
1206        assert_eq!(stderr, "synthetic bind failure");
1207    }
1208
1209    #[cfg(unix)]
1210    #[test]
1211    fn managed_startup_rejects_oversized_port_files_and_cleans_up() {
1212        let script = TestServerScript::new(
1213            "oversized-port",
1214            "#!/bin/sh\nprintf '%s\\n%s\\n' \"$$\" \"$2\"\nprintf '34567\\n%064s' '' > \"$2.pending\"\nmv \"$2.pending\" \"$2\"\nexec sleep 30\n",
1215        );
1216        let result = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
1217            .start_managed_server();
1218        let Err(PocketIcStartupError::InvalidServerPort { value, stdout, .. }) = result else {
1219            panic!("oversized port publication must fail readiness");
1220        };
1221        assert!(value.contains("port file exceeds"));
1222        assert!(value.len() < 256);
1223        let mut lines = stdout.lines();
1224        let pid = lines.next().unwrap().parse::<u32>().unwrap();
1225        let port_path = PathBuf::from(lines.next().unwrap());
1226        assert!(!port_path.parent().unwrap().exists());
1227        assert_eq!(process_state(pid), None, "failed server must be reaped");
1228    }
1229
1230    #[cfg(unix)]
1231    #[test]
1232    fn managed_startup_terminates_a_server_that_never_becomes_ready() {
1233        let script = TestServerScript::new(
1234            "timeout",
1235            "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nexec sleep 30\n",
1236        );
1237        let timeout = Duration::from_millis(100);
1238        let started = Instant::now();
1239
1240        let result = PocketIcBuilder::new()
1241            .with_application_subnet()
1242            .try_build(PocketIcStartupConfig::spawn(script.path(), timeout));
1243
1244        assert!(
1245            started.elapsed() < Duration::from_secs(2),
1246            "bounded startup should not wait for the sleeping child"
1247        );
1248        assert!(matches!(
1249            result,
1250            Err(PocketIcStartupError::ReadinessTimeout {
1251                server_binary,
1252                timeout: actual_timeout,
1253                termination_error: None,
1254                ..
1255            }) if server_binary == script.path() && actual_timeout == timeout
1256        ));
1257    }
1258
1259    #[cfg(unix)]
1260    #[test]
1261    fn managed_server_handle_exposes_process_id_url_output_and_raii_ownership() {
1262        let script = TestServerScript::new(
1263            "handle",
1264            "#!/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",
1265        );
1266
1267        let server = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
1268            .start_managed_server()
1269            .expect("start caller-owned managed server");
1270
1271        assert_eq!(server.url(), "http://127.0.0.1:34567/");
1272        assert_eq!(
1273            server.output().stdout(),
1274            format!("managed server ready: {}", server.process_id())
1275        );
1276        assert_eq!(server.output().stderr(), "");
1277        let pid = server.process_id();
1278        assert!(process_state(pid).is_some_and(|state| state != 'Z'));
1279        drop(server);
1280        assert_eq!(process_state(pid), None, "owned server must be reaped");
1281    }
1282
1283    #[cfg(unix)]
1284    #[test]
1285    fn managed_server_cleans_descendants_on_drop_timeout_exit_and_background_reap() {
1286        for mode in ["drop", "timeout", "exit", "background"] {
1287            let publish = if matches!(mode, "drop" | "background") {
1288                "printf '34567\\n' > \"$2\"\n"
1289            } else {
1290                ""
1291            };
1292            let finish = if mode == "background" {
1293                // The caller removes the port only after handing off to the
1294                // reaper, so slow native hosts cannot miss this ready server.
1295                "while [ -e \"$2\" ]; do sleep 0.01; done\nexit 23\n"
1296            } else if mode == "exit" {
1297                "sleep 0.03\nexit 23\n"
1298            } else {
1299                "exec sleep 30\n"
1300            };
1301            let script = TestServerScript::new(
1302                mode,
1303                &format!("#!/bin/sh\nsleep 30 &\nprintf '%s' \"$!\"\n{publish}{finish}"),
1304            );
1305            let result = PocketIcStartupConfig::spawn(script.path(), Duration::from_millis(300))
1306                .start_managed_server();
1307            let output = match result {
1308                Ok(server) => {
1309                    let output = server.output().stdout().to_owned();
1310                    if mode == "background" {
1311                        let port = server.server.files.port.clone();
1312                        server.server.reap_in_background();
1313                        fs::remove_file(port).expect("release the background server after handoff");
1314                    } else {
1315                        drop(server);
1316                    }
1317                    output
1318                }
1319                Err(PocketIcStartupError::ReadinessTimeout {
1320                    stdout,
1321                    termination_error,
1322                    ..
1323                }) => {
1324                    assert_eq!(mode, "timeout");
1325                    assert_eq!(termination_error, None);
1326                    stdout
1327                }
1328                Err(PocketIcStartupError::ServerExited { stdout, status, .. }) => {
1329                    assert_eq!(mode, "exit");
1330                    assert_eq!(status.code(), Some(23));
1331                    stdout
1332                }
1333                other => panic!(
1334                    "unexpected {mode} startup result: {}",
1335                    match other {
1336                        Err(error) => error.to_string(),
1337                        Ok(_) => unreachable!(),
1338                    }
1339                ),
1340            };
1341            let pid = output
1342                .parse::<u32>()
1343                .expect("server published its descendant PID");
1344            let deadline = Instant::now() + Duration::from_secs(2);
1345            while process_state(pid).is_some_and(|state| state != 'Z') {
1346                assert!(
1347                    Instant::now() < deadline,
1348                    "{mode} left its descendant running"
1349                );
1350                std::thread::sleep(Duration::from_millis(10));
1351            }
1352        }
1353    }
1354
1355    #[cfg(unix)]
1356    #[test]
1357    fn managed_server_passes_an_explicit_hard_ttl() {
1358        let script = TestServerScript::new(
1359            "hard-ttl",
1360            "#!/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",
1361        );
1362
1363        let server = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
1364            .with_server_hard_ttl(Duration::from_secs(17))
1365            .start_managed_server()
1366            .expect("start managed server with an explicit hard TTL");
1367
1368        assert_eq!(server.url(), "http://127.0.0.1:34567/");
1369    }
1370
1371    #[test]
1372    #[ignore = "requires IC_TESTKIT_POCKET_IC_SERVER=<caller-provided PocketIC server binary>"]
1373    fn caller_provided_server_publishes_port_constructs_instance_and_cleans_up() {
1374        let binary = std::env::var_os("IC_TESTKIT_POCKET_IC_SERVER")
1375            .map(PathBuf::from)
1376            .expect("set IC_TESTKIT_POCKET_IC_SERVER to the exact server binary");
1377        let one_shot_sequence = super::STARTUP_FILE_SEQUENCE.load(super::Ordering::Relaxed);
1378        let one_shot_directory = std::env::temp_dir().join(format!(
1379            "ic-testkit-pocket-ic-startup-{}-{one_shot_sequence}",
1380            std::process::id()
1381        ));
1382        let one_shot = PocketIcBuilder::new()
1383            .with_application_subnet()
1384            .try_build(
1385                PocketIcStartupConfig::spawn(&binary, Duration::from_secs(30))
1386                    .with_server_hard_ttl(Duration::from_secs(1)),
1387            )
1388            .expect("one-shot managed spawn must construct an instance");
1389        assert!(one_shot_directory.is_dir());
1390        drop(one_shot);
1391        let cleanup_deadline = Instant::now() + Duration::from_secs(3);
1392        while one_shot_directory.exists() && Instant::now() < cleanup_deadline {
1393            std::thread::sleep(Duration::from_millis(20));
1394        }
1395        assert!(!one_shot_directory.exists());
1396
1397        let server = PocketIcStartupConfig::spawn(&binary, Duration::from_secs(30))
1398            .with_server_hard_ttl(Duration::from_secs(60))
1399            .start_managed_server()
1400            .expect("caller-provided PocketIC server must publish its port");
1401        let files = &server.server.files;
1402        let startup_directory = files.directory.clone();
1403
1404        assert!(files.port.is_file());
1405        let pocket_ic = PocketIcBuilder::new()
1406            .with_application_subnet()
1407            .try_build(PocketIcStartupConfig::connect(
1408                server.url(),
1409                Duration::from_secs(30),
1410            ))
1411            .expect("construct instance through caller-provided server");
1412
1413        drop(pocket_ic);
1414        drop(server);
1415        assert!(!startup_directory.exists());
1416    }
1417
1418    #[cfg(unix)]
1419    struct TestServerScript {
1420        path: PathBuf,
1421    }
1422
1423    #[cfg(unix)]
1424    impl TestServerScript {
1425        fn new(label: &str, contents: &str) -> Self {
1426            let path = std::env::temp_dir().join(format!(
1427                "ic-testkit-pocket-ic-{label}-{}-{}",
1428                std::process::id(),
1429                super::STARTUP_FILE_SEQUENCE.fetch_add(1, super::Ordering::Relaxed),
1430            ));
1431            write_executable_script(&path, contents);
1432            Self { path }
1433        }
1434
1435        fn path(&self) -> PathBuf {
1436            self.path.clone()
1437        }
1438    }
1439
1440    #[cfg(unix)]
1441    impl Drop for TestServerScript {
1442        fn drop(&mut self) {
1443            let _ = fs::remove_file(&self.path);
1444        }
1445    }
1446}