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