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