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