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