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