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