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