Skip to main content

ic_testkit/pic/
startup.rs

1use std::{
2    fmt::Write as _,
3    fs::{self, File, OpenOptions},
4    io,
5    path::{Path, PathBuf},
6    process::{Child, Command, ExitStatus, Stdio},
7    sync::{
8        atomic::{AtomicU64, Ordering},
9        mpsc::{self, RecvTimeoutError},
10    },
11    thread,
12    time::{Duration, Instant},
13};
14
15use pocket_ic::{PocketIc, PocketIcBuilder};
16
17use super::transport;
18
19const DEFAULT_SERVER_HARD_TTL: Duration = Duration::from_secs(10 * 60);
20const STARTUP_POLL_INTERVAL: Duration = Duration::from_millis(20);
21const SERVER_OUTPUT_LIMIT: usize = 16 * 1024;
22
23static STARTUP_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
24
25/// Explicit bounded source and policy for one PocketIC startup.
26#[derive(Clone, Debug, Eq, PartialEq)]
27pub struct PocketIcStartupConfig {
28    source: PocketIcStartupSource,
29    timeout: Duration,
30    server_hard_ttl: Duration,
31}
32
33#[derive(Clone, Debug, Eq, PartialEq)]
34enum PocketIcStartupSource {
35    Spawn { server_binary: PathBuf },
36    Connect { server_url: String },
37}
38
39/// Structured failure from bounded PocketIC construction.
40#[non_exhaustive]
41#[derive(Debug)]
42pub enum PocketIcStartupError {
43    /// The caller supplied a zero timeout or unusable hard TTL.
44    InvalidConfiguration { message: String },
45    /// A caller-provided existing server URL could not be parsed.
46    InvalidServerUrl { server_url: String, message: String },
47    /// Preparing or inspecting bounded startup files failed.
48    Io {
49        operation: &'static str,
50        path: PathBuf,
51        source: io::Error,
52    },
53    /// The configured PocketIC server process could not be spawned.
54    ServerSpawn {
55        server_binary: PathBuf,
56        source: io::Error,
57    },
58    /// The managed PocketIC server exited before instance construction completed.
59    ServerExited {
60        server_binary: PathBuf,
61        status: ExitStatus,
62        elapsed: Duration,
63        stdout: String,
64        stderr: String,
65    },
66    /// The managed server did not publish a usable port before the deadline.
67    ReadinessTimeout {
68        server_binary: PathBuf,
69        timeout: Duration,
70        stdout: String,
71        stderr: String,
72        termination_error: Option<String>,
73    },
74    /// The managed server published an invalid port-file value.
75    InvalidServerPort {
76        server_binary: PathBuf,
77        value: String,
78        stdout: String,
79        stderr: String,
80    },
81    /// PocketIC instance creation did not finish before the startup deadline.
82    InstanceCreationTimeout {
83        timeout: Duration,
84        stdout: String,
85        stderr: String,
86        termination_error: Option<String>,
87    },
88    /// Spawning the bounded builder worker failed.
89    BuilderThreadSpawn { source: io::Error },
90    /// Upstream PocketIC construction panicked before returning an instance.
91    BuilderPanicked { message: String },
92    /// The bounded builder worker ended without returning a result.
93    BuilderDisconnected,
94}
95
96/// Fallible construction at PocketIC's panicking builder boundary.
97///
98/// Startup is explicit: callers either provide an existing server URL or let
99/// `ic-testkit` spawn and monitor one exact server binary. This prevents the
100/// upstream builder from hiding an unobservable child process.
101pub trait PocketIcBuilderExt {
102    /// Build one PocketIC instance within the configured deadline.
103    ///
104    /// Managed server startup detects child exit while awaiting the port file,
105    /// terminates the child on timeout, and captures bounded stdout/stderr.
106    /// Instance creation is also bounded. Upstream panics remain structured.
107    fn try_build(self, config: PocketIcStartupConfig) -> Result<PocketIc, PocketIcStartupError>;
108}
109
110impl PocketIcStartupConfig {
111    /// Spawn and monitor one exact PocketIC server binary.
112    #[must_use]
113    pub fn spawn(server_binary: impl Into<PathBuf>, timeout: Duration) -> Self {
114        Self {
115            source: PocketIcStartupSource::Spawn {
116                server_binary: server_binary.into(),
117            },
118            timeout,
119            server_hard_ttl: DEFAULT_SERVER_HARD_TTL,
120        }
121    }
122
123    /// Connect to a caller-owned existing PocketIC server.
124    ///
125    /// The URL is applied to the builder explicitly, so this mode never lets
126    /// the upstream builder spawn a hidden server child.
127    #[must_use]
128    pub fn connect(server_url: impl Into<String>, timeout: Duration) -> Self {
129        Self {
130            source: PocketIcStartupSource::Connect {
131                server_url: server_url.into(),
132            },
133            timeout,
134            server_hard_ttl: DEFAULT_SERVER_HARD_TTL,
135        }
136    }
137
138    /// Set the hard lifetime passed to an `ic-testkit`-managed server.
139    #[must_use]
140    pub const fn with_server_hard_ttl(mut self, hard_ttl: Duration) -> Self {
141        self.server_hard_ttl = hard_ttl;
142        self
143    }
144
145    /// Complete startup deadline.
146    #[must_use]
147    pub const fn timeout(&self) -> Duration {
148        self.timeout
149    }
150
151    /// Managed server hard lifetime.
152    #[must_use]
153    pub const fn server_hard_ttl(&self) -> Duration {
154        self.server_hard_ttl
155    }
156
157    /// Managed server binary, when this configuration spawns one.
158    #[must_use]
159    pub fn server_binary(&self) -> Option<&Path> {
160        match &self.source {
161            PocketIcStartupSource::Spawn { server_binary } => Some(server_binary),
162            PocketIcStartupSource::Connect { .. } => None,
163        }
164    }
165
166    /// Existing caller-owned server URL, when configured.
167    #[must_use]
168    pub fn server_url(&self) -> Option<&str> {
169        match &self.source {
170            PocketIcStartupSource::Connect { server_url } => Some(server_url),
171            PocketIcStartupSource::Spawn { .. } => None,
172        }
173    }
174
175    fn validate(&self) -> Result<(), PocketIcStartupError> {
176        if self.timeout.is_zero() {
177            return Err(PocketIcStartupError::InvalidConfiguration {
178                message: "PocketIC startup timeout must be greater than zero".to_owned(),
179            });
180        }
181        if matches!(&self.source, PocketIcStartupSource::Spawn { .. })
182            && self.server_hard_ttl.as_secs() == 0
183        {
184            return Err(PocketIcStartupError::InvalidConfiguration {
185                message: "PocketIC server hard TTL must be at least one second".to_owned(),
186            });
187        }
188        Ok(())
189    }
190}
191
192impl PocketIcBuilderExt for PocketIcBuilder {
193    fn try_build(self, config: PocketIcStartupConfig) -> Result<PocketIc, PocketIcStartupError> {
194        config.validate()?;
195        let started = Instant::now();
196        let deadline = started.checked_add(config.timeout).ok_or_else(|| {
197            PocketIcStartupError::InvalidConfiguration {
198                message: "PocketIC startup timeout exceeds the platform clock range".to_owned(),
199            }
200        })?;
201        match config.source {
202            PocketIcStartupSource::Connect { server_url } => {
203                build_bounded(self, &server_url, deadline, config.timeout, None)
204            }
205            PocketIcStartupSource::Spawn { server_binary } => {
206                let (server, server_url) = ManagedServer::start(
207                    server_binary,
208                    config.server_hard_ttl,
209                    deadline,
210                    config.timeout,
211                    started,
212                )?;
213                build_bounded(self, &server_url, deadline, config.timeout, Some(server))
214            }
215        }
216    }
217}
218
219fn build_bounded(
220    builder: PocketIcBuilder,
221    server_url: &str,
222    deadline: Instant,
223    timeout: Duration,
224    mut server: Option<ManagedServer>,
225) -> Result<PocketIc, PocketIcStartupError> {
226    let builder = match server_url.parse() {
227        Ok(server_url) => builder.with_server_url(server_url),
228        Err(error) => {
229            return Err(PocketIcStartupError::InvalidServerUrl {
230                server_url: server_url.to_owned(),
231                message: error.to_string(),
232            });
233        }
234    };
235    let (sender, receiver) = mpsc::sync_channel(1);
236    if let Err(source) = thread::Builder::new()
237        .name("ic-testkit-pocket-ic-startup".to_owned())
238        .spawn(move || {
239            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| builder.build()))
240                .map_err(|payload| transport::panic_payload_to_string(payload.as_ref()));
241            let _ = sender.send(result);
242        })
243    {
244        return Err(PocketIcStartupError::BuilderThreadSpawn { source });
245    }
246
247    loop {
248        let now = Instant::now();
249        if now >= deadline {
250            let captured = server.take().map_or_else(
251                CapturedServer::default,
252                ManagedServer::terminate_and_capture,
253            );
254            return Err(PocketIcStartupError::InstanceCreationTimeout {
255                timeout,
256                stdout: captured.stdout,
257                stderr: captured.stderr,
258                termination_error: captured.termination_error,
259            });
260        }
261        let remaining = deadline.saturating_duration_since(now);
262        let wait = if server.is_some() {
263            remaining.min(STARTUP_POLL_INTERVAL)
264        } else {
265            remaining
266        };
267        match receiver.recv_timeout(wait) {
268            Ok(Ok(pocket_ic)) => {
269                if let Some(mut managed) = server.take() {
270                    if let Some(status) = managed.try_wait()? {
271                        return Err(managed.exited_error(status));
272                    }
273                    managed.reap_in_background();
274                }
275                return Ok(pocket_ic);
276            }
277            Ok(Err(message)) => {
278                if let Some(server) = server.take() {
279                    let _ = server.terminate_and_capture();
280                }
281                return Err(PocketIcStartupError::BuilderPanicked { message });
282            }
283            Err(RecvTimeoutError::Disconnected) => {
284                if let Some(server) = server.take() {
285                    let _ = server.terminate_and_capture();
286                }
287                return Err(PocketIcStartupError::BuilderDisconnected);
288            }
289            Err(RecvTimeoutError::Timeout) => {
290                if let Some(managed) = &mut server
291                    && let Some(status) = managed.try_wait()?
292                {
293                    return Err(server
294                        .take()
295                        .expect("managed server must remain present")
296                        .exited_error(status));
297                }
298            }
299        }
300    }
301}
302
303struct ManagedServer {
304    child: Option<Child>,
305    binary: PathBuf,
306    files: Option<StartupFiles>,
307    started: Instant,
308}
309
310enum PortFileState {
311    Pending,
312    Ready(u16),
313    Invalid(String),
314}
315
316impl ManagedServer {
317    fn start(
318        binary: PathBuf,
319        hard_ttl: Duration,
320        deadline: Instant,
321        timeout: Duration,
322        started: Instant,
323    ) -> Result<(Self, String), PocketIcStartupError> {
324        let (files, stdout, stderr) = StartupFiles::create()?;
325        let mut command = Command::new(&binary);
326        command
327            .arg("--hard-ttl")
328            .arg(hard_ttl.as_secs().to_string())
329            .arg("--port-file")
330            .arg(&files.port)
331            .stdout(Stdio::from(stdout))
332            .stderr(Stdio::from(stderr));
333        #[cfg(unix)]
334        {
335            use std::os::unix::process::CommandExt as _;
336            command.process_group(0);
337        }
338        let child = command
339            .spawn()
340            .map_err(|source| PocketIcStartupError::ServerSpawn {
341                server_binary: binary.clone(),
342                source,
343            })?;
344        let mut server = Self {
345            child: Some(child),
346            binary,
347            files: Some(files),
348            started,
349        };
350
351        loop {
352            if let Some(status) = server.try_wait()? {
353                return Err(server.exited_error(status));
354            }
355            let now = Instant::now();
356            if now >= deadline {
357                let binary = server.binary.clone();
358                let captured = server.terminate_and_capture();
359                return Err(PocketIcStartupError::ReadinessTimeout {
360                    server_binary: binary,
361                    timeout,
362                    stdout: captured.stdout,
363                    stderr: captured.stderr,
364                    termination_error: captured.termination_error,
365                });
366            }
367            match server.read_port()? {
368                PortFileState::Pending => {}
369                PortFileState::Ready(port) => {
370                    return Ok((server, format!("http://127.0.0.1:{port}/")));
371                }
372                PortFileState::Invalid(value) => {
373                    let binary = server.binary.clone();
374                    let captured = server.terminate_and_capture();
375                    return Err(PocketIcStartupError::InvalidServerPort {
376                        server_binary: binary,
377                        value,
378                        stdout: captured.stdout,
379                        stderr: captured.stderr,
380                    });
381                }
382            }
383            thread::sleep(
384                deadline
385                    .saturating_duration_since(now)
386                    .min(STARTUP_POLL_INTERVAL),
387            );
388        }
389    }
390
391    fn try_wait(&mut self) -> Result<Option<ExitStatus>, PocketIcStartupError> {
392        self.child
393            .as_mut()
394            .expect("managed server child must remain present")
395            .try_wait()
396            .map_err(|source| PocketIcStartupError::Io {
397                operation: "inspect PocketIC server child",
398                path: self.binary.clone(),
399                source,
400            })
401    }
402
403    fn read_port(&self) -> Result<PortFileState, PocketIcStartupError> {
404        let port_path = &self
405            .files
406            .as_ref()
407            .expect("managed server startup files must remain present")
408            .port;
409        let contents =
410            fs::read_to_string(port_path).map_err(|source| PocketIcStartupError::Io {
411                operation: "read PocketIC server port file",
412                path: port_path.clone(),
413                source,
414            })?;
415        if !contents.contains('\n') {
416            return Ok(PortFileState::Pending);
417        }
418        let value = contents.trim().to_owned();
419        match value.parse::<u16>() {
420            Ok(port) if port != 0 => Ok(PortFileState::Ready(port)),
421            _ => Ok(PortFileState::Invalid(value)),
422        }
423    }
424
425    fn exited_error(mut self, status: ExitStatus) -> PocketIcStartupError {
426        let elapsed = self.started.elapsed();
427        let binary = self.binary.clone();
428        self.child.take();
429        let captured = self.capture();
430        PocketIcStartupError::ServerExited {
431            server_binary: binary,
432            status,
433            elapsed,
434            stdout: captured.stdout,
435            stderr: captured.stderr,
436        }
437    }
438
439    fn terminate_and_capture(mut self) -> CapturedServer {
440        let termination_error = match self.child.take() {
441            Some(mut child) => terminate_child(&mut child),
442            None => None,
443        };
444        let mut captured = self.capture();
445        captured.termination_error = termination_error;
446        captured
447    }
448
449    fn capture(&self) -> CapturedServer {
450        let files = self
451            .files
452            .as_ref()
453            .expect("managed server startup files must remain present");
454        CapturedServer {
455            stdout: read_bounded_lossy(&files.stdout),
456            stderr: read_bounded_lossy(&files.stderr),
457            termination_error: None,
458        }
459    }
460
461    fn reap_in_background(mut self) {
462        let child = ServerChildGuard {
463            child: self.child.take(),
464        };
465        let files = self.files.take();
466        let _ = thread::Builder::new()
467            .name("ic-testkit-pocket-ic-server-reaper".to_owned())
468            .spawn(move || {
469                let mut child = child;
470                if let Some(mut process) = child.child.take() {
471                    let _ = process.wait();
472                }
473                drop(files);
474            });
475    }
476}
477
478impl Drop for ManagedServer {
479    fn drop(&mut self) {
480        if let Some(mut child) = self.child.take() {
481            let _ = terminate_child(&mut child);
482        }
483    }
484}
485
486struct ServerChildGuard {
487    child: Option<Child>,
488}
489
490impl Drop for ServerChildGuard {
491    fn drop(&mut self) {
492        if let Some(mut child) = self.child.take() {
493            let _ = terminate_child(&mut child);
494        }
495    }
496}
497
498fn terminate_child(child: &mut Child) -> Option<String> {
499    match child.try_wait() {
500        Ok(Some(_)) => None,
501        Ok(None) => child
502            .kill()
503            .and_then(|()| child.wait().map(|_| ()))
504            .err()
505            .map(|error| error.to_string()),
506        Err(inspect_error) => {
507            let termination_error = child.kill().and_then(|()| child.wait().map(|_| ())).err();
508            termination_error.map(|termination_error| {
509                format!(
510                    "failed to inspect child before termination: {inspect_error}; termination also failed: {termination_error}"
511                )
512            })
513        }
514    }
515}
516
517#[derive(Default)]
518struct CapturedServer {
519    stdout: String,
520    stderr: String,
521    termination_error: Option<String>,
522}
523
524struct StartupFiles {
525    port: PathBuf,
526    stdout: PathBuf,
527    stderr: PathBuf,
528}
529
530impl StartupFiles {
531    fn create() -> Result<(Self, File, File), PocketIcStartupError> {
532        loop {
533            let sequence = STARTUP_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
534            let base = std::env::temp_dir().join(format!(
535                "ic-testkit-pocket-ic-startup-{}-{sequence}",
536                std::process::id()
537            ));
538            let files = Self {
539                port: base.with_extension("port"),
540                stdout: base.with_extension("stdout"),
541                stderr: base.with_extension("stderr"),
542            };
543            let port = match create_new_file(&files.port) {
544                Ok(file) => file,
545                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
546                Err(source) => return Err(startup_file_error("create", &files.port, source)),
547            };
548            drop(port);
549            let stdout = create_new_file(&files.stdout)
550                .map_err(|source| startup_file_error("create", &files.stdout, source))?;
551            let stderr = create_new_file(&files.stderr)
552                .map_err(|source| startup_file_error("create", &files.stderr, source))?;
553            return Ok((files, stdout, stderr));
554        }
555    }
556}
557
558impl Drop for StartupFiles {
559    fn drop(&mut self) {
560        let _ = fs::remove_file(&self.port);
561        let _ = fs::remove_file(&self.stdout);
562        let _ = fs::remove_file(&self.stderr);
563    }
564}
565
566fn create_new_file(path: &Path) -> io::Result<File> {
567    OpenOptions::new().write(true).create_new(true).open(path)
568}
569
570fn startup_file_error(
571    operation: &'static str,
572    path: &Path,
573    source: io::Error,
574) -> PocketIcStartupError {
575    PocketIcStartupError::Io {
576        operation,
577        path: path.to_owned(),
578        source,
579    }
580}
581
582fn read_bounded_lossy(path: &Path) -> String {
583    let Ok(bytes) = fs::read(path) else {
584        return String::new();
585    };
586    let retained = bytes.len().min(SERVER_OUTPUT_LIMIT);
587    let mut output = String::from_utf8_lossy(&bytes[..retained]).into_owned();
588    let omitted = bytes.len().saturating_sub(retained);
589    if omitted > 0 {
590        let _ = write!(output, "\n<truncated {omitted} bytes>");
591    }
592    output
593}
594
595impl std::fmt::Display for PocketIcStartupError {
596    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
597        match self {
598            Self::InvalidConfiguration { message } => formatter.write_str(message),
599            Self::InvalidServerUrl {
600                server_url,
601                message,
602            } => write!(
603                formatter,
604                "invalid PocketIC server URL {server_url:?}: {message}"
605            ),
606            Self::Io {
607                operation,
608                path,
609                source,
610            } => write!(
611                formatter,
612                "failed to {operation} at {}: {source}",
613                path.display()
614            ),
615            Self::ServerSpawn {
616                server_binary,
617                source,
618            } => write!(
619                formatter,
620                "failed to spawn PocketIC server {}: {source}",
621                server_binary.display()
622            ),
623            Self::ServerExited {
624                server_binary,
625                status,
626                elapsed,
627                stderr,
628                ..
629            } => write!(
630                formatter,
631                "PocketIC server {} exited with {status} after {elapsed:?}: {stderr}",
632                server_binary.display()
633            ),
634            Self::ReadinessTimeout {
635                server_binary,
636                timeout,
637                ..
638            } => write!(
639                formatter,
640                "PocketIC server {} was not ready within {timeout:?}",
641                server_binary.display()
642            ),
643            Self::InvalidServerPort {
644                server_binary,
645                value,
646                ..
647            } => write!(
648                formatter,
649                "PocketIC server {} published invalid port {value:?}",
650                server_binary.display()
651            ),
652            Self::InstanceCreationTimeout { timeout, .. } => {
653                write!(formatter, "PocketIC instance creation exceeded {timeout:?}")
654            }
655            Self::BuilderThreadSpawn { source } => {
656                write!(
657                    formatter,
658                    "failed to spawn PocketIC builder worker: {source}"
659                )
660            }
661            Self::BuilderPanicked { message } => {
662                write!(formatter, "PocketIC startup panicked: {message}")
663            }
664            Self::BuilderDisconnected => {
665                formatter.write_str("PocketIC builder worker disconnected without a result")
666            }
667        }
668    }
669}
670
671impl std::error::Error for PocketIcStartupError {
672    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
673        match self {
674            Self::Io { source, .. }
675            | Self::ServerSpawn { source, .. }
676            | Self::BuilderThreadSpawn { source } => Some(source),
677            _ => None,
678        }
679    }
680}
681
682#[cfg(test)]
683mod tests {
684    use std::time::{Duration, Instant};
685
686    use super::{PocketIcStartupConfig, PocketIcStartupError};
687
688    #[cfg(unix)]
689    use {
690        super::PocketIcBuilderExt as _,
691        pocket_ic::PocketIcBuilder,
692        std::{fs, os::unix::fs::PermissionsExt as _, path::PathBuf},
693    };
694
695    #[test]
696    fn startup_config_requires_positive_bounds() {
697        let error = PocketIcStartupConfig::connect("http://127.0.0.1:1/", Duration::ZERO)
698            .validate()
699            .expect_err("zero startup timeout must fail");
700        assert!(matches!(
701            error,
702            PocketIcStartupError::InvalidConfiguration { .. }
703        ));
704
705        let error = PocketIcStartupConfig::spawn("pocket-ic", Duration::from_secs(1))
706            .with_server_hard_ttl(Duration::from_millis(1))
707            .validate()
708            .expect_err("subsecond server hard TTL must fail");
709        assert!(matches!(
710            error,
711            PocketIcStartupError::InvalidConfiguration { .. }
712        ));
713    }
714
715    #[cfg(unix)]
716    #[test]
717    fn managed_startup_reports_an_exited_server_with_bounded_output() {
718        let script = TestServerScript::new(
719            "exit",
720            "#!/bin/sh\nprintf 'synthetic server stdout'\nprintf 'synthetic bind failure' >&2\nexit 23\n",
721        );
722
723        let result = PocketIcBuilder::new().with_application_subnet().try_build(
724            PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2)),
725        );
726
727        let Err(PocketIcStartupError::ServerExited {
728            server_binary,
729            status,
730            stdout,
731            stderr,
732            ..
733        }) = result
734        else {
735            panic!("an exited managed server must return a structured exit error");
736        };
737        assert_eq!(server_binary, script.path());
738        assert_eq!(status.code(), Some(23));
739        assert_eq!(stdout, "synthetic server stdout");
740        assert_eq!(stderr, "synthetic bind failure");
741    }
742
743    #[cfg(unix)]
744    #[test]
745    fn managed_startup_terminates_a_server_that_never_becomes_ready() {
746        let script = TestServerScript::new("timeout", "#!/bin/sh\nexec sleep 30\n");
747        let timeout = Duration::from_millis(100);
748        let started = Instant::now();
749
750        let result = PocketIcBuilder::new()
751            .with_application_subnet()
752            .try_build(PocketIcStartupConfig::spawn(script.path(), timeout));
753
754        assert!(
755            started.elapsed() < Duration::from_secs(2),
756            "bounded startup should not wait for the sleeping child"
757        );
758        assert!(matches!(
759            result,
760            Err(PocketIcStartupError::ReadinessTimeout {
761                server_binary,
762                timeout: actual_timeout,
763                termination_error: None,
764                ..
765            }) if server_binary == script.path() && actual_timeout == timeout
766        ));
767    }
768
769    #[cfg(unix)]
770    struct TestServerScript {
771        path: PathBuf,
772    }
773
774    #[cfg(unix)]
775    impl TestServerScript {
776        fn new(label: &str, contents: &str) -> Self {
777            let path = std::env::temp_dir().join(format!(
778                "ic-testkit-pocket-ic-{label}-{}-{}",
779                std::process::id(),
780                super::STARTUP_FILE_SEQUENCE.fetch_add(1, super::Ordering::Relaxed),
781            ));
782            fs::write(&path, contents).expect("write synthetic PocketIC server script");
783            let mut permissions = fs::metadata(&path)
784                .expect("read synthetic server script metadata")
785                .permissions();
786            permissions.set_mode(0o755);
787            fs::set_permissions(&path, permissions)
788                .expect("make synthetic server script executable");
789            Self { path }
790        }
791
792        fn path(&self) -> PathBuf {
793            self.path.clone()
794        }
795    }
796
797    #[cfg(unix)]
798    impl Drop for TestServerScript {
799        fn drop(&mut self) {
800            let _ = fs::remove_file(&self.path);
801        }
802    }
803}