1use std::{
2 fmt::Write as _,
3 fs::{self, File, OpenOptions},
4 io,
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
15use pocket_ic::{PocketIc, PocketIcBuilder};
16
17use super::transport;
18
19const DEFAULT_SERVER_HARD_TTL: Duration = Duration::from_secs(10 * 60);
20const STARTUP_POLL_INTERVAL: Duration = Duration::from_millis(20);
21const SERVER_OUTPUT_LIMIT: usize = 16 * 1024;
22
23static STARTUP_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
24
25#[derive(Clone, Debug, Eq, PartialEq)]
27pub struct PocketIcStartupConfig {
28 source: PocketIcStartupSource,
29 timeout: Duration,
30 server_hard_ttl: Duration,
31}
32
33#[derive(Clone, Debug, Eq, PartialEq)]
34enum PocketIcStartupSource {
35 Spawn { server_binary: PathBuf },
36 Connect { server_url: String },
37}
38
39#[non_exhaustive]
41#[derive(Debug)]
42pub enum PocketIcStartupError {
43 InvalidConfiguration { message: String },
45 InvalidServerUrl { server_url: String, message: String },
47 Io {
49 operation: &'static str,
50 path: PathBuf,
51 source: io::Error,
52 },
53 ServerSpawn {
55 server_binary: PathBuf,
56 source: io::Error,
57 },
58 ServerExited {
60 server_binary: PathBuf,
61 status: ExitStatus,
62 elapsed: Duration,
63 stdout: String,
64 stderr: String,
65 },
66 ReadinessTimeout {
68 server_binary: PathBuf,
69 timeout: Duration,
70 stdout: String,
71 stderr: String,
72 termination_error: Option<String>,
73 },
74 InvalidServerPort {
76 server_binary: PathBuf,
77 value: String,
78 stdout: String,
79 stderr: String,
80 },
81 InstanceCreationTimeout {
83 timeout: Duration,
84 stdout: String,
85 stderr: String,
86 termination_error: Option<String>,
87 },
88 BuilderThreadSpawn { source: io::Error },
90 BuilderPanicked { message: String },
92 BuilderDisconnected,
94}
95
96pub trait PocketIcBuilderExt {
102 fn try_build(self, config: PocketIcStartupConfig) -> Result<PocketIc, PocketIcStartupError>;
108}
109
110impl PocketIcStartupConfig {
111 #[must_use]
113 pub fn spawn(server_binary: impl Into<PathBuf>, timeout: Duration) -> Self {
114 Self {
115 source: PocketIcStartupSource::Spawn {
116 server_binary: server_binary.into(),
117 },
118 timeout,
119 server_hard_ttl: DEFAULT_SERVER_HARD_TTL,
120 }
121 }
122
123 #[must_use]
128 pub fn connect(server_url: impl Into<String>, timeout: Duration) -> Self {
129 Self {
130 source: PocketIcStartupSource::Connect {
131 server_url: server_url.into(),
132 },
133 timeout,
134 server_hard_ttl: DEFAULT_SERVER_HARD_TTL,
135 }
136 }
137
138 #[must_use]
140 pub const fn with_server_hard_ttl(mut self, hard_ttl: Duration) -> Self {
141 self.server_hard_ttl = hard_ttl;
142 self
143 }
144
145 #[must_use]
147 pub const fn timeout(&self) -> Duration {
148 self.timeout
149 }
150
151 #[must_use]
153 pub const fn server_hard_ttl(&self) -> Duration {
154 self.server_hard_ttl
155 }
156
157 #[must_use]
159 pub fn server_binary(&self) -> Option<&Path> {
160 match &self.source {
161 PocketIcStartupSource::Spawn { server_binary } => Some(server_binary),
162 PocketIcStartupSource::Connect { .. } => None,
163 }
164 }
165
166 #[must_use]
168 pub fn server_url(&self) -> Option<&str> {
169 match &self.source {
170 PocketIcStartupSource::Connect { server_url } => Some(server_url),
171 PocketIcStartupSource::Spawn { .. } => None,
172 }
173 }
174
175 fn validate(&self) -> Result<(), PocketIcStartupError> {
176 if self.timeout.is_zero() {
177 return Err(PocketIcStartupError::InvalidConfiguration {
178 message: "PocketIC startup timeout must be greater than zero".to_owned(),
179 });
180 }
181 if matches!(&self.source, PocketIcStartupSource::Spawn { .. })
182 && self.server_hard_ttl.as_secs() == 0
183 {
184 return Err(PocketIcStartupError::InvalidConfiguration {
185 message: "PocketIC server hard TTL must be at least one second".to_owned(),
186 });
187 }
188 Ok(())
189 }
190}
191
192impl PocketIcBuilderExt for PocketIcBuilder {
193 fn try_build(self, config: PocketIcStartupConfig) -> Result<PocketIc, PocketIcStartupError> {
194 config.validate()?;
195 let started = Instant::now();
196 let deadline = started.checked_add(config.timeout).ok_or_else(|| {
197 PocketIcStartupError::InvalidConfiguration {
198 message: "PocketIC startup timeout exceeds the platform clock range".to_owned(),
199 }
200 })?;
201 match config.source {
202 PocketIcStartupSource::Connect { server_url } => {
203 build_bounded(self, &server_url, deadline, config.timeout, None)
204 }
205 PocketIcStartupSource::Spawn { server_binary } => {
206 let (server, server_url) = ManagedServer::start(
207 server_binary,
208 config.server_hard_ttl,
209 deadline,
210 config.timeout,
211 started,
212 )?;
213 build_bounded(self, &server_url, deadline, config.timeout, Some(server))
214 }
215 }
216 }
217}
218
219fn build_bounded(
220 builder: PocketIcBuilder,
221 server_url: &str,
222 deadline: Instant,
223 timeout: Duration,
224 mut server: Option<ManagedServer>,
225) -> Result<PocketIc, PocketIcStartupError> {
226 let builder = match server_url.parse() {
227 Ok(server_url) => builder.with_server_url(server_url),
228 Err(error) => {
229 return Err(PocketIcStartupError::InvalidServerUrl {
230 server_url: server_url.to_owned(),
231 message: error.to_string(),
232 });
233 }
234 };
235 let (sender, receiver) = mpsc::sync_channel(1);
236 if let Err(source) = thread::Builder::new()
237 .name("ic-testkit-pocket-ic-startup".to_owned())
238 .spawn(move || {
239 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| builder.build()))
240 .map_err(|payload| transport::panic_payload_to_string(payload.as_ref()));
241 let _ = sender.send(result);
242 })
243 {
244 return Err(PocketIcStartupError::BuilderThreadSpawn { source });
245 }
246
247 loop {
248 let now = Instant::now();
249 if now >= deadline {
250 let captured = server.take().map_or_else(
251 CapturedServer::default,
252 ManagedServer::terminate_and_capture,
253 );
254 return Err(PocketIcStartupError::InstanceCreationTimeout {
255 timeout,
256 stdout: captured.stdout,
257 stderr: captured.stderr,
258 termination_error: captured.termination_error,
259 });
260 }
261 let remaining = deadline.saturating_duration_since(now);
262 let wait = if server.is_some() {
263 remaining.min(STARTUP_POLL_INTERVAL)
264 } else {
265 remaining
266 };
267 match receiver.recv_timeout(wait) {
268 Ok(Ok(pocket_ic)) => {
269 if let Some(mut managed) = server.take() {
270 if let Some(status) = managed.try_wait()? {
271 return Err(managed.exited_error(status));
272 }
273 managed.reap_in_background();
274 }
275 return Ok(pocket_ic);
276 }
277 Ok(Err(message)) => {
278 if let Some(server) = server.take() {
279 let _ = server.terminate_and_capture();
280 }
281 return Err(PocketIcStartupError::BuilderPanicked { message });
282 }
283 Err(RecvTimeoutError::Disconnected) => {
284 if let Some(server) = server.take() {
285 let _ = server.terminate_and_capture();
286 }
287 return Err(PocketIcStartupError::BuilderDisconnected);
288 }
289 Err(RecvTimeoutError::Timeout) => {
290 if let Some(managed) = &mut server
291 && let Some(status) = managed.try_wait()?
292 {
293 return Err(server
294 .take()
295 .expect("managed server must remain present")
296 .exited_error(status));
297 }
298 }
299 }
300 }
301}
302
303struct ManagedServer {
304 child: Option<Child>,
305 binary: PathBuf,
306 files: Option<StartupFiles>,
307 started: Instant,
308}
309
310enum PortFileState {
311 Pending,
312 Ready(u16),
313 Invalid(String),
314}
315
316impl ManagedServer {
317 fn start(
318 binary: PathBuf,
319 hard_ttl: Duration,
320 deadline: Instant,
321 timeout: Duration,
322 started: Instant,
323 ) -> Result<(Self, String), PocketIcStartupError> {
324 let (files, stdout, stderr) = StartupFiles::create()?;
325 let mut command = Command::new(&binary);
326 command
327 .arg("--hard-ttl")
328 .arg(hard_ttl.as_secs().to_string())
329 .arg("--port-file")
330 .arg(&files.port)
331 .stdout(Stdio::from(stdout))
332 .stderr(Stdio::from(stderr));
333 #[cfg(unix)]
334 {
335 use std::os::unix::process::CommandExt as _;
336 command.process_group(0);
337 }
338 let child = command
339 .spawn()
340 .map_err(|source| PocketIcStartupError::ServerSpawn {
341 server_binary: binary.clone(),
342 source,
343 })?;
344 let mut server = Self {
345 child: Some(child),
346 binary,
347 files: Some(files),
348 started,
349 };
350
351 loop {
352 if let Some(status) = server.try_wait()? {
353 return Err(server.exited_error(status));
354 }
355 let now = Instant::now();
356 if now >= deadline {
357 let binary = server.binary.clone();
358 let captured = server.terminate_and_capture();
359 return Err(PocketIcStartupError::ReadinessTimeout {
360 server_binary: binary,
361 timeout,
362 stdout: captured.stdout,
363 stderr: captured.stderr,
364 termination_error: captured.termination_error,
365 });
366 }
367 match server.read_port()? {
368 PortFileState::Pending => {}
369 PortFileState::Ready(port) => {
370 return Ok((server, format!("http://127.0.0.1:{port}/")));
371 }
372 PortFileState::Invalid(value) => {
373 let binary = server.binary.clone();
374 let captured = server.terminate_and_capture();
375 return Err(PocketIcStartupError::InvalidServerPort {
376 server_binary: binary,
377 value,
378 stdout: captured.stdout,
379 stderr: captured.stderr,
380 });
381 }
382 }
383 thread::sleep(
384 deadline
385 .saturating_duration_since(now)
386 .min(STARTUP_POLL_INTERVAL),
387 );
388 }
389 }
390
391 fn try_wait(&mut self) -> Result<Option<ExitStatus>, PocketIcStartupError> {
392 self.child
393 .as_mut()
394 .expect("managed server child must remain present")
395 .try_wait()
396 .map_err(|source| PocketIcStartupError::Io {
397 operation: "inspect PocketIC server child",
398 path: self.binary.clone(),
399 source,
400 })
401 }
402
403 fn read_port(&self) -> Result<PortFileState, PocketIcStartupError> {
404 let port_path = &self
405 .files
406 .as_ref()
407 .expect("managed server startup files must remain present")
408 .port;
409 let contents =
410 fs::read_to_string(port_path).map_err(|source| PocketIcStartupError::Io {
411 operation: "read PocketIC server port file",
412 path: port_path.clone(),
413 source,
414 })?;
415 if !contents.contains('\n') {
416 return Ok(PortFileState::Pending);
417 }
418 let value = contents.trim().to_owned();
419 match value.parse::<u16>() {
420 Ok(port) if port != 0 => Ok(PortFileState::Ready(port)),
421 _ => Ok(PortFileState::Invalid(value)),
422 }
423 }
424
425 fn exited_error(mut self, status: ExitStatus) -> PocketIcStartupError {
426 let elapsed = self.started.elapsed();
427 let binary = self.binary.clone();
428 self.child.take();
429 let captured = self.capture();
430 PocketIcStartupError::ServerExited {
431 server_binary: binary,
432 status,
433 elapsed,
434 stdout: captured.stdout,
435 stderr: captured.stderr,
436 }
437 }
438
439 fn terminate_and_capture(mut self) -> CapturedServer {
440 let termination_error = match self.child.take() {
441 Some(mut child) => terminate_child(&mut child),
442 None => None,
443 };
444 let mut captured = self.capture();
445 captured.termination_error = termination_error;
446 captured
447 }
448
449 fn capture(&self) -> CapturedServer {
450 let files = self
451 .files
452 .as_ref()
453 .expect("managed server startup files must remain present");
454 CapturedServer {
455 stdout: read_bounded_lossy(&files.stdout),
456 stderr: read_bounded_lossy(&files.stderr),
457 termination_error: None,
458 }
459 }
460
461 fn reap_in_background(mut self) {
462 let child = ServerChildGuard {
463 child: self.child.take(),
464 };
465 let files = self.files.take();
466 let _ = thread::Builder::new()
467 .name("ic-testkit-pocket-ic-server-reaper".to_owned())
468 .spawn(move || {
469 let mut child = child;
470 if let Some(mut process) = child.child.take() {
471 let _ = process.wait();
472 }
473 drop(files);
474 });
475 }
476}
477
478impl Drop for ManagedServer {
479 fn drop(&mut self) {
480 if let Some(mut child) = self.child.take() {
481 let _ = terminate_child(&mut child);
482 }
483 }
484}
485
486struct ServerChildGuard {
487 child: Option<Child>,
488}
489
490impl Drop for ServerChildGuard {
491 fn drop(&mut self) {
492 if let Some(mut child) = self.child.take() {
493 let _ = terminate_child(&mut child);
494 }
495 }
496}
497
498fn terminate_child(child: &mut Child) -> Option<String> {
499 match child.try_wait() {
500 Ok(Some(_)) => None,
501 Ok(None) => child
502 .kill()
503 .and_then(|()| child.wait().map(|_| ()))
504 .err()
505 .map(|error| error.to_string()),
506 Err(inspect_error) => {
507 let termination_error = child.kill().and_then(|()| child.wait().map(|_| ())).err();
508 termination_error.map(|termination_error| {
509 format!(
510 "failed to inspect child before termination: {inspect_error}; termination also failed: {termination_error}"
511 )
512 })
513 }
514 }
515}
516
517#[derive(Default)]
518struct CapturedServer {
519 stdout: String,
520 stderr: String,
521 termination_error: Option<String>,
522}
523
524struct StartupFiles {
525 port: PathBuf,
526 stdout: PathBuf,
527 stderr: PathBuf,
528}
529
530impl StartupFiles {
531 fn create() -> Result<(Self, File, File), PocketIcStartupError> {
532 loop {
533 let sequence = STARTUP_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
534 let base = std::env::temp_dir().join(format!(
535 "ic-testkit-pocket-ic-startup-{}-{sequence}",
536 std::process::id()
537 ));
538 let files = Self {
539 port: base.with_extension("port"),
540 stdout: base.with_extension("stdout"),
541 stderr: base.with_extension("stderr"),
542 };
543 let port = match create_new_file(&files.port) {
544 Ok(file) => file,
545 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
546 Err(source) => return Err(startup_file_error("create", &files.port, source)),
547 };
548 drop(port);
549 let stdout = create_new_file(&files.stdout)
550 .map_err(|source| startup_file_error("create", &files.stdout, source))?;
551 let stderr = create_new_file(&files.stderr)
552 .map_err(|source| startup_file_error("create", &files.stderr, source))?;
553 return Ok((files, stdout, stderr));
554 }
555 }
556}
557
558impl Drop for StartupFiles {
559 fn drop(&mut self) {
560 let _ = fs::remove_file(&self.port);
561 let _ = fs::remove_file(&self.stdout);
562 let _ = fs::remove_file(&self.stderr);
563 }
564}
565
566fn create_new_file(path: &Path) -> io::Result<File> {
567 OpenOptions::new().write(true).create_new(true).open(path)
568}
569
570fn startup_file_error(
571 operation: &'static str,
572 path: &Path,
573 source: io::Error,
574) -> PocketIcStartupError {
575 PocketIcStartupError::Io {
576 operation,
577 path: path.to_owned(),
578 source,
579 }
580}
581
582fn read_bounded_lossy(path: &Path) -> String {
583 let Ok(bytes) = fs::read(path) else {
584 return String::new();
585 };
586 let retained = bytes.len().min(SERVER_OUTPUT_LIMIT);
587 let mut output = String::from_utf8_lossy(&bytes[..retained]).into_owned();
588 let omitted = bytes.len().saturating_sub(retained);
589 if omitted > 0 {
590 let _ = write!(output, "\n<truncated {omitted} bytes>");
591 }
592 output
593}
594
595impl std::fmt::Display for PocketIcStartupError {
596 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
597 match self {
598 Self::InvalidConfiguration { message } => formatter.write_str(message),
599 Self::InvalidServerUrl {
600 server_url,
601 message,
602 } => write!(
603 formatter,
604 "invalid PocketIC server URL {server_url:?}: {message}"
605 ),
606 Self::Io {
607 operation,
608 path,
609 source,
610 } => write!(
611 formatter,
612 "failed to {operation} at {}: {source}",
613 path.display()
614 ),
615 Self::ServerSpawn {
616 server_binary,
617 source,
618 } => write!(
619 formatter,
620 "failed to spawn PocketIC server {}: {source}",
621 server_binary.display()
622 ),
623 Self::ServerExited {
624 server_binary,
625 status,
626 elapsed,
627 stderr,
628 ..
629 } => write!(
630 formatter,
631 "PocketIC server {} exited with {status} after {elapsed:?}: {stderr}",
632 server_binary.display()
633 ),
634 Self::ReadinessTimeout {
635 server_binary,
636 timeout,
637 ..
638 } => write!(
639 formatter,
640 "PocketIC server {} was not ready within {timeout:?}",
641 server_binary.display()
642 ),
643 Self::InvalidServerPort {
644 server_binary,
645 value,
646 ..
647 } => write!(
648 formatter,
649 "PocketIC server {} published invalid port {value:?}",
650 server_binary.display()
651 ),
652 Self::InstanceCreationTimeout { timeout, .. } => {
653 write!(formatter, "PocketIC instance creation exceeded {timeout:?}")
654 }
655 Self::BuilderThreadSpawn { source } => {
656 write!(
657 formatter,
658 "failed to spawn PocketIC builder worker: {source}"
659 )
660 }
661 Self::BuilderPanicked { message } => {
662 write!(formatter, "PocketIC startup panicked: {message}")
663 }
664 Self::BuilderDisconnected => {
665 formatter.write_str("PocketIC builder worker disconnected without a result")
666 }
667 }
668 }
669}
670
671impl std::error::Error for PocketIcStartupError {
672 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
673 match self {
674 Self::Io { source, .. }
675 | Self::ServerSpawn { source, .. }
676 | Self::BuilderThreadSpawn { source } => Some(source),
677 _ => None,
678 }
679 }
680}
681
682#[cfg(test)]
683mod tests {
684 use std::time::{Duration, Instant};
685
686 use super::{PocketIcStartupConfig, PocketIcStartupError};
687
688 #[cfg(unix)]
689 use {
690 super::PocketIcBuilderExt as _,
691 pocket_ic::PocketIcBuilder,
692 std::{fs, os::unix::fs::PermissionsExt as _, path::PathBuf},
693 };
694
695 #[test]
696 fn startup_config_requires_positive_bounds() {
697 let error = PocketIcStartupConfig::connect("http://127.0.0.1:1/", Duration::ZERO)
698 .validate()
699 .expect_err("zero startup timeout must fail");
700 assert!(matches!(
701 error,
702 PocketIcStartupError::InvalidConfiguration { .. }
703 ));
704
705 let error = PocketIcStartupConfig::spawn("pocket-ic", Duration::from_secs(1))
706 .with_server_hard_ttl(Duration::from_millis(1))
707 .validate()
708 .expect_err("subsecond server hard TTL must fail");
709 assert!(matches!(
710 error,
711 PocketIcStartupError::InvalidConfiguration { .. }
712 ));
713 }
714
715 #[cfg(unix)]
716 #[test]
717 fn managed_startup_reports_an_exited_server_with_bounded_output() {
718 let script = TestServerScript::new(
719 "exit",
720 "#!/bin/sh\nprintf 'synthetic server stdout'\nprintf 'synthetic bind failure' >&2\nexit 23\n",
721 );
722
723 let result = PocketIcBuilder::new().with_application_subnet().try_build(
724 PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2)),
725 );
726
727 let Err(PocketIcStartupError::ServerExited {
728 server_binary,
729 status,
730 stdout,
731 stderr,
732 ..
733 }) = result
734 else {
735 panic!("an exited managed server must return a structured exit error");
736 };
737 assert_eq!(server_binary, script.path());
738 assert_eq!(status.code(), Some(23));
739 assert_eq!(stdout, "synthetic server stdout");
740 assert_eq!(stderr, "synthetic bind failure");
741 }
742
743 #[cfg(unix)]
744 #[test]
745 fn managed_startup_terminates_a_server_that_never_becomes_ready() {
746 let script = TestServerScript::new("timeout", "#!/bin/sh\nexec sleep 30\n");
747 let timeout = Duration::from_millis(100);
748 let started = Instant::now();
749
750 let result = PocketIcBuilder::new()
751 .with_application_subnet()
752 .try_build(PocketIcStartupConfig::spawn(script.path(), timeout));
753
754 assert!(
755 started.elapsed() < Duration::from_secs(2),
756 "bounded startup should not wait for the sleeping child"
757 );
758 assert!(matches!(
759 result,
760 Err(PocketIcStartupError::ReadinessTimeout {
761 server_binary,
762 timeout: actual_timeout,
763 termination_error: None,
764 ..
765 }) if server_binary == script.path() && actual_timeout == timeout
766 ));
767 }
768
769 #[cfg(unix)]
770 struct TestServerScript {
771 path: PathBuf,
772 }
773
774 #[cfg(unix)]
775 impl TestServerScript {
776 fn new(label: &str, contents: &str) -> Self {
777 let path = std::env::temp_dir().join(format!(
778 "ic-testkit-pocket-ic-{label}-{}-{}",
779 std::process::id(),
780 super::STARTUP_FILE_SEQUENCE.fetch_add(1, super::Ordering::Relaxed),
781 ));
782 fs::write(&path, contents).expect("write synthetic PocketIC server script");
783 let mut permissions = fs::metadata(&path)
784 .expect("read synthetic server script metadata")
785 .permissions();
786 permissions.set_mode(0o755);
787 fs::set_permissions(&path, permissions)
788 .expect("make synthetic server script executable");
789 Self { path }
790 }
791
792 fn path(&self) -> PathBuf {
793 self.path.clone()
794 }
795 }
796
797 #[cfg(unix)]
798 impl Drop for TestServerScript {
799 fn drop(&mut self) {
800 let _ = fs::remove_file(&self.path);
801 }
802 }
803}