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 STARTUP_POLL_INTERVAL: Duration = Duration::from_millis(20);
20const SERVER_OUTPUT_LIMIT: usize = 16 * 1024;
21
22static STARTUP_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
23
24#[derive(Clone, Debug, Eq, PartialEq)]
26pub struct PocketIcStartupConfig {
27 source: PocketIcStartupSource,
28 timeout: Duration,
29 server_hard_ttl: Option<Duration>,
30}
31
32pub struct PocketIcManagedServer {
42 server: ManagedServer,
43 url: String,
44}
45
46#[derive(Clone, Debug, Default, Eq, PartialEq)]
51pub struct PocketIcManagedServerOutput {
52 stdout: String,
53 stderr: String,
54}
55
56#[derive(Clone, Debug, Eq, PartialEq)]
57enum PocketIcStartupSource {
58 Spawn { server_binary: PathBuf },
59 Connect { server_url: String },
60}
61
62#[non_exhaustive]
64#[derive(Debug)]
65pub enum PocketIcStartupError {
66 InvalidConfiguration { message: String },
68 InvalidServerUrl { server_url: String, message: String },
70 Io {
72 operation: &'static str,
73 path: PathBuf,
74 source: io::Error,
75 },
76 ServerSpawn {
78 server_binary: PathBuf,
79 source: io::Error,
80 },
81 ServerExited {
83 server_binary: PathBuf,
84 status: ExitStatus,
85 elapsed: Duration,
86 stdout: String,
87 stderr: String,
88 },
89 ReadinessTimeout {
91 server_binary: PathBuf,
92 timeout: Duration,
93 stdout: String,
94 stderr: String,
95 termination_error: Option<String>,
96 },
97 InvalidServerPort {
99 server_binary: PathBuf,
100 value: String,
101 stdout: String,
102 stderr: String,
103 },
104 InstanceCreationTimeout {
106 timeout: Duration,
107 stdout: String,
108 stderr: String,
109 termination_error: Option<String>,
110 },
111 BuilderThreadSpawn { source: io::Error },
113 BuilderPanicked { message: String },
115 BuilderDisconnected,
117}
118
119pub trait PocketIcBuilderExt {
125 fn try_build(self, config: PocketIcStartupConfig) -> Result<PocketIc, PocketIcStartupError>;
135}
136
137impl PocketIcStartupConfig {
138 #[must_use]
143 pub fn spawn(server_binary: impl Into<PathBuf>, timeout: Duration) -> Self {
144 Self {
145 source: PocketIcStartupSource::Spawn {
146 server_binary: server_binary.into(),
147 },
148 timeout,
149 server_hard_ttl: None,
150 }
151 }
152
153 #[must_use]
158 pub fn connect(server_url: impl Into<String>, timeout: Duration) -> Self {
159 Self {
160 source: PocketIcStartupSource::Connect {
161 server_url: server_url.into(),
162 },
163 timeout,
164 server_hard_ttl: None,
165 }
166 }
167
168 #[must_use]
170 pub const fn with_server_hard_ttl(mut self, hard_ttl: Duration) -> Self {
171 self.server_hard_ttl = Some(hard_ttl);
172 self
173 }
174
175 #[must_use]
177 pub const fn timeout(&self) -> Duration {
178 self.timeout
179 }
180
181 #[must_use]
183 pub const fn server_hard_ttl(&self) -> Option<Duration> {
184 self.server_hard_ttl
185 }
186
187 #[must_use]
189 pub fn server_binary(&self) -> Option<&Path> {
190 match &self.source {
191 PocketIcStartupSource::Spawn { server_binary } => Some(server_binary),
192 PocketIcStartupSource::Connect { .. } => None,
193 }
194 }
195
196 #[must_use]
198 pub fn server_url(&self) -> Option<&str> {
199 match &self.source {
200 PocketIcStartupSource::Connect { server_url } => Some(server_url),
201 PocketIcStartupSource::Spawn { .. } => None,
202 }
203 }
204
205 pub fn start_managed_server(self) -> Result<PocketIcManagedServer, PocketIcStartupError> {
213 self.validate()?;
214 let PocketIcStartupSource::Spawn { server_binary } = self.source else {
215 return Err(PocketIcStartupError::InvalidConfiguration {
216 message: "starting a managed PocketIC server requires a spawn configuration"
217 .to_owned(),
218 });
219 };
220 let started = Instant::now();
221 let deadline = startup_deadline(started, self.timeout)?;
222 let (server, url) = ManagedServer::start(
223 server_binary,
224 self.server_hard_ttl,
225 deadline,
226 self.timeout,
227 started,
228 )?;
229 Ok(PocketIcManagedServer { server, url })
230 }
231
232 fn validate(&self) -> Result<(), PocketIcStartupError> {
233 if self.timeout.is_zero() {
234 return Err(PocketIcStartupError::InvalidConfiguration {
235 message: "PocketIC startup timeout must be greater than zero".to_owned(),
236 });
237 }
238 if matches!(&self.source, PocketIcStartupSource::Spawn { .. })
239 && self
240 .server_hard_ttl
241 .is_some_and(|hard_ttl| hard_ttl.as_secs() == 0)
242 {
243 return Err(PocketIcStartupError::InvalidConfiguration {
244 message: "PocketIC server hard TTL must be at least one second".to_owned(),
245 });
246 }
247 Ok(())
248 }
249}
250
251impl PocketIcManagedServer {
252 #[must_use]
259 pub fn process_id(&self) -> u32 {
260 self.server
261 .child
262 .as_ref()
263 .expect("managed server handle must own its child")
264 .id()
265 }
266
267 #[must_use]
269 pub fn url(&self) -> &str {
270 &self.url
271 }
272
273 #[must_use]
278 pub fn output(&self) -> PocketIcManagedServerOutput {
279 self.server.capture().into()
280 }
281}
282
283impl PocketIcManagedServerOutput {
284 #[must_use]
286 pub fn stdout(&self) -> &str {
287 &self.stdout
288 }
289
290 #[must_use]
292 pub fn stderr(&self) -> &str {
293 &self.stderr
294 }
295}
296
297impl PocketIcBuilderExt for PocketIcBuilder {
298 fn try_build(self, config: PocketIcStartupConfig) -> Result<PocketIc, PocketIcStartupError> {
299 config.validate()?;
300 let started = Instant::now();
301 let deadline = startup_deadline(started, config.timeout)?;
302 match config.source {
303 PocketIcStartupSource::Connect { server_url } => {
304 build_bounded(self, &server_url, deadline, config.timeout, None)
305 }
306 PocketIcStartupSource::Spawn { server_binary } => {
307 let (server, server_url) = ManagedServer::start(
308 server_binary,
309 config.server_hard_ttl,
310 deadline,
311 config.timeout,
312 started,
313 )?;
314 build_bounded(self, &server_url, deadline, config.timeout, Some(server))
315 }
316 }
317 }
318}
319
320fn startup_deadline(started: Instant, timeout: Duration) -> Result<Instant, PocketIcStartupError> {
321 started
322 .checked_add(timeout)
323 .ok_or_else(|| PocketIcStartupError::InvalidConfiguration {
324 message: "PocketIC startup timeout exceeds the platform clock range".to_owned(),
325 })
326}
327
328fn build_bounded(
329 builder: PocketIcBuilder,
330 server_url: &str,
331 deadline: Instant,
332 timeout: Duration,
333 mut server: Option<ManagedServer>,
334) -> Result<PocketIc, PocketIcStartupError> {
335 let builder = match server_url.parse() {
336 Ok(server_url) => builder.with_server_url(server_url),
337 Err(error) => {
338 return Err(PocketIcStartupError::InvalidServerUrl {
339 server_url: server_url.to_owned(),
340 message: error.to_string(),
341 });
342 }
343 };
344 let (sender, receiver) = mpsc::sync_channel(1);
345 if let Err(source) = thread::Builder::new()
346 .name("ic-testkit-pocket-ic-startup".to_owned())
347 .spawn(move || {
348 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| builder.build()))
349 .map_err(|payload| transport::panic_payload_to_string(payload.as_ref()));
350 let _ = sender.send(result);
351 })
352 {
353 return Err(PocketIcStartupError::BuilderThreadSpawn { source });
354 }
355
356 loop {
357 let now = Instant::now();
358 if now >= deadline {
359 let captured = server.take().map_or_else(
360 CapturedServer::default,
361 ManagedServer::terminate_and_capture,
362 );
363 return Err(PocketIcStartupError::InstanceCreationTimeout {
364 timeout,
365 stdout: captured.stdout,
366 stderr: captured.stderr,
367 termination_error: captured.termination_error,
368 });
369 }
370 let remaining = deadline.saturating_duration_since(now);
371 let wait = if server.is_some() {
372 remaining.min(STARTUP_POLL_INTERVAL)
373 } else {
374 remaining
375 };
376 match receiver.recv_timeout(wait) {
377 Ok(Ok(pocket_ic)) => {
378 if let Some(mut managed) = server.take() {
379 if let Some(status) = managed.try_wait()? {
380 return Err(managed.exited_error(status));
381 }
382 managed.reap_in_background();
383 }
384 return Ok(pocket_ic);
385 }
386 Ok(Err(message)) => {
387 if let Some(server) = server.take() {
388 let _ = server.terminate_and_capture();
389 }
390 return Err(PocketIcStartupError::BuilderPanicked { message });
391 }
392 Err(RecvTimeoutError::Disconnected) => {
393 if let Some(server) = server.take() {
394 let _ = server.terminate_and_capture();
395 }
396 return Err(PocketIcStartupError::BuilderDisconnected);
397 }
398 Err(RecvTimeoutError::Timeout) => {
399 if let Some(managed) = &mut server
400 && let Some(status) = managed.try_wait()?
401 {
402 return Err(server
403 .take()
404 .expect("managed server must remain present")
405 .exited_error(status));
406 }
407 }
408 }
409 }
410}
411
412struct ManagedServer {
413 child: Option<Child>,
414 binary: PathBuf,
415 files: Option<StartupFiles>,
416 started: Instant,
417}
418
419enum PortFileState {
420 Pending,
421 Ready(u16),
422 Invalid(String),
423}
424
425impl ManagedServer {
426 fn start(
427 binary: PathBuf,
428 hard_ttl: Option<Duration>,
429 deadline: Instant,
430 timeout: Duration,
431 started: Instant,
432 ) -> Result<(Self, String), PocketIcStartupError> {
433 let (files, stdout, stderr) = StartupFiles::create()?;
434 let mut command = Command::new(&binary);
435 if let Some(hard_ttl) = hard_ttl {
436 command
437 .arg("--hard-ttl")
438 .arg(hard_ttl.as_secs().to_string());
439 }
440 command
441 .arg("--port-file")
442 .arg(&files.port)
443 .stdout(Stdio::from(stdout))
444 .stderr(Stdio::from(stderr));
445 #[cfg(unix)]
446 {
447 use std::os::unix::process::CommandExt as _;
448 command.process_group(0);
449 }
450 let child = command
451 .spawn()
452 .map_err(|source| PocketIcStartupError::ServerSpawn {
453 server_binary: binary.clone(),
454 source,
455 })?;
456 let mut server = Self {
457 child: Some(child),
458 binary,
459 files: Some(files),
460 started,
461 };
462
463 loop {
464 if let Some(status) = server.try_wait()? {
465 return Err(server.exited_error(status));
466 }
467 let now = Instant::now();
468 if now >= deadline {
469 let binary = server.binary.clone();
470 let captured = server.terminate_and_capture();
471 return Err(PocketIcStartupError::ReadinessTimeout {
472 server_binary: binary,
473 timeout,
474 stdout: captured.stdout,
475 stderr: captured.stderr,
476 termination_error: captured.termination_error,
477 });
478 }
479 match server.read_port()? {
480 PortFileState::Pending => {}
481 PortFileState::Ready(port) => {
482 return Ok((server, format!("http://127.0.0.1:{port}/")));
483 }
484 PortFileState::Invalid(value) => {
485 let binary = server.binary.clone();
486 let captured = server.terminate_and_capture();
487 return Err(PocketIcStartupError::InvalidServerPort {
488 server_binary: binary,
489 value,
490 stdout: captured.stdout,
491 stderr: captured.stderr,
492 });
493 }
494 }
495 thread::sleep(
496 deadline
497 .saturating_duration_since(now)
498 .min(STARTUP_POLL_INTERVAL),
499 );
500 }
501 }
502
503 fn try_wait(&mut self) -> Result<Option<ExitStatus>, PocketIcStartupError> {
504 self.child
505 .as_mut()
506 .expect("managed server child must remain present")
507 .try_wait()
508 .map_err(|source| PocketIcStartupError::Io {
509 operation: "inspect PocketIC server child",
510 path: self.binary.clone(),
511 source,
512 })
513 }
514
515 fn read_port(&self) -> Result<PortFileState, PocketIcStartupError> {
516 let port_path = &self
517 .files
518 .as_ref()
519 .expect("managed server startup files must remain present")
520 .port;
521 let contents = match fs::read_to_string(port_path) {
522 Ok(contents) => contents,
523 Err(error) if error.kind() == io::ErrorKind::NotFound => {
524 return Ok(PortFileState::Pending);
525 }
526 Err(source) => {
527 return Err(PocketIcStartupError::Io {
528 operation: "read PocketIC server port file",
529 path: port_path.clone(),
530 source,
531 });
532 }
533 };
534 if !contents.contains('\n') {
535 return Ok(PortFileState::Pending);
536 }
537 let value = contents.trim().to_owned();
538 match value.parse::<u16>() {
539 Ok(port) if port != 0 => Ok(PortFileState::Ready(port)),
540 _ => Ok(PortFileState::Invalid(value)),
541 }
542 }
543
544 fn exited_error(mut self, status: ExitStatus) -> PocketIcStartupError {
545 let elapsed = self.started.elapsed();
546 let binary = self.binary.clone();
547 self.child.take();
548 let captured = self.capture();
549 PocketIcStartupError::ServerExited {
550 server_binary: binary,
551 status,
552 elapsed,
553 stdout: captured.stdout,
554 stderr: captured.stderr,
555 }
556 }
557
558 fn terminate_and_capture(mut self) -> CapturedServer {
559 let termination_error = match self.child.take() {
560 Some(mut child) => terminate_child(&mut child),
561 None => None,
562 };
563 let mut captured = self.capture();
564 captured.termination_error = termination_error;
565 captured
566 }
567
568 fn capture(&self) -> CapturedServer {
569 let files = self
570 .files
571 .as_ref()
572 .expect("managed server startup files must remain present");
573 CapturedServer {
574 stdout: read_bounded_lossy(&files.stdout),
575 stderr: read_bounded_lossy(&files.stderr),
576 termination_error: None,
577 }
578 }
579
580 fn reap_in_background(mut self) {
581 let child = ServerChildGuard {
582 child: self.child.take(),
583 };
584 let files = self.files.take();
585 let _ = thread::Builder::new()
586 .name("ic-testkit-pocket-ic-server-reaper".to_owned())
587 .spawn(move || {
588 let mut child = child;
589 if let Some(mut process) = child.child.take() {
590 let _ = process.wait();
591 }
592 drop(files);
593 });
594 }
595}
596
597impl Drop for ManagedServer {
598 fn drop(&mut self) {
599 if let Some(mut child) = self.child.take() {
600 let _ = terminate_child(&mut child);
601 }
602 }
603}
604
605struct ServerChildGuard {
606 child: Option<Child>,
607}
608
609impl Drop for ServerChildGuard {
610 fn drop(&mut self) {
611 if let Some(mut child) = self.child.take() {
612 let _ = terminate_child(&mut child);
613 }
614 }
615}
616
617fn terminate_child(child: &mut Child) -> Option<String> {
618 match child.try_wait() {
619 Ok(Some(_)) => None,
620 Ok(None) => child
621 .kill()
622 .and_then(|()| child.wait().map(|_| ()))
623 .err()
624 .map(|error| error.to_string()),
625 Err(inspect_error) => {
626 let termination_error = child.kill().and_then(|()| child.wait().map(|_| ())).err();
627 termination_error.map(|termination_error| {
628 format!(
629 "failed to inspect child before termination: {inspect_error}; termination also failed: {termination_error}"
630 )
631 })
632 }
633 }
634}
635
636#[derive(Default)]
637struct CapturedServer {
638 stdout: String,
639 stderr: String,
640 termination_error: Option<String>,
641}
642
643impl From<CapturedServer> for PocketIcManagedServerOutput {
644 fn from(captured: CapturedServer) -> Self {
645 Self {
646 stdout: captured.stdout,
647 stderr: captured.stderr,
648 }
649 }
650}
651
652struct StartupFiles {
653 directory: PathBuf,
654 port: PathBuf,
655 stdout: PathBuf,
656 stderr: PathBuf,
657}
658
659impl StartupFiles {
660 fn create() -> Result<(Self, File, File), PocketIcStartupError> {
661 loop {
662 let sequence = STARTUP_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
663 let base = std::env::temp_dir().join(format!(
664 "ic-testkit-pocket-ic-startup-{}-{sequence}",
665 std::process::id()
666 ));
667 let mut directory = fs::DirBuilder::new();
668 #[cfg(unix)]
669 {
670 use std::os::unix::fs::DirBuilderExt as _;
671 directory.mode(0o700);
672 }
673 match directory.create(&base) {
674 Ok(()) => {}
675 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
676 Err(source) => return Err(startup_file_error("create", &base, source)),
677 }
678 let files = Self {
679 port: base.join("port"),
680 stdout: base.join("stdout"),
681 stderr: base.join("stderr"),
682 directory: base,
683 };
684 let stdout = create_new_file(&files.stdout)
685 .map_err(|source| startup_file_error("create", &files.stdout, source))?;
686 let stderr = create_new_file(&files.stderr)
687 .map_err(|source| startup_file_error("create", &files.stderr, source))?;
688 return Ok((files, stdout, stderr));
689 }
690 }
691}
692
693impl Drop for StartupFiles {
694 fn drop(&mut self) {
695 let _ = fs::remove_dir_all(&self.directory);
696 }
697}
698
699fn create_new_file(path: &Path) -> io::Result<File> {
700 OpenOptions::new().write(true).create_new(true).open(path)
701}
702
703fn startup_file_error(
704 operation: &'static str,
705 path: &Path,
706 source: io::Error,
707) -> PocketIcStartupError {
708 PocketIcStartupError::Io {
709 operation,
710 path: path.to_owned(),
711 source,
712 }
713}
714
715fn read_bounded_lossy(path: &Path) -> String {
716 let Ok(bytes) = fs::read(path) else {
717 return String::new();
718 };
719 let retained = bytes.len().min(SERVER_OUTPUT_LIMIT);
720 let mut output = String::from_utf8_lossy(&bytes[..retained]).into_owned();
721 let omitted = bytes.len().saturating_sub(retained);
722 if omitted > 0 {
723 let _ = write!(output, "\n<truncated {omitted} bytes>");
724 }
725 output
726}
727
728impl std::fmt::Display for PocketIcStartupError {
729 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
730 match self {
731 Self::InvalidConfiguration { message } => formatter.write_str(message),
732 Self::InvalidServerUrl {
733 server_url,
734 message,
735 } => write!(
736 formatter,
737 "invalid PocketIC server URL {server_url:?}: {message}"
738 ),
739 Self::Io {
740 operation,
741 path,
742 source,
743 } => write!(
744 formatter,
745 "failed to {operation} at {}: {source}",
746 path.display()
747 ),
748 Self::ServerSpawn {
749 server_binary,
750 source,
751 } => write!(
752 formatter,
753 "failed to spawn PocketIC server {}: {source}",
754 server_binary.display()
755 ),
756 Self::ServerExited {
757 server_binary,
758 status,
759 elapsed,
760 stderr,
761 ..
762 } => write!(
763 formatter,
764 "PocketIC server {} exited with {status} after {elapsed:?}: {stderr}",
765 server_binary.display()
766 ),
767 Self::ReadinessTimeout {
768 server_binary,
769 timeout,
770 ..
771 } => write!(
772 formatter,
773 "PocketIC server {} was not ready within {timeout:?}",
774 server_binary.display()
775 ),
776 Self::InvalidServerPort {
777 server_binary,
778 value,
779 ..
780 } => write!(
781 formatter,
782 "PocketIC server {} published invalid port {value:?}",
783 server_binary.display()
784 ),
785 Self::InstanceCreationTimeout { timeout, .. } => {
786 write!(formatter, "PocketIC instance creation exceeded {timeout:?}")
787 }
788 Self::BuilderThreadSpawn { source } => {
789 write!(
790 formatter,
791 "failed to spawn PocketIC builder worker: {source}"
792 )
793 }
794 Self::BuilderPanicked { message } => {
795 write!(formatter, "PocketIC startup panicked: {message}")
796 }
797 Self::BuilderDisconnected => {
798 formatter.write_str("PocketIC builder worker disconnected without a result")
799 }
800 }
801 }
802}
803
804impl std::error::Error for PocketIcStartupError {
805 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
806 match self {
807 Self::Io { source, .. }
808 | Self::ServerSpawn { source, .. }
809 | Self::BuilderThreadSpawn { source } => Some(source),
810 _ => None,
811 }
812 }
813}
814
815#[cfg(test)]
816mod tests {
817 use std::{
818 fs,
819 path::PathBuf,
820 time::{Duration, Instant},
821 };
822
823 use super::{
824 PocketIcBuilderExt as _, PocketIcStartupConfig, PocketIcStartupError, StartupFiles,
825 };
826 use pocket_ic::PocketIcBuilder;
827
828 #[cfg(unix)]
829 use std::os::unix::fs::PermissionsExt as _;
830
831 #[test]
832 fn startup_config_requires_positive_bounds() {
833 let error = PocketIcStartupConfig::connect("http://127.0.0.1:1/", Duration::ZERO)
834 .validate()
835 .expect_err("zero startup timeout must fail");
836 assert!(matches!(
837 error,
838 PocketIcStartupError::InvalidConfiguration { .. }
839 ));
840
841 let error = PocketIcStartupConfig::spawn("pocket-ic", Duration::from_secs(1))
842 .with_server_hard_ttl(Duration::from_millis(1))
843 .validate()
844 .expect_err("subsecond server hard TTL must fail");
845 assert!(matches!(
846 error,
847 PocketIcStartupError::InvalidConfiguration { .. }
848 ));
849 }
850
851 #[test]
852 fn managed_server_hard_ttl_is_opt_in() {
853 let default = PocketIcStartupConfig::spawn("pocket-ic", Duration::from_secs(1));
854 assert_eq!(default.server_hard_ttl(), None);
855
856 let explicit = default.with_server_hard_ttl(Duration::from_secs(17));
857 assert_eq!(explicit.server_hard_ttl(), Some(Duration::from_secs(17)));
858 }
859
860 #[test]
861 fn startup_files_leave_the_server_owned_port_path_absent() {
862 let (files, stdout, stderr) = StartupFiles::create().expect("allocate startup files");
863 let directory = files.directory.clone();
864
865 assert!(directory.is_dir());
866 assert!(!files.port.exists());
867 assert!(files.stdout.is_file());
868 assert!(files.stderr.is_file());
869 #[cfg(unix)]
870 {
871 use std::os::unix::fs::PermissionsExt as _;
872
873 let mode = fs::metadata(&directory)
874 .expect("inspect private startup directory")
875 .permissions()
876 .mode();
877 assert_eq!(mode & 0o077, 0);
878 }
879
880 drop(stdout);
881 drop(stderr);
882 drop(files);
883 assert!(!directory.exists());
884 }
885
886 #[cfg(unix)]
887 #[test]
888 fn managed_startup_reports_an_exited_server_with_bounded_output() {
889 let script = TestServerScript::new(
890 "exit",
891 "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nprintf 'synthetic server stdout'\nprintf 'synthetic bind failure' >&2\nexit 23\n",
892 );
893
894 let result = PocketIcBuilder::new().with_application_subnet().try_build(
895 PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2)),
896 );
897
898 let Err(PocketIcStartupError::ServerExited {
899 server_binary,
900 status,
901 stdout,
902 stderr,
903 ..
904 }) = result
905 else {
906 panic!("an exited managed server must return a structured exit error");
907 };
908 assert_eq!(server_binary, script.path());
909 assert_eq!(status.code(), Some(23));
910 assert_eq!(stdout, "synthetic server stdout");
911 assert_eq!(stderr, "synthetic bind failure");
912 }
913
914 #[cfg(unix)]
915 #[test]
916 fn managed_startup_terminates_a_server_that_never_becomes_ready() {
917 let script = TestServerScript::new(
918 "timeout",
919 "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nexec sleep 30\n",
920 );
921 let timeout = Duration::from_millis(100);
922 let started = Instant::now();
923
924 let result = PocketIcBuilder::new()
925 .with_application_subnet()
926 .try_build(PocketIcStartupConfig::spawn(script.path(), timeout));
927
928 assert!(
929 started.elapsed() < Duration::from_secs(2),
930 "bounded startup should not wait for the sleeping child"
931 );
932 assert!(matches!(
933 result,
934 Err(PocketIcStartupError::ReadinessTimeout {
935 server_binary,
936 timeout: actual_timeout,
937 termination_error: None,
938 ..
939 }) if server_binary == script.path() && actual_timeout == timeout
940 ));
941 }
942
943 #[cfg(unix)]
944 #[test]
945 fn managed_server_handle_exposes_process_id_url_output_and_raii_ownership() {
946 let script = TestServerScript::new(
947 "handle",
948 "#!/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",
949 );
950
951 let server = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
952 .start_managed_server()
953 .expect("start caller-owned managed server");
954
955 assert_eq!(server.url(), "http://127.0.0.1:34567/");
956 assert_eq!(
957 server.output().stdout(),
958 format!("managed server ready: {}", server.process_id())
959 );
960 assert_eq!(server.output().stderr(), "");
961 #[cfg(target_os = "linux")]
962 let child_process = PathBuf::from(format!("/proc/{}", server.process_id()));
963 #[cfg(target_os = "linux")]
964 assert!(child_process.exists());
965 drop(server);
966 #[cfg(target_os = "linux")]
967 assert!(!child_process.exists());
968 }
969
970 #[cfg(unix)]
971 #[test]
972 fn managed_server_passes_an_explicit_hard_ttl() {
973 let script = TestServerScript::new(
974 "hard-ttl",
975 "#!/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",
976 );
977
978 let server = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
979 .with_server_hard_ttl(Duration::from_secs(17))
980 .start_managed_server()
981 .expect("start managed server with an explicit hard TTL");
982
983 assert_eq!(server.url(), "http://127.0.0.1:34567/");
984 }
985
986 #[test]
987 #[ignore = "requires IC_TESTKIT_POCKET_IC_SERVER=<caller-provided PocketIC server binary>"]
988 fn caller_provided_server_publishes_port_constructs_instance_and_cleans_up() {
989 let binary = std::env::var_os("IC_TESTKIT_POCKET_IC_SERVER")
990 .map(PathBuf::from)
991 .expect("set IC_TESTKIT_POCKET_IC_SERVER to the exact server binary");
992 let one_shot_sequence = super::STARTUP_FILE_SEQUENCE.load(super::Ordering::Relaxed);
993 let one_shot_directory = std::env::temp_dir().join(format!(
994 "ic-testkit-pocket-ic-startup-{}-{one_shot_sequence}",
995 std::process::id()
996 ));
997 let one_shot = PocketIcBuilder::new()
998 .with_application_subnet()
999 .try_build(
1000 PocketIcStartupConfig::spawn(&binary, Duration::from_secs(30))
1001 .with_server_hard_ttl(Duration::from_secs(1)),
1002 )
1003 .expect("one-shot managed spawn must construct an instance");
1004 assert!(one_shot_directory.is_dir());
1005 drop(one_shot);
1006 let cleanup_deadline = Instant::now() + Duration::from_secs(3);
1007 while one_shot_directory.exists() && Instant::now() < cleanup_deadline {
1008 std::thread::sleep(Duration::from_millis(20));
1009 }
1010 assert!(!one_shot_directory.exists());
1011
1012 let server = PocketIcStartupConfig::spawn(&binary, Duration::from_secs(30))
1013 .with_server_hard_ttl(Duration::from_secs(60))
1014 .start_managed_server()
1015 .expect("caller-provided PocketIC server must publish its port");
1016 let files = server
1017 .server
1018 .files
1019 .as_ref()
1020 .expect("managed server must retain startup files");
1021 let startup_directory = files.directory.clone();
1022
1023 assert!(files.port.is_file());
1024 let pocket_ic = PocketIcBuilder::new()
1025 .with_application_subnet()
1026 .try_build(PocketIcStartupConfig::connect(
1027 server.url(),
1028 Duration::from_secs(30),
1029 ))
1030 .expect("construct instance through caller-provided server");
1031
1032 drop(pocket_ic);
1033 drop(server);
1034 assert!(!startup_directory.exists());
1035 }
1036
1037 #[cfg(unix)]
1038 struct TestServerScript {
1039 path: PathBuf,
1040 }
1041
1042 #[cfg(unix)]
1043 impl TestServerScript {
1044 fn new(label: &str, contents: &str) -> Self {
1045 let path = std::env::temp_dir().join(format!(
1046 "ic-testkit-pocket-ic-{label}-{}-{}",
1047 std::process::id(),
1048 super::STARTUP_FILE_SEQUENCE.fetch_add(1, super::Ordering::Relaxed),
1049 ));
1050 fs::write(&path, contents).expect("write synthetic PocketIC server script");
1051 let mut permissions = fs::metadata(&path)
1052 .expect("read synthetic server script metadata")
1053 .permissions();
1054 permissions.set_mode(0o755);
1055 fs::set_permissions(&path, permissions)
1056 .expect("make synthetic server script executable");
1057 Self { path }
1058 }
1059
1060 fn path(&self) -> PathBuf {
1061 self.path.clone()
1062 }
1063 }
1064
1065 #[cfg(unix)]
1066 impl Drop for TestServerScript {
1067 fn drop(&mut self) {
1068 let _ = fs::remove_file(&self.path);
1069 }
1070 }
1071}