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