1use std::{
2 fmt::Write as _,
3 fs::{self, File, OpenOptions},
4 io::{self, Read as _},
5 path::{Path, PathBuf},
6 process::{Child, Command, ExitStatus, Stdio},
7 sync::{
8 atomic::{AtomicU64, Ordering},
9 mpsc::{self, RecvTimeoutError},
10 },
11 thread,
12 time::{Duration, Instant},
13};
14
15#[cfg(unix)]
16use std::os::unix::{
17 fs::{DirBuilderExt as _, OpenOptionsExt as _},
18 process::CommandExt as _,
19};
20
21use pocket_ic::{PocketIc, PocketIcBuilder};
22
23use super::transport;
24
25const STARTUP_POLL_INTERVAL: Duration = Duration::from_millis(20);
26const SERVER_OUTPUT_LIMIT: usize = 16 * 1024;
27const SERVER_PORT_FILE_LIMIT: usize = 64;
30
31static STARTUP_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
32
33#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct PocketIcStartupConfig {
36 source: PocketIcStartupSource,
37 timeout: Duration,
38 server_hard_ttl: Option<Duration>,
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 },
131 InstanceCreationTimeout {
133 timeout: Duration,
134 stdout: String,
135 stderr: String,
136 termination_error: Option<String>,
137 },
138 BuilderThreadSpawn { source: io::Error },
140 BuilderPanicked { message: String },
142 BuilderDisconnected,
144}
145
146pub trait PocketIcBuilderExt {
152 fn try_build(self, config: PocketIcStartupConfig) -> Result<PocketIc, PocketIcStartupError>;
162}
163
164impl PocketIcStartupConfig {
165 pub fn from_env(timeout: Duration) -> Result<Self, PocketIcStartupError> {
176 Self::from_environment(timeout, |name| std::env::var_os(name))
177 }
178
179 fn from_environment(
180 timeout: Duration,
181 mut variable: impl FnMut(&str) -> Option<std::ffi::OsString>,
182 ) -> Result<Self, PocketIcStartupError> {
183 if let Some(value) = variable("IC_TESTKIT_POCKET_IC_URL") {
184 let server_url = value
185 .into_string()
186 .ok()
187 .filter(|value| !value.is_empty())
188 .ok_or(PocketIcStartupError::InvalidEnvironment {
189 variable: "IC_TESTKIT_POCKET_IC_URL",
190 })?;
191 let config = Self::connect(&server_url, timeout);
192 config.validate()?;
193 let parsed =
194 server_url
195 .parse()
196 .map_err(|error| PocketIcStartupError::InvalidServerUrl {
197 server_url: server_url.clone(),
198 message: format!("{error}"),
199 })?;
200 let _ = PocketIcBuilder::new().with_server_url(parsed);
201 return Ok(config);
202 }
203 let value = variable("POCKET_IC_BIN").ok_or(PocketIcStartupError::NotConfigured)?;
204 if value.is_empty() {
205 return Err(PocketIcStartupError::InvalidEnvironment {
206 variable: "POCKET_IC_BIN",
207 });
208 }
209 let path = PathBuf::from(value);
210 let binary = fs::canonicalize(&path).map_err(|source| PocketIcStartupError::Io {
211 operation: "resolve configured PocketIC executable",
212 path,
213 source,
214 })?;
215 let config = Self::spawn(&binary, timeout);
216 config.validate()?;
217 let evidence = ic_host_process::tool::capture_command(
218 Command::new(&binary).arg("--version"),
219 ic_host_process::tool::OutputLimits {
220 stdout_bytes: SERVER_OUTPUT_LIMIT,
221 stderr_bytes: SERVER_OUTPUT_LIMIT,
222 timeout,
223 },
224 )
225 .map_err(|source| PocketIcStartupError::ServerVersionProbe { source })?;
226 let expected = format!("pocket-ic-server {}", pocket_ic::LATEST_SERVER_VERSION);
227 if std::str::from_utf8(&evidence.stdout).map(str::trim) != Ok(expected.as_str()) {
228 return Err(PocketIcStartupError::ServerVersionMismatch {
229 expected,
230 observed: evidence.stdout,
231 });
232 }
233 Ok(config)
234 }
235
236 #[must_use]
241 pub fn spawn(server_binary: impl Into<PathBuf>, timeout: Duration) -> Self {
242 Self {
243 source: PocketIcStartupSource::Spawn {
244 server_binary: server_binary.into(),
245 },
246 timeout,
247 server_hard_ttl: None,
248 }
249 }
250
251 #[must_use]
256 pub fn connect(server_url: impl Into<String>, timeout: Duration) -> Self {
257 Self {
258 source: PocketIcStartupSource::Connect {
259 server_url: server_url.into(),
260 },
261 timeout,
262 server_hard_ttl: None,
263 }
264 }
265
266 #[must_use]
268 pub const fn with_server_hard_ttl(mut self, hard_ttl: Duration) -> Self {
269 self.server_hard_ttl = Some(hard_ttl);
270 self
271 }
272
273 #[must_use]
275 pub const fn timeout(&self) -> Duration {
276 self.timeout
277 }
278
279 #[must_use]
281 pub const fn server_hard_ttl(&self) -> Option<Duration> {
282 self.server_hard_ttl
283 }
284
285 #[must_use]
287 pub fn server_binary(&self) -> Option<&Path> {
288 match &self.source {
289 PocketIcStartupSource::Spawn { server_binary } => Some(server_binary),
290 PocketIcStartupSource::Connect { .. } => None,
291 }
292 }
293
294 #[must_use]
296 pub fn server_url(&self) -> Option<&str> {
297 match &self.source {
298 PocketIcStartupSource::Connect { server_url } => Some(server_url),
299 PocketIcStartupSource::Spawn { .. } => None,
300 }
301 }
302
303 pub fn start_managed_server(self) -> Result<PocketIcManagedServer, PocketIcStartupError> {
315 self.validate()?;
316 let PocketIcStartupSource::Spawn { server_binary } = self.source else {
317 return Err(PocketIcStartupError::InvalidConfiguration {
318 message: "starting a managed PocketIC server requires a spawn configuration"
319 .to_owned(),
320 });
321 };
322 let started = Instant::now();
323 let deadline = startup_deadline(started, self.timeout)?;
324 let (server, url) = ManagedServer::start(
325 server_binary,
326 self.server_hard_ttl,
327 deadline,
328 self.timeout,
329 started,
330 )?;
331 Ok(PocketIcManagedServer { server, url })
332 }
333
334 pub fn run_command(
351 self,
352 command: &mut Command,
353 mut cancelled: impl FnMut() -> bool,
354 ) -> Result<ExitStatus, PocketIcStartupError> {
355 self.validate()?;
356 if cancelled() {
357 return Err(PocketIcStartupError::Io {
358 operation: "run command with PocketIC server",
359 path: PathBuf::from(command.get_program()),
360 source: io::Error::from(io::ErrorKind::Interrupted),
361 });
362 }
363 let (mut server, url) = if let Some(url) = self.server_url() {
364 (None, url.to_owned())
365 } else {
366 let server = self.start_managed_server()?;
367 let url = server.url().to_owned();
368 (Some(server), url)
369 };
370 let command_error = |source| PocketIcStartupError::Io {
371 operation: "run command with PocketIC server",
372 path: PathBuf::from(command.get_program()),
373 source,
374 };
375 if cancelled() {
376 return Err(command_error(io::Error::from(io::ErrorKind::Interrupted)));
377 }
378 command.env("IC_TESTKIT_POCKET_IC_URL", url);
379 #[cfg(unix)]
380 command.process_group(0);
381 let child = command.spawn().map_err(|source| PocketIcStartupError::Io {
382 operation: "spawn command with PocketIC server",
383 path: PathBuf::from(command.get_program()),
384 source,
385 })?;
386 let mut owned_child = CommandChild(Some(child));
387 let result = loop {
388 if cancelled() {
389 break Err(io::Error::from(io::ErrorKind::Interrupted));
390 }
391 match poll_child(owned_child.0.as_mut().expect("command child remains owned")) {
392 Ok(Some(status)) => break Ok(status),
393 Ok(None) => {
394 if let Some(managed) = server.as_mut()
395 && let Some(status) = managed.server.try_wait()?
396 {
397 return Err(server
398 .take()
399 .expect("managed server remains owned")
400 .server
401 .exited_error(status));
402 }
403 thread::sleep(STARTUP_POLL_INTERVAL);
404 }
405 Err(source) => break Err(source),
406 }
407 };
408 let mut child = owned_child.0.take().expect("command child remains owned");
409 let termination_error = result
410 .is_err()
411 .then(|| terminate_child(&mut child))
412 .flatten();
413 drop(server);
414 result.map_err(|source| PocketIcStartupError::CommandRun {
415 program: PathBuf::from(command.get_program()),
416 source,
417 termination_error,
418 })
419 }
420
421 fn validate(&self) -> Result<(), PocketIcStartupError> {
422 if self.timeout.is_zero() {
423 return Err(PocketIcStartupError::InvalidConfiguration {
424 message: "PocketIC startup timeout must be greater than zero".to_owned(),
425 });
426 }
427 if matches!(&self.source, PocketIcStartupSource::Spawn { .. })
428 && self
429 .server_hard_ttl
430 .is_some_and(|hard_ttl| hard_ttl.as_secs() == 0)
431 {
432 return Err(PocketIcStartupError::InvalidConfiguration {
433 message: "PocketIC server hard TTL must be at least one second".to_owned(),
434 });
435 }
436 Ok(())
437 }
438}
439
440impl PocketIcManagedServer {
441 #[must_use]
448 pub fn process_id(&self) -> u32 {
449 self.server
450 .child
451 .as_ref()
452 .expect("managed server handle must own its child")
453 .id()
454 }
455
456 #[must_use]
458 pub fn url(&self) -> &str {
459 &self.url
460 }
461
462 #[must_use]
468 pub fn output(&self) -> PocketIcManagedServerOutput {
469 self.server.capture().into()
470 }
471}
472
473impl PocketIcManagedServerOutput {
474 #[must_use]
476 pub fn stdout(&self) -> &str {
477 &self.stdout
478 }
479
480 #[must_use]
482 pub fn stderr(&self) -> &str {
483 &self.stderr
484 }
485}
486
487impl PocketIcBuilderExt for PocketIcBuilder {
488 fn try_build(self, config: PocketIcStartupConfig) -> Result<PocketIc, PocketIcStartupError> {
489 config.validate()?;
490 let started = Instant::now();
491 let deadline = startup_deadline(started, config.timeout)?;
492 match config.source {
493 PocketIcStartupSource::Connect { server_url } => {
494 build_bounded(self, &server_url, deadline, config.timeout, None)
495 }
496 PocketIcStartupSource::Spawn { server_binary } => {
497 let (server, server_url) = ManagedServer::start(
498 server_binary,
499 config.server_hard_ttl,
500 deadline,
501 config.timeout,
502 started,
503 )?;
504 build_bounded(self, &server_url, deadline, config.timeout, Some(server))
505 }
506 }
507 }
508}
509
510fn startup_deadline(started: Instant, timeout: Duration) -> Result<Instant, PocketIcStartupError> {
511 started
512 .checked_add(timeout)
513 .ok_or_else(|| PocketIcStartupError::InvalidConfiguration {
514 message: "PocketIC startup timeout exceeds the platform clock range".to_owned(),
515 })
516}
517
518fn build_bounded(
519 builder: PocketIcBuilder,
520 server_url: &str,
521 deadline: Instant,
522 timeout: Duration,
523 mut server: Option<ManagedServer>,
524) -> Result<PocketIc, PocketIcStartupError> {
525 let builder = match server_url.parse() {
526 Ok(server_url) => builder.with_server_url(server_url),
527 Err(error) => {
528 return Err(PocketIcStartupError::InvalidServerUrl {
529 server_url: server_url.to_owned(),
530 message: error.to_string(),
531 });
532 }
533 };
534 let (sender, receiver) = mpsc::sync_channel(1);
535 if let Err(source) = thread::Builder::new()
536 .name("ic-testkit-pocket-ic-startup".to_owned())
537 .spawn(move || {
538 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| builder.build()))
539 .map_err(|payload| transport::panic_payload_to_string(payload.as_ref()));
540 let _ = sender.send(result);
541 })
542 {
543 return Err(PocketIcStartupError::BuilderThreadSpawn { source });
544 }
545
546 loop {
547 let now = Instant::now();
548 if now >= deadline {
549 let captured = server.take().map_or_else(
550 CapturedServer::default,
551 ManagedServer::terminate_and_capture,
552 );
553 return Err(PocketIcStartupError::InstanceCreationTimeout {
554 timeout,
555 stdout: captured.stdout,
556 stderr: captured.stderr,
557 termination_error: captured.termination_error,
558 });
559 }
560 let remaining = deadline.saturating_duration_since(now);
561 let wait = if server.is_some() {
562 remaining.min(STARTUP_POLL_INTERVAL)
563 } else {
564 remaining
565 };
566 match receiver.recv_timeout(wait) {
567 Ok(Ok(pocket_ic)) => {
568 if let Some(mut managed) = server.take() {
569 if let Some(status) = managed.try_wait()? {
570 return Err(managed.exited_error(status));
571 }
572 managed.reap_in_background();
573 }
574 return Ok(pocket_ic);
575 }
576 Ok(Err(message)) => {
577 if let Some(server) = server.take() {
578 let _ = server.terminate_and_capture();
579 }
580 return Err(PocketIcStartupError::BuilderPanicked { message });
581 }
582 Err(RecvTimeoutError::Disconnected) => {
583 if let Some(server) = server.take() {
584 let _ = server.terminate_and_capture();
585 }
586 return Err(PocketIcStartupError::BuilderDisconnected);
587 }
588 Err(RecvTimeoutError::Timeout) => {
589 if let Some(managed) = &mut server
590 && let Some(status) = managed.try_wait()?
591 {
592 return Err(server
593 .take()
594 .expect("managed server must remain present")
595 .exited_error(status));
596 }
597 }
598 }
599 }
600}
601
602struct ManagedServer {
603 child: Option<Child>,
604 binary: PathBuf,
605 files: StartupFiles,
606 started: Instant,
607}
608
609struct CommandChild(Option<Child>);
610
611impl Drop for CommandChild {
612 fn drop(&mut self) {
613 if let Some(mut child) = self.0.take() {
614 let _ = terminate_child(&mut child);
615 }
616 }
617}
618
619enum PortFileState {
620 Pending,
621 Ready(u16),
622 Invalid(String),
623}
624
625impl ManagedServer {
626 fn start(
627 binary: PathBuf,
628 hard_ttl: Option<Duration>,
629 deadline: Instant,
630 timeout: Duration,
631 started: Instant,
632 ) -> Result<(Self, String), PocketIcStartupError> {
633 let (files, stdout, stderr) = StartupFiles::create()?;
634 let mut command = Command::new(&binary);
635 if let Some(hard_ttl) = hard_ttl {
636 command
637 .arg("--hard-ttl")
638 .arg(hard_ttl.as_secs().to_string());
639 }
640 command
641 .arg("--port-file")
642 .arg(&files.port)
643 .stdout(Stdio::from(stdout))
644 .stderr(Stdio::from(stderr));
645 #[cfg(unix)]
646 {
647 command.process_group(0);
648 }
649 let child = command
650 .spawn()
651 .map_err(|source| PocketIcStartupError::ServerSpawn {
652 server_binary: binary.clone(),
653 source,
654 })?;
655 let mut server = Self {
656 child: Some(child),
657 binary,
658 files,
659 started,
660 };
661
662 loop {
663 if let Some(status) = server.try_wait()? {
664 return Err(server.exited_error(status));
665 }
666 let now = Instant::now();
667 if now >= deadline {
668 let binary = server.binary.clone();
669 let captured = server.terminate_and_capture();
670 return Err(PocketIcStartupError::ReadinessTimeout {
671 server_binary: binary,
672 timeout,
673 stdout: captured.stdout,
674 stderr: captured.stderr,
675 termination_error: captured.termination_error,
676 });
677 }
678 match server.read_port()? {
679 PortFileState::Pending => {}
680 PortFileState::Ready(port) => {
681 return Ok((server, format!("http://127.0.0.1:{port}/")));
682 }
683 PortFileState::Invalid(value) => {
684 let binary = server.binary.clone();
685 let captured = server.terminate_and_capture();
686 return Err(PocketIcStartupError::InvalidServerPort {
687 server_binary: binary,
688 value,
689 stdout: captured.stdout,
690 stderr: captured.stderr,
691 });
692 }
693 }
694 thread::sleep(
695 deadline
696 .saturating_duration_since(now)
697 .min(STARTUP_POLL_INTERVAL),
698 );
699 }
700 }
701
702 fn try_wait(&mut self) -> Result<Option<ExitStatus>, PocketIcStartupError> {
703 let child = self
704 .child
705 .as_mut()
706 .expect("managed server child must remain present");
707 poll_child(child).map_err(|source| PocketIcStartupError::Io {
708 operation: "inspect PocketIC server child",
709 path: self.binary.clone(),
710 source,
711 })
712 }
713
714 fn read_port(&self) -> Result<PortFileState, PocketIcStartupError> {
715 let port_path = &self.files.port;
716 let mut contents = String::new();
717 match open_regular_startup_file(port_path).and_then(|file| {
718 file.take((SERVER_PORT_FILE_LIMIT + 1) as u64)
719 .read_to_string(&mut contents)
720 }) {
721 Ok(_) => {}
722 Err(error) if error.kind() == io::ErrorKind::NotFound => {
723 return Ok(PortFileState::Pending);
724 }
725 Err(source) => {
726 return Err(PocketIcStartupError::Io {
727 operation: "read PocketIC server port file",
728 path: port_path.clone(),
729 source,
730 });
731 }
732 }
733 if contents.len() > SERVER_PORT_FILE_LIMIT {
734 return Ok(PortFileState::Invalid(format!(
735 "{} (port file exceeds {SERVER_PORT_FILE_LIMIT} bytes)",
736 contents.trim()
737 )));
738 }
739 if !contents.contains('\n') {
740 return Ok(PortFileState::Pending);
741 }
742 let value = contents.trim().to_owned();
743 match value.parse::<u16>() {
744 Ok(port) if port != 0 => Ok(PortFileState::Ready(port)),
745 _ => Ok(PortFileState::Invalid(value)),
746 }
747 }
748
749 fn exited_error(mut self, status: ExitStatus) -> PocketIcStartupError {
750 let elapsed = self.started.elapsed();
751 let binary = self.binary.clone();
752 self.child.take();
753 let captured = self.capture();
754 PocketIcStartupError::ServerExited {
755 server_binary: binary,
756 status,
757 elapsed,
758 stdout: captured.stdout,
759 stderr: captured.stderr,
760 }
761 }
762
763 fn terminate_and_capture(mut self) -> CapturedServer {
764 let termination_error = match self.child.take() {
765 Some(mut child) => terminate_child(&mut child),
766 None => None,
767 };
768 let mut captured = self.capture();
769 captured.termination_error = termination_error;
770 captured
771 }
772
773 fn capture(&self) -> CapturedServer {
774 let files = &self.files;
775 CapturedServer {
776 stdout: read_bounded_lossy(&files.stdout),
777 stderr: read_bounded_lossy(&files.stderr),
778 termination_error: None,
779 }
780 }
781
782 fn reap_in_background(self) {
783 let _ = thread::Builder::new()
784 .name("ic-testkit-pocket-ic-server-reaper".to_owned())
785 .spawn(move || {
786 let mut server = self;
789 if let Some(child) = server.child.as_mut() {
790 #[cfg(unix)]
791 let _ = wait_for_child_exit(child, false);
792 #[cfg(not(unix))]
793 let _ = child.wait();
794 }
795 });
796 }
797}
798
799impl Drop for ManagedServer {
800 fn drop(&mut self) {
801 if let Some(mut child) = self.child.take() {
802 let _ = terminate_child(&mut child);
803 }
804 }
805}
806
807#[cfg(unix)]
808fn wait_for_child_exit(child: &Child, nonblocking: bool) -> io::Result<bool> {
809 let flags = libc::WEXITED | libc::WNOWAIT | if nonblocking { libc::WNOHANG } else { 0 };
811 loop {
812 let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
814 let result = unsafe { libc::waitid(libc::P_PID, child.id(), &raw mut info, flags) };
816 if result == 0 {
817 return Ok(info.si_signo != 0);
818 }
819 let error = io::Error::last_os_error();
820 if error.kind() != io::ErrorKind::Interrupted {
821 return Err(error);
822 }
823 }
824}
825
826fn poll_child(child: &mut Child) -> io::Result<Option<ExitStatus>> {
827 #[cfg(unix)]
828 {
829 if !wait_for_child_exit(child, true)? {
830 return Ok(None);
831 }
832 terminate_process_group(child)?;
834 child.wait().map(Some)
835 }
836 #[cfg(not(unix))]
837 child.try_wait()
838}
839
840#[cfg(unix)]
841fn terminate_process_group(child: &Child) -> io::Result<()> {
842 let group = libc::pid_t::try_from(child.id())
843 .map_err(|_| io::Error::other("managed child PID exceeds the OS process ID range"))?;
844 loop {
845 let result = unsafe { libc::killpg(group, libc::SIGKILL) };
848 if result == 0 {
849 return Ok(());
850 }
851 let error = io::Error::last_os_error();
852 if error.raw_os_error() == Some(libc::ESRCH) {
853 return Ok(());
854 }
855 #[cfg(target_os = "macos")]
856 if error.raw_os_error() == Some(libc::EPERM) && wait_for_child_exit(child, true)? {
857 let mut members: [libc::pid_t; 2] = [0; 2];
862 let size = libc::c_int::try_from(std::mem::size_of_val(&members))
863 .expect("two process IDs fit in a libproc buffer size");
864 let count =
867 unsafe { libc::proc_listpgrppids(group, members.as_mut_ptr().cast(), size) };
868 if count == 1 && members[0] == group {
869 return Ok(());
870 }
871 }
872 if error.kind() != io::ErrorKind::Interrupted {
873 return Err(error);
874 }
875 }
876}
877
878#[cfg(unix)]
879fn terminate_child(child: &mut Child) -> Option<String> {
880 let termination = terminate_process_group(child);
881 if termination.is_err() {
882 let _ = child.kill();
883 }
884 let wait = child.wait();
885 termination
886 .and_then(|()| wait.map(|_| ()))
887 .err()
888 .map(|error| error.to_string())
889}
890
891#[cfg(not(unix))]
892fn terminate_child(child: &mut Child) -> Option<String> {
893 match child.try_wait() {
894 Ok(Some(_)) => None,
895 Ok(None) => child
896 .kill()
897 .and_then(|()| child.wait().map(|_| ()))
898 .err()
899 .map(|error| error.to_string()),
900 Err(inspect_error) => {
901 let termination_error = child.kill().and_then(|()| child.wait().map(|_| ())).err();
902 termination_error.map(|termination_error| {
903 format!(
904 "failed to inspect child before termination: {inspect_error}; termination also failed: {termination_error}"
905 )
906 })
907 }
908 }
909}
910
911#[derive(Default)]
912struct CapturedServer {
913 stdout: String,
914 stderr: String,
915 termination_error: Option<String>,
916}
917
918impl From<CapturedServer> for PocketIcManagedServerOutput {
919 fn from(captured: CapturedServer) -> Self {
920 Self {
921 stdout: captured.stdout,
922 stderr: captured.stderr,
923 }
924 }
925}
926
927struct StartupFiles {
928 directory: PathBuf,
929 port: PathBuf,
930 stdout: PathBuf,
931 stderr: PathBuf,
932}
933
934impl StartupFiles {
935 fn create() -> Result<(Self, File, File), PocketIcStartupError> {
936 loop {
937 let sequence = STARTUP_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
938 let base = std::env::temp_dir().join(format!(
939 "ic-testkit-pocket-ic-startup-{}-{sequence}",
940 std::process::id()
941 ));
942 let mut directory = fs::DirBuilder::new();
943 #[cfg(unix)]
944 {
945 directory.mode(0o700);
946 }
947 match directory.create(&base) {
948 Ok(()) => {}
949 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
950 Err(source) => return Err(startup_file_error("create", &base, source)),
951 }
952 let files = Self {
953 port: base.join("port"),
954 stdout: base.join("stdout"),
955 stderr: base.join("stderr"),
956 directory: base,
957 };
958 let stdout = create_new_file(&files.stdout)
959 .map_err(|source| startup_file_error("create", &files.stdout, source))?;
960 let stderr = create_new_file(&files.stderr)
961 .map_err(|source| startup_file_error("create", &files.stderr, source))?;
962 return Ok((files, stdout, stderr));
963 }
964 }
965}
966
967impl Drop for StartupFiles {
968 fn drop(&mut self) {
969 let _ = fs::remove_dir_all(&self.directory);
970 }
971}
972
973fn create_new_file(path: &Path) -> io::Result<File> {
974 OpenOptions::new().write(true).create_new(true).open(path)
975}
976
977fn startup_file_error(
978 operation: &'static str,
979 path: &Path,
980 source: io::Error,
981) -> PocketIcStartupError {
982 PocketIcStartupError::Io {
983 operation,
984 path: path.to_owned(),
985 source,
986 }
987}
988
989fn read_bounded_lossy(path: &Path) -> String {
990 let Ok(file) = open_regular_startup_file(path) else {
991 return String::new();
992 };
993 let length = file.metadata().map_or(0, |metadata| metadata.len());
994 let Ok(bytes) = ic_host_artifacts::artifact::read_reader(
995 file.take(SERVER_OUTPUT_LIMIT as u64),
996 SERVER_OUTPUT_LIMIT,
997 ) else {
998 return String::new();
999 };
1000 let mut output = String::from_utf8_lossy(&bytes).into_owned();
1001 let omitted = length.saturating_sub(bytes.len() as u64);
1002 if omitted > 0 {
1003 let _ = write!(output, "\n<truncated {omitted} bytes>");
1004 }
1005 output
1006}
1007
1008fn open_regular_startup_file(path: &Path) -> io::Result<File> {
1009 let mut options = OpenOptions::new();
1010 options.read(true);
1011 #[cfg(unix)]
1014 options.custom_flags(libc::O_NONBLOCK);
1015 let file = options.open(path)?;
1016 if !file.metadata()?.is_file() {
1017 return Err(io::Error::new(
1018 io::ErrorKind::InvalidData,
1019 "PocketIC startup reader requires a regular file",
1020 ));
1021 }
1022 Ok(file)
1023}
1024
1025impl std::fmt::Display for PocketIcStartupError {
1026 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1027 match self {
1028 Self::NotConfigured => formatter.write_str("configure IC_TESTKIT_POCKET_IC_URL or POCKET_IC_BIN; prepare a verified binary with make install-tools"),
1029 Self::InvalidEnvironment { variable } => write!(formatter, "invalid selected environment value: {variable}"),
1030 Self::ServerVersionProbe { source } => write!(formatter, "PocketIC version probe failed: {source}"),
1031 Self::ServerVersionMismatch { expected, observed } => write!(formatter, "PocketIC version mismatch: expected {expected:?}, observed {:?}", String::from_utf8_lossy(observed)),
1032 Self::CommandRun { program, source, termination_error } => {
1033 write!(formatter, "command {} failed: {source}", program.display())?;
1034 if let Some(error) = termination_error { write!(formatter, "; cleanup also failed: {error}")?; }
1035 Ok(())
1036 }
1037 Self::InvalidConfiguration { message } => formatter.write_str(message),
1038 Self::InvalidServerUrl {
1039 server_url,
1040 message,
1041 } => write!(
1042 formatter,
1043 "invalid PocketIC server URL {server_url:?}: {message}"
1044 ),
1045 Self::Io {
1046 operation,
1047 path,
1048 source,
1049 } => write!(
1050 formatter,
1051 "failed to {operation} at {}: {source}",
1052 path.display()
1053 ),
1054 Self::ServerSpawn {
1055 server_binary,
1056 source,
1057 } => write!(
1058 formatter,
1059 "failed to spawn PocketIC server {}: {source}",
1060 server_binary.display()
1061 ),
1062 Self::ServerExited {
1063 server_binary,
1064 status,
1065 elapsed,
1066 stderr,
1067 ..
1068 } => write!(
1069 formatter,
1070 "PocketIC server {} exited with {status} after {elapsed:?}: {stderr}",
1071 server_binary.display()
1072 ),
1073 Self::ReadinessTimeout {
1074 server_binary,
1075 timeout,
1076 ..
1077 } => write!(
1078 formatter,
1079 "PocketIC server {} was not ready within {timeout:?}",
1080 server_binary.display()
1081 ),
1082 Self::InvalidServerPort {
1083 server_binary,
1084 value,
1085 ..
1086 } => write!(
1087 formatter,
1088 "PocketIC server {} published invalid port {value:?}",
1089 server_binary.display()
1090 ),
1091 Self::InstanceCreationTimeout { timeout, .. } => {
1092 write!(formatter, "PocketIC instance creation exceeded {timeout:?}")
1093 }
1094 Self::BuilderThreadSpawn { source } => {
1095 write!(
1096 formatter,
1097 "failed to spawn PocketIC builder worker: {source}"
1098 )
1099 }
1100 Self::BuilderPanicked { message } => {
1101 write!(formatter, "PocketIC startup panicked: {message}")
1102 }
1103 Self::BuilderDisconnected => {
1104 formatter.write_str("PocketIC builder worker disconnected without a result")
1105 }
1106 }
1107 }
1108}
1109
1110impl std::error::Error for PocketIcStartupError {
1111 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1112 match self {
1113 Self::ServerVersionProbe { source } => Some(source),
1114 Self::Io { source, .. }
1115 | Self::CommandRun { source, .. }
1116 | Self::ServerSpawn { source, .. }
1117 | Self::BuilderThreadSpawn { source } => Some(source),
1118 _ => None,
1119 }
1120 }
1121}
1122
1123#[cfg(test)]
1124mod tests {
1125 use std::{
1126 fs,
1127 path::PathBuf,
1128 time::{Duration, Instant},
1129 };
1130
1131 use super::{
1132 PocketIcBuilderExt as _, PocketIcStartupConfig, PocketIcStartupError, StartupFiles,
1133 };
1134 use pocket_ic::PocketIcBuilder;
1135
1136 #[cfg(unix)]
1137 use crate::test_executable::write_executable_script;
1138 #[cfg(unix)]
1139 use std::{
1140 io::Write as _,
1141 os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _},
1142 process::Command,
1143 sync::mpsc,
1144 };
1145
1146 #[cfg(unix)]
1147 #[test]
1148 fn command_cancellation_before_startup_has_no_spawn_effects() {
1149 let error = PocketIcStartupConfig::spawn("/missing/server", Duration::from_secs(1))
1150 .run_command(&mut Command::new("/missing/command"), || true)
1151 .unwrap_err();
1152 assert!(
1153 matches!(error, PocketIcStartupError::Io { source, .. } if source.kind() == std::io::ErrorKind::Interrupted)
1154 );
1155 }
1156
1157 #[cfg(unix)]
1158 #[test]
1159 fn cancellation_callback_panic_still_reaps_the_owned_command() {
1160 let script = TestServerScript::new(
1161 "cancel-panic",
1162 "#!/bin/sh\nprintf '%s' \"$$\" > \"$1\"\nexec sleep 30\n",
1163 );
1164 let pid_file = script.path().with_extension("pid");
1165 let result = std::panic::catch_unwind(|| {
1166 PocketIcStartupConfig::connect("http://127.0.0.1:12345/", Duration::from_secs(1))
1167 .run_command(Command::new(script.path()).arg(&pid_file), || {
1168 assert!(
1169 !fs::read_to_string(&pid_file).is_ok_and(|value| !value.is_empty()),
1170 "caller cancellation failed"
1171 );
1172 false
1173 })
1174 });
1175 assert!(result.is_err());
1176 let pid = fs::read_to_string(&pid_file).unwrap().parse().unwrap();
1177 assert!(process_state(pid).is_none_or(|state| state == 'Z'));
1178 fs::remove_file(pid_file).unwrap();
1179 }
1180
1181 #[cfg(unix)]
1182 #[test]
1183 fn environment_selection_prefers_urls_and_fails_closed() {
1184 use std::os::unix::ffi::OsStringExt as _;
1185
1186 let timeout = Duration::from_secs(1);
1187 let config = PocketIcStartupConfig::from_environment(timeout, |name| {
1188 Some(
1189 if name == "IC_TESTKIT_POCKET_IC_URL" {
1190 "http://127.0.0.1:12345/"
1191 } else {
1192 "/missing/server"
1193 }
1194 .into(),
1195 )
1196 })
1197 .unwrap();
1198 assert_eq!(config.server_url(), Some("http://127.0.0.1:12345/"));
1199 assert!(config.server_binary().is_none());
1200 assert!(matches!(
1201 PocketIcStartupConfig::from_environment(timeout, |_| None),
1202 Err(PocketIcStartupError::NotConfigured)
1203 ));
1204 assert!(matches!(
1205 PocketIcStartupConfig::from_environment(timeout, |_| Some("".into())),
1206 Err(PocketIcStartupError::InvalidEnvironment {
1207 variable: "IC_TESTKIT_POCKET_IC_URL"
1208 })
1209 ));
1210 assert!(matches!(
1211 PocketIcStartupConfig::from_environment(timeout, |_| Some("bad URL".into())),
1212 Err(PocketIcStartupError::InvalidServerUrl { .. })
1213 ));
1214 assert!(matches!(
1215 PocketIcStartupConfig::from_environment(timeout, |_| Some(
1216 std::ffi::OsString::from_vec(vec![0xff])
1217 )),
1218 Err(PocketIcStartupError::InvalidEnvironment { .. })
1219 ));
1220 }
1221
1222 #[cfg(unix)]
1223 #[test]
1224 fn environment_binary_selection_uses_bounded_shared_version_capture() {
1225 for (label, body, expected) in [
1226 ("qualified", "printf 'pocket-ic-server 16.0.0\\n'", 0),
1227 ("wrong-version", "printf 'pocket-ic-server 15.0.0\\n'", 1),
1228 (
1229 "failed-version",
1230 "printf 'pocket-ic-server 16.0.0\\n'; exit 23",
1231 2,
1232 ),
1233 ("invalid-utf8", "printf '\\377'", 1),
1234 ("version-timeout", "exec sleep 30", 2),
1235 ] {
1236 let script = TestServerScript::new(
1237 label,
1238 &format!("#!/bin/sh\n[ \"$1\" = --version ] || exit 99\n{body}\n"),
1239 );
1240 let result =
1241 PocketIcStartupConfig::from_environment(Duration::from_millis(200), |name| {
1242 (name == "POCKET_IC_BIN").then(|| script.path().into_os_string())
1243 });
1244 match (expected, result) {
1245 (0, Ok(config)) => {
1246 assert_eq!(config.server_binary(), Some(script.path().as_path()));
1247 }
1248 (1, Err(PocketIcStartupError::ServerVersionMismatch { .. }))
1249 | (2, Err(PocketIcStartupError::ServerVersionProbe { .. })) => {}
1250 (_, result) => panic!("unexpected {label} result: {result:?}"),
1251 }
1252 }
1253 }
1254
1255 #[cfg(unix)]
1256 fn process_state(pid: u32) -> Option<char> {
1257 let output = Command::new("/bin/ps")
1260 .args(["-p", &pid.to_string(), "-o", "stat="])
1261 .output()
1262 .expect("inspect managed test process state");
1263 assert!(
1264 output.status.success()
1265 || (output.status.code() == Some(1)
1266 && output.stdout.is_empty()
1267 && output.stderr.is_empty()),
1268 "process-state inspection failed: {}: {}",
1269 output.status,
1270 String::from_utf8_lossy(&output.stderr),
1271 );
1272 String::from_utf8(output.stdout)
1273 .expect("process state is ASCII")
1274 .trim()
1275 .chars()
1276 .next()
1277 }
1278
1279 #[cfg(unix)]
1280 #[test]
1281 fn reading_large_sparse_server_output_is_bounded() {
1282 let (files, _, _) = StartupFiles::create().expect("allocate startup files");
1283 let mut file = fs::File::create(&files.stdout).expect("create sparse log");
1284 file.write_all(b"server started\n").expect("write prefix");
1285 let size = 8_u64 * 1024 * 1024 * 1024;
1286 file.set_len(size).expect("extend sparse log");
1287 let output = super::read_bounded_lossy(&files.stdout);
1288 assert!(output.starts_with("server started\n"));
1289 assert!(output.ends_with(&format!(
1290 "<truncated {} bytes>",
1291 size - super::SERVER_OUTPUT_LIMIT as u64
1292 )));
1293 assert!(output.len() < super::SERVER_OUTPUT_LIMIT + 100);
1294 }
1295
1296 #[cfg(unix)]
1297 #[test]
1298 fn startup_readers_reject_fifos_without_waiting_for_a_writer() {
1299 let (files, stdout, stderr) = StartupFiles::create().expect("allocate startup files");
1300 drop((stdout, stderr));
1301 fs::remove_file(&files.stdout).unwrap();
1302 fs::remove_file(&files.stderr).unwrap();
1303 assert!(
1304 Command::new("mkfifo")
1305 .args([&files.port, &files.stdout, &files.stderr])
1306 .status()
1307 .expect("create FIFO startup files")
1308 .success()
1309 );
1310 let server = super::ManagedServer {
1311 child: None,
1312 binary: PathBuf::from("unused-server"),
1313 files,
1314 started: Instant::now(),
1315 };
1316 for path in [
1317 &server.files.port,
1318 &server.files.stdout,
1319 &server.files.stderr,
1320 ] {
1321 let fifo = path.clone();
1325 let (stop_writer, stopped) = mpsc::channel();
1326 let writer = std::thread::spawn(move || {
1327 loop {
1328 match stopped.recv_timeout(Duration::from_millis(200)) {
1329 Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return false,
1330 Err(mpsc::RecvTimeoutError::Timeout) => {}
1331 }
1332 if fs::OpenOptions::new()
1333 .write(true)
1334 .custom_flags(libc::O_NONBLOCK)
1335 .open(&fifo)
1336 .is_ok()
1337 {
1338 return true;
1339 }
1340 }
1341 });
1342 let result = if path == &server.files.port {
1343 Some(server.read_port())
1344 } else {
1345 assert_eq!(super::read_bounded_lossy(path), "");
1346 None
1347 };
1348 let _ = stop_writer.send(());
1349 assert!(
1350 !writer.join().expect("join delayed FIFO writer"),
1351 "startup reader waited for a writer: {}",
1352 path.display(),
1353 );
1354 if let Some(result) = result {
1355 assert!(matches!(
1356 result,
1357 Err(PocketIcStartupError::Io { source, .. })
1358 if source.kind() == std::io::ErrorKind::InvalidData
1359 ));
1360 }
1361 }
1362 }
1363
1364 #[test]
1365 fn port_file_readiness_preserves_partial_writes_and_rejects_oversized_contents() {
1366 let (files, _, _) = StartupFiles::create().expect("allocate startup files");
1367 let server = super::ManagedServer {
1368 child: None,
1369 binary: PathBuf::from("unused-server"),
1370 files,
1371 started: Instant::now(),
1372 };
1373 assert!(matches!(
1374 server.read_port().unwrap(),
1375 super::PortFileState::Pending
1376 ));
1377 for contents in ["", "34567"] {
1378 fs::write(&server.files.port, contents).unwrap();
1379 assert!(matches!(
1380 server.read_port().unwrap(),
1381 super::PortFileState::Pending
1382 ));
1383 }
1384 for (contents, expected) in [("1\n", 1), ("65535\n", 65535), (" 34567\r\n", 34567)] {
1385 fs::write(&server.files.port, contents).unwrap();
1386 assert!(matches!(
1387 server.read_port().unwrap(),
1388 super::PortFileState::Ready(port) if port == expected
1389 ));
1390 }
1391 for contents in ["0\n", "65536\n", "invalid\n", "1\n2\n"] {
1392 fs::write(&server.files.port, contents).unwrap();
1393 assert!(matches!(
1394 server.read_port().unwrap(),
1395 super::PortFileState::Invalid(_)
1396 ));
1397 }
1398 fs::write(&server.files.port, [0xff, b'\n']).unwrap();
1399 assert!(matches!(
1400 server.read_port(),
1401 Err(PocketIcStartupError::Io { source, .. })
1402 if source.kind() == std::io::ErrorKind::InvalidData
1403 ));
1404 for contents in ["1\n".to_owned() + &" ".repeat(128), "0".repeat(128)] {
1405 fs::write(&server.files.port, contents).unwrap();
1406 assert!(
1407 matches!(
1408 server.read_port().unwrap(),
1409 super::PortFileState::Invalid(_)
1410 ),
1411 "oversized port contents must fail even without a newline",
1412 );
1413 }
1414 fs::File::options()
1416 .write(true)
1417 .open(&server.files.port)
1418 .unwrap()
1419 .set_len(1024 * 1024)
1420 .unwrap();
1421 assert!(matches!(
1422 server.read_port().unwrap(),
1423 super::PortFileState::Invalid(value) if value.len() < 256
1424 ));
1425 }
1426
1427 #[test]
1428 fn startup_config_requires_positive_bounds() {
1429 let error = PocketIcStartupConfig::connect("http://127.0.0.1:1/", Duration::ZERO)
1430 .validate()
1431 .expect_err("zero startup timeout must fail");
1432 assert!(matches!(
1433 error,
1434 PocketIcStartupError::InvalidConfiguration { .. }
1435 ));
1436
1437 let error = PocketIcStartupConfig::spawn("pocket-ic", Duration::from_secs(1))
1438 .with_server_hard_ttl(Duration::from_millis(1))
1439 .validate()
1440 .expect_err("subsecond server hard TTL must fail");
1441 assert!(matches!(
1442 error,
1443 PocketIcStartupError::InvalidConfiguration { .. }
1444 ));
1445 }
1446
1447 #[test]
1448 fn managed_server_hard_ttl_is_opt_in() {
1449 let default = PocketIcStartupConfig::spawn("pocket-ic", Duration::from_secs(1));
1450 assert_eq!(default.server_hard_ttl(), None);
1451
1452 let explicit = default.with_server_hard_ttl(Duration::from_secs(17));
1453 assert_eq!(explicit.server_hard_ttl(), Some(Duration::from_secs(17)));
1454 }
1455
1456 #[test]
1457 fn startup_files_leave_the_server_owned_port_path_absent() {
1458 let (files, stdout, stderr) = StartupFiles::create().expect("allocate startup files");
1459 let directory = files.directory.clone();
1460
1461 assert!(directory.is_dir());
1462 assert!(!files.port.exists());
1463 assert!(files.stdout.is_file());
1464 assert!(files.stderr.is_file());
1465 #[cfg(unix)]
1466 {
1467 let mode = fs::metadata(&directory)
1468 .expect("inspect private startup directory")
1469 .permissions()
1470 .mode();
1471 assert_eq!(mode & 0o077, 0);
1472 }
1473
1474 drop(stdout);
1475 drop(stderr);
1476 drop(files);
1477 assert!(!directory.exists());
1478 }
1479
1480 #[cfg(unix)]
1481 #[test]
1482 fn managed_startup_reports_an_exited_server_with_bounded_output() {
1483 let script = TestServerScript::new(
1484 "exit",
1485 "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nprintf 'synthetic server stdout'\nprintf 'synthetic bind failure' >&2\nexit 23\n",
1486 );
1487
1488 let result = PocketIcBuilder::new().with_application_subnet().try_build(
1489 PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2)),
1490 );
1491
1492 let Err(PocketIcStartupError::ServerExited {
1493 server_binary,
1494 status,
1495 stdout,
1496 stderr,
1497 ..
1498 }) = result
1499 else {
1500 panic!(
1501 "an exited managed server must return a structured exit error; got {:?}",
1502 result.err(),
1503 );
1504 };
1505 assert_eq!(server_binary, script.path());
1506 assert_eq!(status.code(), Some(23));
1507 assert_eq!(stdout, "synthetic server stdout");
1508 assert_eq!(stderr, "synthetic bind failure");
1509 }
1510
1511 #[cfg(unix)]
1512 #[test]
1513 fn managed_startup_rejects_oversized_port_files_and_cleans_up() {
1514 let script = TestServerScript::new(
1515 "oversized-port",
1516 "#!/bin/sh\nprintf '%s\\n%s\\n' \"$$\" \"$2\"\nprintf '34567\\n%064s' '' > \"$2.pending\"\nmv \"$2.pending\" \"$2\"\nexec sleep 30\n",
1517 );
1518 let result = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
1519 .start_managed_server();
1520 let Err(PocketIcStartupError::InvalidServerPort { value, stdout, .. }) = result else {
1521 panic!("oversized port publication must fail readiness");
1522 };
1523 assert!(value.contains("port file exceeds"));
1524 assert!(value.len() < 256);
1525 let mut lines = stdout.lines();
1526 let pid = lines.next().unwrap().parse::<u32>().unwrap();
1527 let port_path = PathBuf::from(lines.next().unwrap());
1528 assert!(!port_path.parent().unwrap().exists());
1529 assert_eq!(process_state(pid), None, "failed server must be reaped");
1530 }
1531
1532 #[cfg(unix)]
1533 #[test]
1534 fn managed_startup_terminates_a_server_that_never_becomes_ready() {
1535 let script = TestServerScript::new(
1536 "timeout",
1537 "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nexec sleep 30\n",
1538 );
1539 let timeout = Duration::from_millis(100);
1540 let started = Instant::now();
1541
1542 let result = PocketIcBuilder::new()
1543 .with_application_subnet()
1544 .try_build(PocketIcStartupConfig::spawn(script.path(), timeout));
1545
1546 assert!(
1547 started.elapsed() < Duration::from_secs(2),
1548 "bounded startup should not wait for the sleeping child"
1549 );
1550 assert!(matches!(
1551 result,
1552 Err(PocketIcStartupError::ReadinessTimeout {
1553 server_binary,
1554 timeout: actual_timeout,
1555 termination_error: None,
1556 ..
1557 }) if server_binary == script.path() && actual_timeout == timeout
1558 ));
1559 }
1560
1561 #[cfg(unix)]
1562 #[test]
1563 fn managed_server_handle_exposes_process_id_url_output_and_raii_ownership() {
1564 let script = TestServerScript::new(
1565 "handle",
1566 "#!/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",
1567 );
1568
1569 let server = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
1570 .start_managed_server()
1571 .expect("start caller-owned managed server");
1572
1573 assert_eq!(server.url(), "http://127.0.0.1:34567/");
1574 assert_eq!(
1575 server.output().stdout(),
1576 format!("managed server ready: {}", server.process_id())
1577 );
1578 assert_eq!(server.output().stderr(), "");
1579 let pid = server.process_id();
1580 assert!(process_state(pid).is_some_and(|state| state != 'Z'));
1581 drop(server);
1582 assert_eq!(process_state(pid), None, "owned server must be reaped");
1583 }
1584
1585 #[cfg(unix)]
1586 #[test]
1587 fn managed_server_cleans_descendants_on_drop_timeout_exit_and_background_reap() {
1588 for mode in ["drop", "timeout", "exit", "background"] {
1589 let publish = if matches!(mode, "drop" | "background") {
1590 "printf '34567\\n' > \"$2\"\n"
1591 } else {
1592 ""
1593 };
1594 let finish = if mode == "background" {
1595 "while [ -e \"$2\" ]; do sleep 0.01; done\nexit 23\n"
1598 } else if mode == "exit" {
1599 "sleep 0.03\nexit 23\n"
1600 } else {
1601 "exec sleep 30\n"
1602 };
1603 let script = TestServerScript::new(
1604 mode,
1605 &format!("#!/bin/sh\nsleep 30 &\nprintf '%s' \"$!\"\n{publish}{finish}"),
1606 );
1607 let result = PocketIcStartupConfig::spawn(script.path(), Duration::from_millis(300))
1608 .start_managed_server();
1609 let output = match result {
1610 Ok(server) => {
1611 let output = server.output().stdout().to_owned();
1612 if mode == "background" {
1613 let port = server.server.files.port.clone();
1614 server.server.reap_in_background();
1615 fs::remove_file(port).expect("release the background server after handoff");
1616 } else {
1617 drop(server);
1618 }
1619 output
1620 }
1621 Err(PocketIcStartupError::ReadinessTimeout {
1622 stdout,
1623 termination_error,
1624 ..
1625 }) => {
1626 assert_eq!(mode, "timeout");
1627 assert_eq!(termination_error, None);
1628 stdout
1629 }
1630 Err(PocketIcStartupError::ServerExited { stdout, status, .. }) => {
1631 assert_eq!(mode, "exit");
1632 assert_eq!(status.code(), Some(23));
1633 stdout
1634 }
1635 other => panic!(
1636 "unexpected {mode} startup result: {}",
1637 match other {
1638 Err(error) => error.to_string(),
1639 Ok(_) => unreachable!(),
1640 }
1641 ),
1642 };
1643 let pid = output
1644 .parse::<u32>()
1645 .expect("server published its descendant PID");
1646 let deadline = Instant::now() + Duration::from_secs(2);
1647 while process_state(pid).is_some_and(|state| state != 'Z') {
1648 assert!(
1649 Instant::now() < deadline,
1650 "{mode} left its descendant running"
1651 );
1652 std::thread::sleep(Duration::from_millis(10));
1653 }
1654 }
1655 }
1656
1657 #[cfg(unix)]
1658 #[test]
1659 fn managed_server_passes_an_explicit_hard_ttl() {
1660 let script = TestServerScript::new(
1661 "hard-ttl",
1662 "#!/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",
1663 );
1664
1665 let server = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
1666 .with_server_hard_ttl(Duration::from_secs(17))
1667 .start_managed_server()
1668 .expect("start managed server with an explicit hard TTL");
1669
1670 assert_eq!(server.url(), "http://127.0.0.1:34567/");
1671 }
1672
1673 #[test]
1674 #[ignore = "requires POCKET_IC_BIN=<caller-provided PocketIC server binary>"]
1675 fn caller_provided_server_publishes_port_constructs_instance_and_cleans_up() {
1676 let binary = std::env::var_os("POCKET_IC_BIN")
1677 .map(PathBuf::from)
1678 .expect("set POCKET_IC_BIN to the exact server binary");
1679 let one_shot_sequence = super::STARTUP_FILE_SEQUENCE.load(super::Ordering::Relaxed);
1680 let one_shot_directory = std::env::temp_dir().join(format!(
1681 "ic-testkit-pocket-ic-startup-{}-{one_shot_sequence}",
1682 std::process::id()
1683 ));
1684 let one_shot = PocketIcBuilder::new()
1685 .with_application_subnet()
1686 .try_build(
1687 PocketIcStartupConfig::spawn(&binary, Duration::from_secs(30))
1688 .with_server_hard_ttl(Duration::from_secs(1)),
1689 )
1690 .expect("one-shot managed spawn must construct an instance");
1691 assert!(one_shot_directory.is_dir());
1692 drop(one_shot);
1693 let cleanup_deadline = Instant::now() + Duration::from_secs(3);
1694 while one_shot_directory.exists() && Instant::now() < cleanup_deadline {
1695 std::thread::sleep(Duration::from_millis(20));
1696 }
1697 assert!(!one_shot_directory.exists());
1698
1699 let server = PocketIcStartupConfig::spawn(&binary, Duration::from_secs(30))
1700 .with_server_hard_ttl(Duration::from_secs(60))
1701 .start_managed_server()
1702 .expect("caller-provided PocketIC server must publish its port");
1703 let files = &server.server.files;
1704 let startup_directory = files.directory.clone();
1705
1706 assert!(files.port.is_file());
1707 let pocket_ic = PocketIcBuilder::new()
1708 .with_application_subnet()
1709 .try_build(PocketIcStartupConfig::connect(
1710 server.url(),
1711 Duration::from_secs(30),
1712 ))
1713 .expect("construct instance through caller-provided server");
1714
1715 drop(pocket_ic);
1716 drop(server);
1717 assert!(!startup_directory.exists());
1718 }
1719
1720 #[cfg(unix)]
1721 struct TestServerScript {
1722 path: PathBuf,
1723 }
1724
1725 #[cfg(unix)]
1726 impl TestServerScript {
1727 fn new(label: &str, contents: &str) -> Self {
1728 let path = std::env::temp_dir().join(format!(
1729 "ic-testkit-pocket-ic-{label}-{}-{}",
1730 std::process::id(),
1731 super::STARTUP_FILE_SEQUENCE.fetch_add(1, super::Ordering::Relaxed),
1732 ));
1733 write_executable_script(&path, contents);
1734 Self { path }
1735 }
1736
1737 fn path(&self) -> PathBuf {
1738 self.path.clone()
1739 }
1740 }
1741
1742 #[cfg(unix)]
1743 impl Drop for TestServerScript {
1744 fn drop(&mut self) {
1745 let _ = fs::remove_file(&self.path);
1746 }
1747 }
1748}