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 InvalidConfiguration { message: String },
79 InvalidServerUrl { server_url: String, message: String },
81 Io {
83 operation: &'static str,
84 path: PathBuf,
85 source: io::Error,
86 },
87 ServerSpawn {
89 server_binary: PathBuf,
90 source: io::Error,
91 },
92 ServerExited {
94 server_binary: PathBuf,
95 status: ExitStatus,
96 elapsed: Duration,
97 stdout: String,
98 stderr: String,
99 },
100 ReadinessTimeout {
102 server_binary: PathBuf,
103 timeout: Duration,
104 stdout: String,
105 stderr: String,
106 termination_error: Option<String>,
107 },
108 InvalidServerPort {
110 server_binary: PathBuf,
111 value: String,
112 stdout: String,
113 stderr: String,
114 },
115 InstanceCreationTimeout {
117 timeout: Duration,
118 stdout: String,
119 stderr: String,
120 termination_error: Option<String>,
121 },
122 BuilderThreadSpawn { source: io::Error },
124 BuilderPanicked { message: String },
126 BuilderDisconnected,
128}
129
130pub trait PocketIcBuilderExt {
136 fn try_build(self, config: PocketIcStartupConfig) -> Result<PocketIc, PocketIcStartupError>;
146}
147
148impl PocketIcStartupConfig {
149 #[must_use]
154 pub fn spawn(server_binary: impl Into<PathBuf>, timeout: Duration) -> Self {
155 Self {
156 source: PocketIcStartupSource::Spawn {
157 server_binary: server_binary.into(),
158 },
159 timeout,
160 server_hard_ttl: None,
161 }
162 }
163
164 #[must_use]
169 pub fn connect(server_url: impl Into<String>, timeout: Duration) -> Self {
170 Self {
171 source: PocketIcStartupSource::Connect {
172 server_url: server_url.into(),
173 },
174 timeout,
175 server_hard_ttl: None,
176 }
177 }
178
179 #[must_use]
181 pub const fn with_server_hard_ttl(mut self, hard_ttl: Duration) -> Self {
182 self.server_hard_ttl = Some(hard_ttl);
183 self
184 }
185
186 #[must_use]
188 pub const fn timeout(&self) -> Duration {
189 self.timeout
190 }
191
192 #[must_use]
194 pub const fn server_hard_ttl(&self) -> Option<Duration> {
195 self.server_hard_ttl
196 }
197
198 #[must_use]
200 pub fn server_binary(&self) -> Option<&Path> {
201 match &self.source {
202 PocketIcStartupSource::Spawn { server_binary } => Some(server_binary),
203 PocketIcStartupSource::Connect { .. } => None,
204 }
205 }
206
207 #[must_use]
209 pub fn server_url(&self) -> Option<&str> {
210 match &self.source {
211 PocketIcStartupSource::Connect { server_url } => Some(server_url),
212 PocketIcStartupSource::Spawn { .. } => None,
213 }
214 }
215
216 pub fn start_managed_server(self) -> Result<PocketIcManagedServer, PocketIcStartupError> {
228 self.validate()?;
229 let PocketIcStartupSource::Spawn { server_binary } = self.source else {
230 return Err(PocketIcStartupError::InvalidConfiguration {
231 message: "starting a managed PocketIC server requires a spawn configuration"
232 .to_owned(),
233 });
234 };
235 let started = Instant::now();
236 let deadline = startup_deadline(started, self.timeout)?;
237 let (server, url) = ManagedServer::start(
238 server_binary,
239 self.server_hard_ttl,
240 deadline,
241 self.timeout,
242 started,
243 )?;
244 Ok(PocketIcManagedServer { server, url })
245 }
246
247 fn validate(&self) -> Result<(), PocketIcStartupError> {
248 if self.timeout.is_zero() {
249 return Err(PocketIcStartupError::InvalidConfiguration {
250 message: "PocketIC startup timeout must be greater than zero".to_owned(),
251 });
252 }
253 if matches!(&self.source, PocketIcStartupSource::Spawn { .. })
254 && self
255 .server_hard_ttl
256 .is_some_and(|hard_ttl| hard_ttl.as_secs() == 0)
257 {
258 return Err(PocketIcStartupError::InvalidConfiguration {
259 message: "PocketIC server hard TTL must be at least one second".to_owned(),
260 });
261 }
262 Ok(())
263 }
264}
265
266impl PocketIcManagedServer {
267 #[must_use]
274 pub fn process_id(&self) -> u32 {
275 self.server
276 .child
277 .as_ref()
278 .expect("managed server handle must own its child")
279 .id()
280 }
281
282 #[must_use]
284 pub fn url(&self) -> &str {
285 &self.url
286 }
287
288 #[must_use]
294 pub fn output(&self) -> PocketIcManagedServerOutput {
295 self.server.capture().into()
296 }
297}
298
299impl PocketIcManagedServerOutput {
300 #[must_use]
302 pub fn stdout(&self) -> &str {
303 &self.stdout
304 }
305
306 #[must_use]
308 pub fn stderr(&self) -> &str {
309 &self.stderr
310 }
311}
312
313impl PocketIcBuilderExt for PocketIcBuilder {
314 fn try_build(self, config: PocketIcStartupConfig) -> Result<PocketIc, PocketIcStartupError> {
315 config.validate()?;
316 let started = Instant::now();
317 let deadline = startup_deadline(started, config.timeout)?;
318 match config.source {
319 PocketIcStartupSource::Connect { server_url } => {
320 build_bounded(self, &server_url, deadline, config.timeout, None)
321 }
322 PocketIcStartupSource::Spawn { server_binary } => {
323 let (server, server_url) = ManagedServer::start(
324 server_binary,
325 config.server_hard_ttl,
326 deadline,
327 config.timeout,
328 started,
329 )?;
330 build_bounded(self, &server_url, deadline, config.timeout, Some(server))
331 }
332 }
333 }
334}
335
336fn startup_deadline(started: Instant, timeout: Duration) -> Result<Instant, PocketIcStartupError> {
337 started
338 .checked_add(timeout)
339 .ok_or_else(|| PocketIcStartupError::InvalidConfiguration {
340 message: "PocketIC startup timeout exceeds the platform clock range".to_owned(),
341 })
342}
343
344fn build_bounded(
345 builder: PocketIcBuilder,
346 server_url: &str,
347 deadline: Instant,
348 timeout: Duration,
349 mut server: Option<ManagedServer>,
350) -> Result<PocketIc, PocketIcStartupError> {
351 let builder = match server_url.parse() {
352 Ok(server_url) => builder.with_server_url(server_url),
353 Err(error) => {
354 return Err(PocketIcStartupError::InvalidServerUrl {
355 server_url: server_url.to_owned(),
356 message: error.to_string(),
357 });
358 }
359 };
360 let (sender, receiver) = mpsc::sync_channel(1);
361 if let Err(source) = thread::Builder::new()
362 .name("ic-testkit-pocket-ic-startup".to_owned())
363 .spawn(move || {
364 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| builder.build()))
365 .map_err(|payload| transport::panic_payload_to_string(payload.as_ref()));
366 let _ = sender.send(result);
367 })
368 {
369 return Err(PocketIcStartupError::BuilderThreadSpawn { source });
370 }
371
372 loop {
373 let now = Instant::now();
374 if now >= deadline {
375 let captured = server.take().map_or_else(
376 CapturedServer::default,
377 ManagedServer::terminate_and_capture,
378 );
379 return Err(PocketIcStartupError::InstanceCreationTimeout {
380 timeout,
381 stdout: captured.stdout,
382 stderr: captured.stderr,
383 termination_error: captured.termination_error,
384 });
385 }
386 let remaining = deadline.saturating_duration_since(now);
387 let wait = if server.is_some() {
388 remaining.min(STARTUP_POLL_INTERVAL)
389 } else {
390 remaining
391 };
392 match receiver.recv_timeout(wait) {
393 Ok(Ok(pocket_ic)) => {
394 if let Some(mut managed) = server.take() {
395 if let Some(status) = managed.try_wait()? {
396 return Err(managed.exited_error(status));
397 }
398 managed.reap_in_background();
399 }
400 return Ok(pocket_ic);
401 }
402 Ok(Err(message)) => {
403 if let Some(server) = server.take() {
404 let _ = server.terminate_and_capture();
405 }
406 return Err(PocketIcStartupError::BuilderPanicked { message });
407 }
408 Err(RecvTimeoutError::Disconnected) => {
409 if let Some(server) = server.take() {
410 let _ = server.terminate_and_capture();
411 }
412 return Err(PocketIcStartupError::BuilderDisconnected);
413 }
414 Err(RecvTimeoutError::Timeout) => {
415 if let Some(managed) = &mut server
416 && let Some(status) = managed.try_wait()?
417 {
418 return Err(server
419 .take()
420 .expect("managed server must remain present")
421 .exited_error(status));
422 }
423 }
424 }
425 }
426}
427
428struct ManagedServer {
429 child: Option<Child>,
430 binary: PathBuf,
431 files: StartupFiles,
432 started: Instant,
433}
434
435enum PortFileState {
436 Pending,
437 Ready(u16),
438 Invalid(String),
439}
440
441impl ManagedServer {
442 fn start(
443 binary: PathBuf,
444 hard_ttl: Option<Duration>,
445 deadline: Instant,
446 timeout: Duration,
447 started: Instant,
448 ) -> Result<(Self, String), PocketIcStartupError> {
449 let (files, stdout, stderr) = StartupFiles::create()?;
450 let mut command = Command::new(&binary);
451 if let Some(hard_ttl) = hard_ttl {
452 command
453 .arg("--hard-ttl")
454 .arg(hard_ttl.as_secs().to_string());
455 }
456 command
457 .arg("--port-file")
458 .arg(&files.port)
459 .stdout(Stdio::from(stdout))
460 .stderr(Stdio::from(stderr));
461 #[cfg(unix)]
462 {
463 command.process_group(0);
464 }
465 let child = command
466 .spawn()
467 .map_err(|source| PocketIcStartupError::ServerSpawn {
468 server_binary: binary.clone(),
469 source,
470 })?;
471 let mut server = Self {
472 child: Some(child),
473 binary,
474 files,
475 started,
476 };
477
478 loop {
479 if let Some(status) = server.try_wait()? {
480 return Err(server.exited_error(status));
481 }
482 let now = Instant::now();
483 if now >= deadline {
484 let binary = server.binary.clone();
485 let captured = server.terminate_and_capture();
486 return Err(PocketIcStartupError::ReadinessTimeout {
487 server_binary: binary,
488 timeout,
489 stdout: captured.stdout,
490 stderr: captured.stderr,
491 termination_error: captured.termination_error,
492 });
493 }
494 match server.read_port()? {
495 PortFileState::Pending => {}
496 PortFileState::Ready(port) => {
497 return Ok((server, format!("http://127.0.0.1:{port}/")));
498 }
499 PortFileState::Invalid(value) => {
500 let binary = server.binary.clone();
501 let captured = server.terminate_and_capture();
502 return Err(PocketIcStartupError::InvalidServerPort {
503 server_binary: binary,
504 value,
505 stdout: captured.stdout,
506 stderr: captured.stderr,
507 });
508 }
509 }
510 thread::sleep(
511 deadline
512 .saturating_duration_since(now)
513 .min(STARTUP_POLL_INTERVAL),
514 );
515 }
516 }
517
518 fn try_wait(&mut self) -> Result<Option<ExitStatus>, PocketIcStartupError> {
519 let child = self
520 .child
521 .as_mut()
522 .expect("managed server child must remain present");
523 poll_child(child).map_err(|source| PocketIcStartupError::Io {
524 operation: "inspect PocketIC server child",
525 path: self.binary.clone(),
526 source,
527 })
528 }
529
530 fn read_port(&self) -> Result<PortFileState, PocketIcStartupError> {
531 let port_path = &self.files.port;
532 let mut contents = String::new();
533 match open_regular_startup_file(port_path).and_then(|file| {
534 file.take((SERVER_PORT_FILE_LIMIT + 1) as u64)
535 .read_to_string(&mut contents)
536 }) {
537 Ok(_) => {}
538 Err(error) if error.kind() == io::ErrorKind::NotFound => {
539 return Ok(PortFileState::Pending);
540 }
541 Err(source) => {
542 return Err(PocketIcStartupError::Io {
543 operation: "read PocketIC server port file",
544 path: port_path.clone(),
545 source,
546 });
547 }
548 }
549 if contents.len() > SERVER_PORT_FILE_LIMIT {
550 return Ok(PortFileState::Invalid(format!(
551 "{} (port file exceeds {SERVER_PORT_FILE_LIMIT} bytes)",
552 contents.trim()
553 )));
554 }
555 if !contents.contains('\n') {
556 return Ok(PortFileState::Pending);
557 }
558 let value = contents.trim().to_owned();
559 match value.parse::<u16>() {
560 Ok(port) if port != 0 => Ok(PortFileState::Ready(port)),
561 _ => Ok(PortFileState::Invalid(value)),
562 }
563 }
564
565 fn exited_error(mut self, status: ExitStatus) -> PocketIcStartupError {
566 let elapsed = self.started.elapsed();
567 let binary = self.binary.clone();
568 self.child.take();
569 let captured = self.capture();
570 PocketIcStartupError::ServerExited {
571 server_binary: binary,
572 status,
573 elapsed,
574 stdout: captured.stdout,
575 stderr: captured.stderr,
576 }
577 }
578
579 fn terminate_and_capture(mut self) -> CapturedServer {
580 let termination_error = match self.child.take() {
581 Some(mut child) => terminate_child(&mut child),
582 None => None,
583 };
584 let mut captured = self.capture();
585 captured.termination_error = termination_error;
586 captured
587 }
588
589 fn capture(&self) -> CapturedServer {
590 let files = &self.files;
591 CapturedServer {
592 stdout: read_bounded_lossy(&files.stdout),
593 stderr: read_bounded_lossy(&files.stderr),
594 termination_error: None,
595 }
596 }
597
598 fn reap_in_background(self) {
599 let _ = thread::Builder::new()
600 .name("ic-testkit-pocket-ic-server-reaper".to_owned())
601 .spawn(move || {
602 let mut server = self;
605 if let Some(child) = server.child.as_mut() {
606 #[cfg(unix)]
607 let _ = wait_for_child_exit(child, false);
608 #[cfg(not(unix))]
609 let _ = child.wait();
610 }
611 });
612 }
613}
614
615impl Drop for ManagedServer {
616 fn drop(&mut self) {
617 if let Some(mut child) = self.child.take() {
618 let _ = terminate_child(&mut child);
619 }
620 }
621}
622
623#[cfg(unix)]
624fn wait_for_child_exit(child: &Child, nonblocking: bool) -> io::Result<bool> {
625 let flags = libc::WEXITED | libc::WNOWAIT | if nonblocking { libc::WNOHANG } else { 0 };
627 loop {
628 let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
630 let result = unsafe { libc::waitid(libc::P_PID, child.id(), &raw mut info, flags) };
632 if result == 0 {
633 return Ok(info.si_signo != 0);
634 }
635 let error = io::Error::last_os_error();
636 if error.kind() != io::ErrorKind::Interrupted {
637 return Err(error);
638 }
639 }
640}
641
642fn poll_child(child: &mut Child) -> io::Result<Option<ExitStatus>> {
643 #[cfg(unix)]
644 {
645 if !wait_for_child_exit(child, true)? {
646 return Ok(None);
647 }
648 terminate_process_group(child)?;
650 child.wait().map(Some)
651 }
652 #[cfg(not(unix))]
653 child.try_wait()
654}
655
656#[cfg(unix)]
657fn terminate_process_group(child: &Child) -> io::Result<()> {
658 let group = libc::pid_t::try_from(child.id())
659 .map_err(|_| io::Error::other("managed child PID exceeds the OS process ID range"))?;
660 loop {
661 let result = unsafe { libc::killpg(group, libc::SIGKILL) };
664 if result == 0 {
665 return Ok(());
666 }
667 let error = io::Error::last_os_error();
668 if error.raw_os_error() == Some(libc::ESRCH) {
669 return Ok(());
670 }
671 #[cfg(target_os = "macos")]
672 if error.raw_os_error() == Some(libc::EPERM) && wait_for_child_exit(child, true)? {
673 let mut members: [libc::pid_t; 2] = [0; 2];
678 let size = libc::c_int::try_from(std::mem::size_of_val(&members))
679 .expect("two process IDs fit in a libproc buffer size");
680 let count =
683 unsafe { libc::proc_listpgrppids(group, members.as_mut_ptr().cast(), size) };
684 if count == 1 && members[0] == group {
685 return Ok(());
686 }
687 }
688 if error.kind() != io::ErrorKind::Interrupted {
689 return Err(error);
690 }
691 }
692}
693
694#[cfg(unix)]
695fn terminate_child(child: &mut Child) -> Option<String> {
696 let termination = terminate_process_group(child);
697 if termination.is_err() {
698 let _ = child.kill();
699 }
700 let wait = child.wait();
701 termination
702 .and_then(|()| wait.map(|_| ()))
703 .err()
704 .map(|error| error.to_string())
705}
706
707#[cfg(not(unix))]
708fn terminate_child(child: &mut Child) -> Option<String> {
709 match child.try_wait() {
710 Ok(Some(_)) => None,
711 Ok(None) => child
712 .kill()
713 .and_then(|()| child.wait().map(|_| ()))
714 .err()
715 .map(|error| error.to_string()),
716 Err(inspect_error) => {
717 let termination_error = child.kill().and_then(|()| child.wait().map(|_| ())).err();
718 termination_error.map(|termination_error| {
719 format!(
720 "failed to inspect child before termination: {inspect_error}; termination also failed: {termination_error}"
721 )
722 })
723 }
724 }
725}
726
727#[derive(Default)]
728struct CapturedServer {
729 stdout: String,
730 stderr: String,
731 termination_error: Option<String>,
732}
733
734impl From<CapturedServer> for PocketIcManagedServerOutput {
735 fn from(captured: CapturedServer) -> Self {
736 Self {
737 stdout: captured.stdout,
738 stderr: captured.stderr,
739 }
740 }
741}
742
743struct StartupFiles {
744 directory: PathBuf,
745 port: PathBuf,
746 stdout: PathBuf,
747 stderr: PathBuf,
748}
749
750impl StartupFiles {
751 fn create() -> Result<(Self, File, File), PocketIcStartupError> {
752 loop {
753 let sequence = STARTUP_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
754 let base = std::env::temp_dir().join(format!(
755 "ic-testkit-pocket-ic-startup-{}-{sequence}",
756 std::process::id()
757 ));
758 let mut directory = fs::DirBuilder::new();
759 #[cfg(unix)]
760 {
761 directory.mode(0o700);
762 }
763 match directory.create(&base) {
764 Ok(()) => {}
765 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
766 Err(source) => return Err(startup_file_error("create", &base, source)),
767 }
768 let files = Self {
769 port: base.join("port"),
770 stdout: base.join("stdout"),
771 stderr: base.join("stderr"),
772 directory: base,
773 };
774 let stdout = create_new_file(&files.stdout)
775 .map_err(|source| startup_file_error("create", &files.stdout, source))?;
776 let stderr = create_new_file(&files.stderr)
777 .map_err(|source| startup_file_error("create", &files.stderr, source))?;
778 return Ok((files, stdout, stderr));
779 }
780 }
781}
782
783impl Drop for StartupFiles {
784 fn drop(&mut self) {
785 let _ = fs::remove_dir_all(&self.directory);
786 }
787}
788
789fn create_new_file(path: &Path) -> io::Result<File> {
790 OpenOptions::new().write(true).create_new(true).open(path)
791}
792
793fn startup_file_error(
794 operation: &'static str,
795 path: &Path,
796 source: io::Error,
797) -> PocketIcStartupError {
798 PocketIcStartupError::Io {
799 operation,
800 path: path.to_owned(),
801 source,
802 }
803}
804
805fn read_bounded_lossy(path: &Path) -> String {
806 let Ok(file) = open_regular_startup_file(path) else {
807 return String::new();
808 };
809 let length = file.metadata().map_or(0, |metadata| metadata.len());
810 let mut bytes = Vec::with_capacity(SERVER_OUTPUT_LIMIT);
811 if file
812 .take(SERVER_OUTPUT_LIMIT as u64)
813 .read_to_end(&mut bytes)
814 .is_err()
815 {
816 return String::new();
817 }
818 let mut output = String::from_utf8_lossy(&bytes).into_owned();
819 let omitted = length.saturating_sub(bytes.len() as u64);
820 if omitted > 0 {
821 let _ = write!(output, "\n<truncated {omitted} bytes>");
822 }
823 output
824}
825
826fn open_regular_startup_file(path: &Path) -> io::Result<File> {
827 let mut options = OpenOptions::new();
828 options.read(true);
829 #[cfg(unix)]
832 options.custom_flags(libc::O_NONBLOCK);
833 let file = options.open(path)?;
834 if !file.metadata()?.is_file() {
835 return Err(io::Error::new(
836 io::ErrorKind::InvalidData,
837 "PocketIC startup reader requires a regular file",
838 ));
839 }
840 Ok(file)
841}
842
843impl std::fmt::Display for PocketIcStartupError {
844 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
845 match self {
846 Self::InvalidConfiguration { message } => formatter.write_str(message),
847 Self::InvalidServerUrl {
848 server_url,
849 message,
850 } => write!(
851 formatter,
852 "invalid PocketIC server URL {server_url:?}: {message}"
853 ),
854 Self::Io {
855 operation,
856 path,
857 source,
858 } => write!(
859 formatter,
860 "failed to {operation} at {}: {source}",
861 path.display()
862 ),
863 Self::ServerSpawn {
864 server_binary,
865 source,
866 } => write!(
867 formatter,
868 "failed to spawn PocketIC server {}: {source}",
869 server_binary.display()
870 ),
871 Self::ServerExited {
872 server_binary,
873 status,
874 elapsed,
875 stderr,
876 ..
877 } => write!(
878 formatter,
879 "PocketIC server {} exited with {status} after {elapsed:?}: {stderr}",
880 server_binary.display()
881 ),
882 Self::ReadinessTimeout {
883 server_binary,
884 timeout,
885 ..
886 } => write!(
887 formatter,
888 "PocketIC server {} was not ready within {timeout:?}",
889 server_binary.display()
890 ),
891 Self::InvalidServerPort {
892 server_binary,
893 value,
894 ..
895 } => write!(
896 formatter,
897 "PocketIC server {} published invalid port {value:?}",
898 server_binary.display()
899 ),
900 Self::InstanceCreationTimeout { timeout, .. } => {
901 write!(formatter, "PocketIC instance creation exceeded {timeout:?}")
902 }
903 Self::BuilderThreadSpawn { source } => {
904 write!(
905 formatter,
906 "failed to spawn PocketIC builder worker: {source}"
907 )
908 }
909 Self::BuilderPanicked { message } => {
910 write!(formatter, "PocketIC startup panicked: {message}")
911 }
912 Self::BuilderDisconnected => {
913 formatter.write_str("PocketIC builder worker disconnected without a result")
914 }
915 }
916 }
917}
918
919impl std::error::Error for PocketIcStartupError {
920 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
921 match self {
922 Self::Io { source, .. }
923 | Self::ServerSpawn { source, .. }
924 | Self::BuilderThreadSpawn { source } => Some(source),
925 _ => None,
926 }
927 }
928}
929
930#[cfg(test)]
931mod tests {
932 use std::{
933 fs,
934 path::PathBuf,
935 time::{Duration, Instant},
936 };
937
938 use super::{
939 PocketIcBuilderExt as _, PocketIcStartupConfig, PocketIcStartupError, StartupFiles,
940 };
941 use pocket_ic::PocketIcBuilder;
942
943 #[cfg(unix)]
944 use crate::test_executable::write_executable_script;
945 #[cfg(unix)]
946 use std::{
947 io::Write as _,
948 os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _},
949 process::Command,
950 sync::mpsc,
951 };
952
953 #[cfg(unix)]
954 fn process_state(pid: u32) -> Option<char> {
955 let output = Command::new("/bin/ps")
958 .args(["-p", &pid.to_string(), "-o", "stat="])
959 .output()
960 .expect("inspect managed test process state");
961 assert!(
962 output.status.success()
963 || (output.status.code() == Some(1)
964 && output.stdout.is_empty()
965 && output.stderr.is_empty()),
966 "process-state inspection failed: {}: {}",
967 output.status,
968 String::from_utf8_lossy(&output.stderr),
969 );
970 String::from_utf8(output.stdout)
971 .expect("process state is ASCII")
972 .trim()
973 .chars()
974 .next()
975 }
976
977 #[cfg(unix)]
978 #[test]
979 fn reading_large_sparse_server_output_is_bounded() {
980 let (files, _, _) = StartupFiles::create().expect("allocate startup files");
981 let mut file = fs::File::create(&files.stdout).expect("create sparse log");
982 file.write_all(b"server started\n").expect("write prefix");
983 let size = 8_u64 * 1024 * 1024 * 1024;
984 file.set_len(size).expect("extend sparse log");
985 let output = super::read_bounded_lossy(&files.stdout);
986 assert!(output.starts_with("server started\n"));
987 assert!(output.ends_with(&format!(
988 "<truncated {} bytes>",
989 size - super::SERVER_OUTPUT_LIMIT as u64
990 )));
991 assert!(output.len() < super::SERVER_OUTPUT_LIMIT + 100);
992 }
993
994 #[cfg(unix)]
995 #[test]
996 fn startup_readers_reject_fifos_without_waiting_for_a_writer() {
997 let (files, stdout, stderr) = StartupFiles::create().expect("allocate startup files");
998 drop((stdout, stderr));
999 fs::remove_file(&files.stdout).unwrap();
1000 fs::remove_file(&files.stderr).unwrap();
1001 assert!(
1002 Command::new("mkfifo")
1003 .args([&files.port, &files.stdout, &files.stderr])
1004 .status()
1005 .expect("create FIFO startup files")
1006 .success()
1007 );
1008 let server = super::ManagedServer {
1009 child: None,
1010 binary: PathBuf::from("unused-server"),
1011 files,
1012 started: Instant::now(),
1013 };
1014 for path in [
1015 &server.files.port,
1016 &server.files.stdout,
1017 &server.files.stderr,
1018 ] {
1019 let fifo = path.clone();
1023 let (stop_writer, stopped) = mpsc::channel();
1024 let writer = std::thread::spawn(move || {
1025 loop {
1026 match stopped.recv_timeout(Duration::from_millis(200)) {
1027 Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return false,
1028 Err(mpsc::RecvTimeoutError::Timeout) => {}
1029 }
1030 if fs::OpenOptions::new()
1031 .write(true)
1032 .custom_flags(libc::O_NONBLOCK)
1033 .open(&fifo)
1034 .is_ok()
1035 {
1036 return true;
1037 }
1038 }
1039 });
1040 let result = if path == &server.files.port {
1041 Some(server.read_port())
1042 } else {
1043 assert_eq!(super::read_bounded_lossy(path), "");
1044 None
1045 };
1046 let _ = stop_writer.send(());
1047 assert!(
1048 !writer.join().expect("join delayed FIFO writer"),
1049 "startup reader waited for a writer: {}",
1050 path.display(),
1051 );
1052 if let Some(result) = result {
1053 assert!(matches!(
1054 result,
1055 Err(PocketIcStartupError::Io { source, .. })
1056 if source.kind() == std::io::ErrorKind::InvalidData
1057 ));
1058 }
1059 }
1060 }
1061
1062 #[test]
1063 fn port_file_readiness_preserves_partial_writes_and_rejects_oversized_contents() {
1064 let (files, _, _) = StartupFiles::create().expect("allocate startup files");
1065 let server = super::ManagedServer {
1066 child: None,
1067 binary: PathBuf::from("unused-server"),
1068 files,
1069 started: Instant::now(),
1070 };
1071 assert!(matches!(
1072 server.read_port().unwrap(),
1073 super::PortFileState::Pending
1074 ));
1075 for contents in ["", "34567"] {
1076 fs::write(&server.files.port, contents).unwrap();
1077 assert!(matches!(
1078 server.read_port().unwrap(),
1079 super::PortFileState::Pending
1080 ));
1081 }
1082 for (contents, expected) in [("1\n", 1), ("65535\n", 65535), (" 34567\r\n", 34567)] {
1083 fs::write(&server.files.port, contents).unwrap();
1084 assert!(matches!(
1085 server.read_port().unwrap(),
1086 super::PortFileState::Ready(port) if port == expected
1087 ));
1088 }
1089 for contents in ["0\n", "65536\n", "invalid\n", "1\n2\n"] {
1090 fs::write(&server.files.port, contents).unwrap();
1091 assert!(matches!(
1092 server.read_port().unwrap(),
1093 super::PortFileState::Invalid(_)
1094 ));
1095 }
1096 fs::write(&server.files.port, [0xff, b'\n']).unwrap();
1097 assert!(matches!(
1098 server.read_port(),
1099 Err(PocketIcStartupError::Io { source, .. })
1100 if source.kind() == std::io::ErrorKind::InvalidData
1101 ));
1102 for contents in ["1\n".to_owned() + &" ".repeat(128), "0".repeat(128)] {
1103 fs::write(&server.files.port, contents).unwrap();
1104 assert!(
1105 matches!(
1106 server.read_port().unwrap(),
1107 super::PortFileState::Invalid(_)
1108 ),
1109 "oversized port contents must fail even without a newline",
1110 );
1111 }
1112 fs::File::options()
1114 .write(true)
1115 .open(&server.files.port)
1116 .unwrap()
1117 .set_len(1024 * 1024)
1118 .unwrap();
1119 assert!(matches!(
1120 server.read_port().unwrap(),
1121 super::PortFileState::Invalid(value) if value.len() < 256
1122 ));
1123 }
1124
1125 #[test]
1126 fn startup_config_requires_positive_bounds() {
1127 let error = PocketIcStartupConfig::connect("http://127.0.0.1:1/", Duration::ZERO)
1128 .validate()
1129 .expect_err("zero startup timeout must fail");
1130 assert!(matches!(
1131 error,
1132 PocketIcStartupError::InvalidConfiguration { .. }
1133 ));
1134
1135 let error = PocketIcStartupConfig::spawn("pocket-ic", Duration::from_secs(1))
1136 .with_server_hard_ttl(Duration::from_millis(1))
1137 .validate()
1138 .expect_err("subsecond server hard TTL must fail");
1139 assert!(matches!(
1140 error,
1141 PocketIcStartupError::InvalidConfiguration { .. }
1142 ));
1143 }
1144
1145 #[test]
1146 fn managed_server_hard_ttl_is_opt_in() {
1147 let default = PocketIcStartupConfig::spawn("pocket-ic", Duration::from_secs(1));
1148 assert_eq!(default.server_hard_ttl(), None);
1149
1150 let explicit = default.with_server_hard_ttl(Duration::from_secs(17));
1151 assert_eq!(explicit.server_hard_ttl(), Some(Duration::from_secs(17)));
1152 }
1153
1154 #[test]
1155 fn startup_files_leave_the_server_owned_port_path_absent() {
1156 let (files, stdout, stderr) = StartupFiles::create().expect("allocate startup files");
1157 let directory = files.directory.clone();
1158
1159 assert!(directory.is_dir());
1160 assert!(!files.port.exists());
1161 assert!(files.stdout.is_file());
1162 assert!(files.stderr.is_file());
1163 #[cfg(unix)]
1164 {
1165 let mode = fs::metadata(&directory)
1166 .expect("inspect private startup directory")
1167 .permissions()
1168 .mode();
1169 assert_eq!(mode & 0o077, 0);
1170 }
1171
1172 drop(stdout);
1173 drop(stderr);
1174 drop(files);
1175 assert!(!directory.exists());
1176 }
1177
1178 #[cfg(unix)]
1179 #[test]
1180 fn managed_startup_reports_an_exited_server_with_bounded_output() {
1181 let script = TestServerScript::new(
1182 "exit",
1183 "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nprintf 'synthetic server stdout'\nprintf 'synthetic bind failure' >&2\nexit 23\n",
1184 );
1185
1186 let result = PocketIcBuilder::new().with_application_subnet().try_build(
1187 PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2)),
1188 );
1189
1190 let Err(PocketIcStartupError::ServerExited {
1191 server_binary,
1192 status,
1193 stdout,
1194 stderr,
1195 ..
1196 }) = result
1197 else {
1198 panic!(
1199 "an exited managed server must return a structured exit error; got {:?}",
1200 result.err(),
1201 );
1202 };
1203 assert_eq!(server_binary, script.path());
1204 assert_eq!(status.code(), Some(23));
1205 assert_eq!(stdout, "synthetic server stdout");
1206 assert_eq!(stderr, "synthetic bind failure");
1207 }
1208
1209 #[cfg(unix)]
1210 #[test]
1211 fn managed_startup_rejects_oversized_port_files_and_cleans_up() {
1212 let script = TestServerScript::new(
1213 "oversized-port",
1214 "#!/bin/sh\nprintf '%s\\n%s\\n' \"$$\" \"$2\"\nprintf '34567\\n%064s' '' > \"$2.pending\"\nmv \"$2.pending\" \"$2\"\nexec sleep 30\n",
1215 );
1216 let result = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
1217 .start_managed_server();
1218 let Err(PocketIcStartupError::InvalidServerPort { value, stdout, .. }) = result else {
1219 panic!("oversized port publication must fail readiness");
1220 };
1221 assert!(value.contains("port file exceeds"));
1222 assert!(value.len() < 256);
1223 let mut lines = stdout.lines();
1224 let pid = lines.next().unwrap().parse::<u32>().unwrap();
1225 let port_path = PathBuf::from(lines.next().unwrap());
1226 assert!(!port_path.parent().unwrap().exists());
1227 assert_eq!(process_state(pid), None, "failed server must be reaped");
1228 }
1229
1230 #[cfg(unix)]
1231 #[test]
1232 fn managed_startup_terminates_a_server_that_never_becomes_ready() {
1233 let script = TestServerScript::new(
1234 "timeout",
1235 "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nexec sleep 30\n",
1236 );
1237 let timeout = Duration::from_millis(100);
1238 let started = Instant::now();
1239
1240 let result = PocketIcBuilder::new()
1241 .with_application_subnet()
1242 .try_build(PocketIcStartupConfig::spawn(script.path(), timeout));
1243
1244 assert!(
1245 started.elapsed() < Duration::from_secs(2),
1246 "bounded startup should not wait for the sleeping child"
1247 );
1248 assert!(matches!(
1249 result,
1250 Err(PocketIcStartupError::ReadinessTimeout {
1251 server_binary,
1252 timeout: actual_timeout,
1253 termination_error: None,
1254 ..
1255 }) if server_binary == script.path() && actual_timeout == timeout
1256 ));
1257 }
1258
1259 #[cfg(unix)]
1260 #[test]
1261 fn managed_server_handle_exposes_process_id_url_output_and_raii_ownership() {
1262 let script = TestServerScript::new(
1263 "handle",
1264 "#!/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",
1265 );
1266
1267 let server = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
1268 .start_managed_server()
1269 .expect("start caller-owned managed server");
1270
1271 assert_eq!(server.url(), "http://127.0.0.1:34567/");
1272 assert_eq!(
1273 server.output().stdout(),
1274 format!("managed server ready: {}", server.process_id())
1275 );
1276 assert_eq!(server.output().stderr(), "");
1277 let pid = server.process_id();
1278 assert!(process_state(pid).is_some_and(|state| state != 'Z'));
1279 drop(server);
1280 assert_eq!(process_state(pid), None, "owned server must be reaped");
1281 }
1282
1283 #[cfg(unix)]
1284 #[test]
1285 fn managed_server_cleans_descendants_on_drop_timeout_exit_and_background_reap() {
1286 for mode in ["drop", "timeout", "exit", "background"] {
1287 let publish = if matches!(mode, "drop" | "background") {
1288 "printf '34567\\n' > \"$2\"\n"
1289 } else {
1290 ""
1291 };
1292 let finish = if mode == "background" {
1293 "while [ -e \"$2\" ]; do sleep 0.01; done\nexit 23\n"
1296 } else if mode == "exit" {
1297 "sleep 0.03\nexit 23\n"
1298 } else {
1299 "exec sleep 30\n"
1300 };
1301 let script = TestServerScript::new(
1302 mode,
1303 &format!("#!/bin/sh\nsleep 30 &\nprintf '%s' \"$!\"\n{publish}{finish}"),
1304 );
1305 let result = PocketIcStartupConfig::spawn(script.path(), Duration::from_millis(300))
1306 .start_managed_server();
1307 let output = match result {
1308 Ok(server) => {
1309 let output = server.output().stdout().to_owned();
1310 if mode == "background" {
1311 let port = server.server.files.port.clone();
1312 server.server.reap_in_background();
1313 fs::remove_file(port).expect("release the background server after handoff");
1314 } else {
1315 drop(server);
1316 }
1317 output
1318 }
1319 Err(PocketIcStartupError::ReadinessTimeout {
1320 stdout,
1321 termination_error,
1322 ..
1323 }) => {
1324 assert_eq!(mode, "timeout");
1325 assert_eq!(termination_error, None);
1326 stdout
1327 }
1328 Err(PocketIcStartupError::ServerExited { stdout, status, .. }) => {
1329 assert_eq!(mode, "exit");
1330 assert_eq!(status.code(), Some(23));
1331 stdout
1332 }
1333 other => panic!(
1334 "unexpected {mode} startup result: {}",
1335 match other {
1336 Err(error) => error.to_string(),
1337 Ok(_) => unreachable!(),
1338 }
1339 ),
1340 };
1341 let pid = output
1342 .parse::<u32>()
1343 .expect("server published its descendant PID");
1344 let deadline = Instant::now() + Duration::from_secs(2);
1345 while process_state(pid).is_some_and(|state| state != 'Z') {
1346 assert!(
1347 Instant::now() < deadline,
1348 "{mode} left its descendant running"
1349 );
1350 std::thread::sleep(Duration::from_millis(10));
1351 }
1352 }
1353 }
1354
1355 #[cfg(unix)]
1356 #[test]
1357 fn managed_server_passes_an_explicit_hard_ttl() {
1358 let script = TestServerScript::new(
1359 "hard-ttl",
1360 "#!/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",
1361 );
1362
1363 let server = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
1364 .with_server_hard_ttl(Duration::from_secs(17))
1365 .start_managed_server()
1366 .expect("start managed server with an explicit hard TTL");
1367
1368 assert_eq!(server.url(), "http://127.0.0.1:34567/");
1369 }
1370
1371 #[test]
1372 #[ignore = "requires IC_TESTKIT_POCKET_IC_SERVER=<caller-provided PocketIC server binary>"]
1373 fn caller_provided_server_publishes_port_constructs_instance_and_cleans_up() {
1374 let binary = std::env::var_os("IC_TESTKIT_POCKET_IC_SERVER")
1375 .map(PathBuf::from)
1376 .expect("set IC_TESTKIT_POCKET_IC_SERVER to the exact server binary");
1377 let one_shot_sequence = super::STARTUP_FILE_SEQUENCE.load(super::Ordering::Relaxed);
1378 let one_shot_directory = std::env::temp_dir().join(format!(
1379 "ic-testkit-pocket-ic-startup-{}-{one_shot_sequence}",
1380 std::process::id()
1381 ));
1382 let one_shot = PocketIcBuilder::new()
1383 .with_application_subnet()
1384 .try_build(
1385 PocketIcStartupConfig::spawn(&binary, Duration::from_secs(30))
1386 .with_server_hard_ttl(Duration::from_secs(1)),
1387 )
1388 .expect("one-shot managed spawn must construct an instance");
1389 assert!(one_shot_directory.is_dir());
1390 drop(one_shot);
1391 let cleanup_deadline = Instant::now() + Duration::from_secs(3);
1392 while one_shot_directory.exists() && Instant::now() < cleanup_deadline {
1393 std::thread::sleep(Duration::from_millis(20));
1394 }
1395 assert!(!one_shot_directory.exists());
1396
1397 let server = PocketIcStartupConfig::spawn(&binary, Duration::from_secs(30))
1398 .with_server_hard_ttl(Duration::from_secs(60))
1399 .start_managed_server()
1400 .expect("caller-provided PocketIC server must publish its port");
1401 let files = &server.server.files;
1402 let startup_directory = files.directory.clone();
1403
1404 assert!(files.port.is_file());
1405 let pocket_ic = PocketIcBuilder::new()
1406 .with_application_subnet()
1407 .try_build(PocketIcStartupConfig::connect(
1408 server.url(),
1409 Duration::from_secs(30),
1410 ))
1411 .expect("construct instance through caller-provided server");
1412
1413 drop(pocket_ic);
1414 drop(server);
1415 assert!(!startup_directory.exists());
1416 }
1417
1418 #[cfg(unix)]
1419 struct TestServerScript {
1420 path: PathBuf,
1421 }
1422
1423 #[cfg(unix)]
1424 impl TestServerScript {
1425 fn new(label: &str, contents: &str) -> Self {
1426 let path = std::env::temp_dir().join(format!(
1427 "ic-testkit-pocket-ic-{label}-{}-{}",
1428 std::process::id(),
1429 super::STARTUP_FILE_SEQUENCE.fetch_add(1, super::Ordering::Relaxed),
1430 ));
1431 write_executable_script(&path, contents);
1432 Self { path }
1433 }
1434
1435 fn path(&self) -> PathBuf {
1436 self.path.clone()
1437 }
1438 }
1439
1440 #[cfg(unix)]
1441 impl Drop for TestServerScript {
1442 fn drop(&mut self) {
1443 let _ = fs::remove_file(&self.path);
1444 }
1445 }
1446}