1use std::{
2 fmt::Write as _,
3 fs::{self, File, OpenOptions},
4 future::Future as _,
5 io::{self, Read as _},
6 path::{Path, PathBuf},
7 process::{Command, ExitStatus, Stdio},
8 sync::{
9 atomic::{AtomicU64, Ordering},
10 mpsc::{self, RecvTimeoutError},
11 },
12 thread,
13 time::{Duration, Instant},
14};
15
16#[cfg(unix)]
17use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _};
18
19use ic_host_process::child::{CleanupError, OwnedChild};
20use pocket_ic::{PocketIc, PocketIcBuilder};
21
22use super::transport;
23
24const STARTUP_POLL_INTERVAL: Duration = Duration::from_millis(20);
25const SERVER_OUTPUT_LIMIT: usize = 16 * 1024;
26const SERVER_PORT_FILE_LIMIT: usize = 64;
29
30static STARTUP_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
31
32#[derive(Clone, Debug, Eq, PartialEq)]
34pub struct PocketIcStartupConfig {
35 source: PocketIcStartupSource,
36 timeout: Duration,
37 server_hard_ttl: Option<Duration>,
38 server_idle_ttl: Option<Duration>,
39 server_output_files: Option<(PathBuf, PathBuf)>,
40}
41
42pub struct PocketIcManagedServer {
53 server: ManagedServer,
54 url: String,
55}
56
57#[must_use]
69pub struct PocketIcInstance<T> {
70 pub instance: T,
72 pub server: Option<PocketIcManagedServer>,
74}
75
76#[derive(Clone, Debug, Default, Eq, PartialEq)]
82pub struct PocketIcManagedServerOutput {
83 stdout: String,
84 stderr: String,
85}
86
87#[derive(Clone, Debug, Eq, PartialEq)]
88enum PocketIcStartupSource {
89 Spawn { server_binary: PathBuf },
90 Connect { server_url: String },
91}
92
93#[derive(Debug)]
99pub struct PocketIcStartupError {
100 failure: Box<PocketIcStartupFailure>,
101 output: PocketIcManagedServerOutput,
102 command_cleanup: Option<Box<CleanupError>>,
103 server_cleanup: Option<Box<CleanupError>>,
104}
105
106impl PocketIcStartupError {
107 fn new(failure: PocketIcStartupFailure) -> Self {
108 Self {
109 failure: Box::new(failure),
110 output: PocketIcManagedServerOutput::default(),
111 command_cleanup: None,
112 server_cleanup: None,
113 }
114 }
115
116 #[must_use]
118 pub fn failure(&self) -> &PocketIcStartupFailure {
119 &self.failure
120 }
121
122 #[must_use]
124 pub const fn output(&self) -> &PocketIcManagedServerOutput {
125 &self.output
126 }
127
128 #[must_use]
130 pub fn command_cleanup(&self) -> Option<&CleanupError> {
131 self.command_cleanup.as_deref()
132 }
133
134 #[must_use]
136 pub fn server_cleanup(&self) -> Option<&CleanupError> {
137 self.server_cleanup.as_deref()
138 }
139
140 fn with_cleanup(
141 mut self,
142 command: Option<CleanupError>,
143 server: Option<(PocketIcManagedServerOutput, Option<CleanupError>)>,
144 ) -> Self {
145 self.command_cleanup = command.map(Box::new);
146 if let Some((output, cleanup)) = server {
147 self.output = output;
148 self.server_cleanup = cleanup.map(Box::new);
149 }
150 self
151 }
152}
153
154#[non_exhaustive]
156#[derive(Debug)]
157pub enum PocketIcStartupFailure {
158 NotConfigured,
160 InvalidEnvironment { variable: &'static str },
162 ServerVersionProbe {
164 source: ic_host_process::tool::ToolError,
165 },
166 ServerVersionMismatch { expected: String, observed: Vec<u8> },
168 CommandRun { program: PathBuf, source: io::Error },
170 InvalidConfiguration { message: String },
172 InvalidServerUrl { server_url: String, message: String },
174 Io {
176 operation: &'static str,
177 path: PathBuf,
178 source: io::Error,
179 },
180 ServerSpawn {
182 server_binary: PathBuf,
183 source: io::Error,
184 },
185 ServerExited {
187 server_binary: PathBuf,
188 status: ExitStatus,
189 elapsed: Duration,
190 },
191 ReadinessTimeout {
193 server_binary: PathBuf,
194 timeout: Duration,
195 },
196 InvalidServerPort {
198 server_binary: PathBuf,
199 value: String,
200 },
201 InstanceCreationTimeout { timeout: Duration },
203 BuilderThreadSpawn { source: io::Error },
205 BuilderPanicked { message: String },
207 BuilderDisconnected,
209 ServerCleanup { command_status: ExitStatus },
211}
212
213pub trait PocketIcBuilderExt {
219 fn try_build(self, config: PocketIcStartupConfig) -> Result<PocketIc, PocketIcStartupError>;
229}
230
231impl PocketIcStartupConfig {
232 pub fn from_env(timeout: Duration) -> Result<Self, PocketIcStartupError> {
243 Self::from_environment(timeout, |name| std::env::var_os(name))
244 }
245
246 fn from_environment(
247 timeout: Duration,
248 mut variable: impl FnMut(&str) -> Option<std::ffi::OsString>,
249 ) -> Result<Self, PocketIcStartupError> {
250 if let Some(value) = variable("IC_TESTKIT_POCKET_IC_URL") {
251 let server_url = value
252 .into_string()
253 .ok()
254 .filter(|value| !value.is_empty())
255 .ok_or_else(|| {
256 PocketIcStartupError::new(PocketIcStartupFailure::InvalidEnvironment {
257 variable: "IC_TESTKIT_POCKET_IC_URL",
258 })
259 })?;
260 let config = Self::connect(&server_url, timeout);
261 config.validate()?;
262 let parsed = server_url.parse().map_err(|error| {
263 PocketIcStartupError::new(PocketIcStartupFailure::InvalidServerUrl {
264 server_url: server_url.clone(),
265 message: format!("{error}"),
266 })
267 })?;
268 let _ = PocketIcBuilder::new().with_server_url(parsed);
269 return Ok(config);
270 }
271 let value = variable("POCKET_IC_BIN")
272 .ok_or_else(|| PocketIcStartupError::new(PocketIcStartupFailure::NotConfigured))?;
273 if value.is_empty() {
274 return Err(PocketIcStartupError::new(
275 PocketIcStartupFailure::InvalidEnvironment {
276 variable: "POCKET_IC_BIN",
277 },
278 ));
279 }
280 let path = PathBuf::from(value);
281 let binary = fs::canonicalize(&path).map_err(|source| {
282 PocketIcStartupError::new(PocketIcStartupFailure::Io {
283 operation: "resolve configured PocketIC executable",
284 path,
285 source,
286 })
287 })?;
288 let config = Self::spawn(&binary, timeout);
289 config.validate()?;
290 let evidence = ic_host_process::tool::capture_group_command(
291 Command::new(&binary).arg("--version"),
292 ic_host_process::tool::OutputLimits {
293 stdout: ic_host_process::tool::OutputLimit::Terminate(SERVER_OUTPUT_LIMIT),
294 stderr: ic_host_process::tool::OutputLimit::Terminate(SERVER_OUTPUT_LIMIT),
295 timeout: Some(timeout),
296 },
297 )
298 .and_then(ic_host_process::tool::CompletedExecution::require_success)
299 .map_err(|source| {
300 PocketIcStartupError::new(PocketIcStartupFailure::ServerVersionProbe { source })
301 })?;
302 let expected = "pocket-ic-server >=16.0.0,<17.0.0 (stable)".to_owned();
303 if !super::supports_pocket_ic_server(&evidence.stdout) {
304 return Err(PocketIcStartupError::new(
305 PocketIcStartupFailure::ServerVersionMismatch {
306 expected,
307 observed: evidence.stdout,
308 },
309 ));
310 }
311 Ok(config)
312 }
313
314 #[must_use]
319 pub fn spawn(server_binary: impl Into<PathBuf>, timeout: Duration) -> Self {
320 Self {
321 source: PocketIcStartupSource::Spawn {
322 server_binary: server_binary.into(),
323 },
324 timeout,
325 server_hard_ttl: None,
326 server_idle_ttl: None,
327 server_output_files: None,
328 }
329 }
330
331 #[must_use]
336 pub fn connect(server_url: impl Into<String>, timeout: Duration) -> Self {
337 Self {
338 source: PocketIcStartupSource::Connect {
339 server_url: server_url.into(),
340 },
341 timeout,
342 server_hard_ttl: None,
343 server_idle_ttl: None,
344 server_output_files: None,
345 }
346 }
347
348 #[must_use]
350 pub const fn with_server_hard_ttl(mut self, hard_ttl: Duration) -> Self {
351 self.server_hard_ttl = Some(hard_ttl);
352 self
353 }
354
355 #[must_use]
360 pub const fn with_server_idle_ttl(mut self, idle_ttl: Duration) -> Self {
361 self.server_idle_ttl = Some(idle_ttl);
362 self
363 }
364
365 #[must_use]
367 pub const fn server_idle_ttl(&self) -> Option<Duration> {
368 self.server_idle_ttl
369 }
370
371 #[must_use]
382 pub fn with_server_output_files(
383 mut self,
384 stdout: impl Into<PathBuf>,
385 stderr: impl Into<PathBuf>,
386 ) -> Self {
387 self.server_output_files = Some((stdout.into(), stderr.into()));
388 self
389 }
390
391 #[must_use]
393 pub const fn timeout(&self) -> Duration {
394 self.timeout
395 }
396
397 #[must_use]
399 pub const fn server_hard_ttl(&self) -> Option<Duration> {
400 self.server_hard_ttl
401 }
402
403 #[must_use]
405 pub fn server_binary(&self) -> Option<&Path> {
406 match &self.source {
407 PocketIcStartupSource::Spawn { server_binary } => Some(server_binary),
408 PocketIcStartupSource::Connect { .. } => None,
409 }
410 }
411
412 #[must_use]
414 pub fn server_url(&self) -> Option<&str> {
415 match &self.source {
416 PocketIcStartupSource::Connect { server_url } => Some(server_url),
417 PocketIcStartupSource::Spawn { .. } => None,
418 }
419 }
420
421 pub fn build_instance(
441 self,
442 builder: PocketIcBuilder,
443 ) -> Result<PocketIcInstance<PocketIc>, PocketIcStartupError> {
444 self.validate()?;
445 let started = Instant::now();
446 let deadline = startup_deadline(started, self.timeout)?;
447 match self.source {
448 PocketIcStartupSource::Connect { server_url } => {
449 build_bounded(builder, &server_url, deadline, self.timeout, None)
450 }
451 PocketIcStartupSource::Spawn { server_binary } => {
452 let (server, server_url) = ManagedServer::start(
453 server_binary,
454 self.server_hard_ttl,
455 self.server_idle_ttl,
456 self.server_output_files,
457 deadline,
458 self.timeout,
459 started,
460 )?;
461 build_bounded(builder, &server_url, deadline, self.timeout, Some(server))
462 }
463 }
464 }
465
466 pub async fn build_instance_async(
497 self,
498 builder: PocketIcBuilder,
499 ) -> Result<PocketIcInstance<pocket_ic::nonblocking::PocketIc>, PocketIcStartupError> {
500 self.validate()?;
501 let started = Instant::now();
502 let deadline = startup_deadline(started, self.timeout)?;
503 let (server, server_url) = match self.source {
504 PocketIcStartupSource::Connect { server_url } => (None, server_url),
505 PocketIcStartupSource::Spawn { server_binary } => {
506 let mut server = ManagedServer::spawn(
507 server_binary,
508 self.server_hard_ttl,
509 self.server_idle_ttl,
510 self.server_output_files,
511 started,
512 )?;
513 let server_url = loop {
514 match server.poll_ready(deadline, self.timeout) {
515 Ok(Some(url)) => break url,
516 Ok(None) => {
517 tokio::time::sleep(
518 deadline
519 .saturating_duration_since(Instant::now())
520 .min(STARTUP_POLL_INTERVAL),
521 )
522 .await;
523 }
524 Err(error) => return Err(finalize_failure(error, None, Some(server))),
525 }
526 };
527 (Some(server), server_url)
528 }
529 };
530 build_async_bounded(builder, &server_url, deadline, self.timeout, server).await
531 }
532
533 pub fn start_managed_server(self) -> Result<PocketIcManagedServer, PocketIcStartupError> {
547 self.validate()?;
548 let PocketIcStartupSource::Spawn { server_binary } = self.source else {
549 return Err(PocketIcStartupError::new(
550 PocketIcStartupFailure::InvalidConfiguration {
551 message: "starting a managed PocketIC server requires a spawn configuration"
552 .to_owned(),
553 },
554 ));
555 };
556 let started = Instant::now();
557 let deadline = startup_deadline(started, self.timeout)?;
558 let (server, url) = ManagedServer::start(
559 server_binary,
560 self.server_hard_ttl,
561 self.server_idle_ttl,
562 self.server_output_files,
563 deadline,
564 self.timeout,
565 started,
566 )?;
567 Ok(PocketIcManagedServer { server, url })
568 }
569
570 pub fn run_command(
587 self,
588 command: &mut Command,
589 mut cancelled: impl FnMut() -> bool,
590 ) -> Result<ExitStatus, PocketIcStartupError> {
591 self.validate()?;
592 let program = PathBuf::from(command.get_program());
593 let command_error = |source| {
594 PocketIcStartupError::new(PocketIcStartupFailure::CommandRun {
595 program: program.clone(),
596 source,
597 })
598 };
599 if cancelled() {
600 return Err(command_error(io::Error::from(io::ErrorKind::Interrupted)));
601 }
602 let (server, url) = if let Some(url) = self.server_url() {
603 (None, url.to_owned())
604 } else {
605 let server = self.start_managed_server()?;
606 let url = server.url().to_owned();
607 (Some(server.server), url)
608 };
609 let mut server = server;
610 if cancelled() {
611 return Err(finalize_failure(
612 command_error(io::Error::from(io::ErrorKind::Interrupted)),
613 None,
614 server,
615 ));
616 }
617 command.env("IC_TESTKIT_POCKET_IC_URL", url);
618 let mut owned_child = match OwnedChild::spawn(command) {
619 Ok(child) => child,
620 Err(source) => {
621 return Err(finalize_failure(
622 PocketIcStartupError::new(PocketIcStartupFailure::Io {
623 operation: "spawn command with PocketIC server",
624 path: PathBuf::from(command.get_program()),
625 source,
626 }),
627 None,
628 server,
629 ));
630 }
631 };
632 let result = loop {
633 if cancelled() {
634 break Err(command_error(io::Error::from(io::ErrorKind::Interrupted)));
635 }
636 match owned_child.try_wait() {
637 Ok(Some(status)) => break Ok(status),
638 Ok(None) => {
639 if let Some(managed) = server.as_mut() {
640 match managed.try_wait() {
641 Ok(Some(status)) => break Err(managed.exit_failure(status)),
642 Ok(None) => {}
643 Err(error) => break Err(error),
644 }
645 }
646 thread::sleep(STARTUP_POLL_INTERVAL);
647 }
648 Err(source) => break Err(command_error(source)),
649 }
650 };
651 match result {
652 Err(error) => Err(finalize_failure(error, Some(&mut owned_child), server)),
653 Ok(status) => {
654 if let Some(server) = server {
655 let (output, cleanup) = server.terminate_and_capture();
656 if let Some(cleanup) = cleanup {
657 return Err(PocketIcStartupError::new(
658 PocketIcStartupFailure::ServerCleanup {
659 command_status: status,
660 },
661 )
662 .with_cleanup(None, Some((output, Some(cleanup)))));
663 }
664 }
665 Ok(status)
666 }
667 }
668 }
669
670 fn validate(&self) -> Result<(), PocketIcStartupError> {
671 if self.server_idle_ttl.is_some()
672 && (self.server_url().is_some()
673 || self.server_idle_ttl.is_some_and(|ttl| ttl.as_secs() == 0))
674 {
675 return Err(PocketIcStartupError::new(
676 PocketIcStartupFailure::InvalidConfiguration {
677 message: "PocketIC server idle TTL requires spawn mode and at least one second"
678 .to_owned(),
679 },
680 ));
681 }
682 if self.server_url().is_some() && self.server_output_files.is_some() {
683 return Err(PocketIcStartupError::new(
684 PocketIcStartupFailure::InvalidConfiguration {
685 message: "server output files require a spawn configuration".to_owned(),
686 },
687 ));
688 }
689 if self.timeout.is_zero() {
690 return Err(PocketIcStartupError::new(
691 PocketIcStartupFailure::InvalidConfiguration {
692 message: "PocketIC startup timeout must be greater than zero".to_owned(),
693 },
694 ));
695 }
696 if matches!(&self.source, PocketIcStartupSource::Spawn { .. })
697 && self
698 .server_hard_ttl
699 .is_some_and(|hard_ttl| hard_ttl.as_secs() == 0)
700 {
701 return Err(PocketIcStartupError::new(
702 PocketIcStartupFailure::InvalidConfiguration {
703 message: "PocketIC server hard TTL must be at least one second".to_owned(),
704 },
705 ));
706 }
707 Ok(())
708 }
709}
710
711impl PocketIcManagedServer {
712 #[must_use]
719 pub fn process_id(&self) -> u32 {
720 self.server
721 .child
722 .as_ref()
723 .expect("managed server handle must own its child")
724 .id()
725 }
726
727 #[must_use]
729 pub fn url(&self) -> &str {
730 &self.url
731 }
732
733 #[must_use]
739 pub fn output(&self) -> PocketIcManagedServerOutput {
740 self.server.capture()
741 }
742}
743
744impl PocketIcManagedServerOutput {
745 #[must_use]
747 pub fn stdout(&self) -> &str {
748 &self.stdout
749 }
750
751 #[must_use]
753 pub fn stderr(&self) -> &str {
754 &self.stderr
755 }
756}
757
758impl PocketIcBuilderExt for PocketIcBuilder {
759 fn try_build(self, config: PocketIcStartupConfig) -> Result<PocketIc, PocketIcStartupError> {
760 let PocketIcInstance { instance, server } = config.build_instance(self)?;
761 if let Some(server) = server {
762 server.server.reap_in_background();
763 }
764 Ok(instance)
765 }
766}
767
768fn startup_deadline(started: Instant, timeout: Duration) -> Result<Instant, PocketIcStartupError> {
769 started.checked_add(timeout).ok_or_else(|| {
770 PocketIcStartupError::new(PocketIcStartupFailure::InvalidConfiguration {
771 message: "PocketIC startup timeout exceeds the platform clock range".to_owned(),
772 })
773 })
774}
775
776fn build_bounded(
777 builder: PocketIcBuilder,
778 server_url: &str,
779 deadline: Instant,
780 timeout: Duration,
781 mut server: Option<ManagedServer>,
782) -> Result<PocketIcInstance<PocketIc>, PocketIcStartupError> {
783 let builder = configured_builder(builder, server_url)
784 .map_err(|error| finalize_failure(error, None, server.take()))?;
785 let (sender, receiver) = mpsc::sync_channel(1);
786 if let Err(source) = thread::Builder::new()
787 .name("ic-testkit-pocket-ic-startup".to_owned())
788 .spawn(move || {
789 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| builder.build()))
790 .map_err(|payload| transport::panic_payload_to_string(payload.as_ref()));
791 let _ = sender.send(result);
792 })
793 {
794 return Err(finalize_failure(
795 PocketIcStartupError::new(PocketIcStartupFailure::BuilderThreadSpawn { source }),
796 None,
797 server,
798 ));
799 }
800 await_builder(receiver, server_url, deadline, timeout, &mut server)
801}
802
803fn await_builder(
804 receiver: mpsc::Receiver<Result<PocketIc, String>>,
805 server_url: &str,
806 deadline: Instant,
807 timeout: Duration,
808 server: &mut Option<ManagedServer>,
809) -> Result<PocketIcInstance<PocketIc>, PocketIcStartupError> {
810 loop {
811 let now = Instant::now();
812 if now >= deadline {
813 return Err(finalize_failure(
814 PocketIcStartupError::new(PocketIcStartupFailure::InstanceCreationTimeout {
815 timeout,
816 }),
817 None,
818 server.take(),
819 ));
820 }
821 let remaining = deadline.saturating_duration_since(now);
822 let wait = if server.is_some() {
823 remaining.min(STARTUP_POLL_INTERVAL)
824 } else {
825 remaining
826 };
827 match receiver.recv_timeout(wait) {
828 Ok(Ok(pocket_ic)) => {
829 check_server(server)?;
830 return Ok(PocketIcInstance {
831 instance: pocket_ic,
832 server: server.take().map(|server| PocketIcManagedServer {
833 server,
834 url: server_url.to_owned(),
835 }),
836 });
837 }
838 Ok(Err(message)) => {
839 return Err(finalize_failure(
840 PocketIcStartupError::new(PocketIcStartupFailure::BuilderPanicked { message }),
841 None,
842 server.take(),
843 ));
844 }
845 Err(RecvTimeoutError::Disconnected) => {
846 return Err(finalize_failure(
847 PocketIcStartupError::new(PocketIcStartupFailure::BuilderDisconnected),
848 None,
849 server.take(),
850 ));
851 }
852 Err(RecvTimeoutError::Timeout) => {
853 check_server(server)?;
854 }
855 }
856 }
857}
858
859fn configured_builder(
860 builder: PocketIcBuilder,
861 server_url: &str,
862) -> Result<PocketIcBuilder, PocketIcStartupError> {
863 server_url
864 .parse()
865 .map(|url| builder.with_server_url(url))
866 .map_err(|error| {
867 PocketIcStartupError::new(PocketIcStartupFailure::InvalidServerUrl {
868 server_url: server_url.to_owned(),
869 message: error.to_string(),
870 })
871 })
872}
873
874fn check_server(server: &mut Option<ManagedServer>) -> Result<(), PocketIcStartupError> {
875 if let Some(managed) = server.as_mut() {
876 let error = match managed.try_wait() {
877 Ok(Some(status)) => Some(managed.exit_failure(status)),
878 Ok(None) => None,
879 Err(error) => Some(error),
880 };
881 if let Some(error) = error {
882 return Err(finalize_failure(error, None, server.take()));
883 }
884 }
885 Ok(())
886}
887
888async fn build_async_bounded(
889 builder: PocketIcBuilder,
890 server_url: &str,
891 deadline: Instant,
892 timeout: Duration,
893 mut server: Option<ManagedServer>,
894) -> Result<PocketIcInstance<pocket_ic::nonblocking::PocketIc>, PocketIcStartupError> {
895 let builder = configured_builder(builder, server_url)
896 .map_err(|error| finalize_failure(error, None, server.take()))?;
897 let mut construction = std::pin::pin!(builder.build_async());
898 let mut construction = std::pin::pin!(std::future::poll_fn(|context| {
899 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
900 construction.as_mut().poll(context)
901 })) {
902 Ok(poll) => poll.map(Ok),
903 Err(payload) => std::task::Poll::Ready(Err(PocketIcStartupError::new(
904 PocketIcStartupFailure::BuilderPanicked {
905 message: transport::panic_payload_to_string(payload.as_ref()),
906 },
907 ))),
908 }
909 }));
910 let mut server = server;
912 loop {
913 let now = Instant::now();
914 if now >= deadline {
915 return Err(finalize_failure(
916 PocketIcStartupError::new(PocketIcStartupFailure::InstanceCreationTimeout {
917 timeout,
918 }),
919 None,
920 server,
921 ));
922 }
923 check_server(&mut server)?;
924 let next_check = if server.is_some() {
925 now + deadline
926 .saturating_duration_since(now)
927 .min(STARTUP_POLL_INTERVAL)
928 } else {
929 deadline
930 };
931 match tokio::time::timeout_at(next_check.into(), construction.as_mut()).await {
932 Ok(Ok(instance)) => {
933 if Instant::now() >= deadline {
934 return Err(finalize_failure(
935 PocketIcStartupError::new(
936 PocketIcStartupFailure::InstanceCreationTimeout { timeout },
937 ),
938 None,
939 server,
940 ));
941 }
942 check_server(&mut server)?;
943 return Ok(PocketIcInstance {
944 instance,
945 server: server.map(|server| PocketIcManagedServer {
946 server,
947 url: server_url.to_owned(),
948 }),
949 });
950 }
951 Ok(Err(error)) => return Err(finalize_failure(error, None, server)),
952 Err(_) => {}
953 }
954 }
955}
956
957fn finalize_failure(
959 error: PocketIcStartupError,
960 command: Option<&mut OwnedChild>,
961 server: Option<ManagedServer>,
962) -> PocketIcStartupError {
963 error.with_cleanup(
964 command.and_then(|child| child.terminate().err()),
965 server.map(ManagedServer::terminate_and_capture),
966 )
967}
968
969struct ManagedServer {
970 child: Option<OwnedChild>,
971 binary: PathBuf,
972 files: StartupFiles,
973 started: Instant,
974}
975
976enum PortFileState {
977 Pending,
978 Ready(u16),
979 Invalid(String),
980}
981
982impl ManagedServer {
983 fn start(
984 binary: PathBuf,
985 hard_ttl: Option<Duration>,
986 idle_ttl: Option<Duration>,
987 output_files: Option<(PathBuf, PathBuf)>,
988 deadline: Instant,
989 timeout: Duration,
990 started: Instant,
991 ) -> Result<(Self, String), PocketIcStartupError> {
992 let mut server = Self::spawn(binary, hard_ttl, idle_ttl, output_files, started)?;
993 loop {
994 match server.poll_ready(deadline, timeout) {
995 Ok(Some(url)) => return Ok((server, url)),
996 Ok(None) => thread::sleep(
997 deadline
998 .saturating_duration_since(Instant::now())
999 .min(STARTUP_POLL_INTERVAL),
1000 ),
1001 Err(error) => return Err(finalize_failure(error, None, Some(server))),
1002 }
1003 }
1004 }
1005
1006 fn spawn(
1007 binary: PathBuf,
1008 hard_ttl: Option<Duration>,
1009 idle_ttl: Option<Duration>,
1010 output_files: Option<(PathBuf, PathBuf)>,
1011 started: Instant,
1012 ) -> Result<Self, PocketIcStartupError> {
1013 let (files, stdout, stderr) = StartupFiles::create(output_files)?;
1014 let mut command = Command::new(&binary);
1015 if let Some(hard_ttl) = hard_ttl {
1016 command
1017 .arg("--hard-ttl")
1018 .arg(hard_ttl.as_secs().to_string());
1019 }
1020 if let Some(idle_ttl) = idle_ttl {
1021 command.arg("--ttl").arg(idle_ttl.as_secs().to_string());
1022 }
1023 command
1024 .arg("--port-file")
1025 .arg(&files.port)
1026 .stdout(Stdio::from(stdout))
1027 .stderr(Stdio::from(stderr));
1028 let child = OwnedChild::spawn(&mut command).map_err(|source| {
1029 PocketIcStartupError::new(PocketIcStartupFailure::ServerSpawn {
1030 server_binary: binary.clone(),
1031 source,
1032 })
1033 })?;
1034 Ok(Self {
1035 child: Some(child),
1036 binary,
1037 files,
1038 started,
1039 })
1040 }
1041
1042 fn poll_ready(
1043 &mut self,
1044 deadline: Instant,
1045 timeout: Duration,
1046 ) -> Result<Option<String>, PocketIcStartupError> {
1047 if let Some(status) = self.try_wait()? {
1048 return Err(self.exit_failure(status));
1049 }
1050 if Instant::now() >= deadline {
1051 return Err(PocketIcStartupError::new(
1052 PocketIcStartupFailure::ReadinessTimeout {
1053 server_binary: self.binary.clone(),
1054 timeout,
1055 },
1056 ));
1057 }
1058 match self.read_port()? {
1059 PortFileState::Pending => Ok(None),
1060 PortFileState::Ready(port) => Ok(Some(format!("http://127.0.0.1:{port}/"))),
1061 PortFileState::Invalid(value) => Err(PocketIcStartupError::new(
1062 PocketIcStartupFailure::InvalidServerPort {
1063 server_binary: self.binary.clone(),
1064 value,
1065 },
1066 )),
1067 }
1068 }
1069
1070 fn try_wait(&mut self) -> Result<Option<ExitStatus>, PocketIcStartupError> {
1071 let child = self
1072 .child
1073 .as_mut()
1074 .expect("managed server child must remain present");
1075 child.try_wait().map_err(|source| {
1076 PocketIcStartupError::new(PocketIcStartupFailure::Io {
1077 operation: "inspect PocketIC server child",
1078 path: self.binary.clone(),
1079 source,
1080 })
1081 })
1082 }
1083
1084 fn read_port(&self) -> Result<PortFileState, PocketIcStartupError> {
1085 let port_path = &self.files.port;
1086 let mut contents = String::new();
1087 match open_regular_startup_file(port_path).and_then(|file| {
1088 file.take((SERVER_PORT_FILE_LIMIT + 1) as u64)
1089 .read_to_string(&mut contents)
1090 }) {
1091 Ok(_) => {}
1092 Err(error) if error.kind() == io::ErrorKind::NotFound => {
1093 return Ok(PortFileState::Pending);
1094 }
1095 Err(source) => {
1096 return Err(PocketIcStartupError::new(PocketIcStartupFailure::Io {
1097 operation: "read PocketIC server port file",
1098 path: port_path.clone(),
1099 source,
1100 }));
1101 }
1102 }
1103 if contents.len() > SERVER_PORT_FILE_LIMIT {
1104 return Ok(PortFileState::Invalid(format!(
1105 "{} (port file exceeds {SERVER_PORT_FILE_LIMIT} bytes)",
1106 contents.trim()
1107 )));
1108 }
1109 if !contents.contains('\n') {
1110 return Ok(PortFileState::Pending);
1111 }
1112 let value = contents.trim().to_owned();
1113 match value.parse::<u16>() {
1114 Ok(port) if port != 0 => Ok(PortFileState::Ready(port)),
1115 _ => Ok(PortFileState::Invalid(value)),
1116 }
1117 }
1118
1119 fn exit_failure(&self, status: ExitStatus) -> PocketIcStartupError {
1120 PocketIcStartupError::new(PocketIcStartupFailure::ServerExited {
1121 server_binary: self.binary.clone(),
1122 status,
1123 elapsed: self.started.elapsed(),
1124 })
1125 }
1126
1127 fn terminate_and_capture(mut self) -> (PocketIcManagedServerOutput, Option<CleanupError>) {
1128 let cleanup = self
1129 .child
1130 .take()
1131 .and_then(|mut child| child.terminate().err());
1132 (self.capture(), cleanup)
1133 }
1134
1135 fn capture(&self) -> PocketIcManagedServerOutput {
1136 PocketIcManagedServerOutput {
1137 stdout: read_bounded_lossy(&self.files.stdout),
1138 stderr: read_bounded_lossy(&self.files.stderr),
1139 }
1140 }
1141
1142 fn reap_in_background(self) {
1143 let _ = thread::Builder::new()
1144 .name("ic-testkit-pocket-ic-server-reaper".to_owned())
1145 .spawn(move || {
1146 let mut server = self;
1149 if let Some(child) = server.child.as_mut() {
1150 let _ = child.wait();
1151 }
1152 });
1153 }
1154}
1155
1156struct StartupFiles {
1157 directory: PathBuf,
1158 port: PathBuf,
1159 stdout: PathBuf,
1160 stderr: PathBuf,
1161}
1162
1163impl StartupFiles {
1164 fn create(
1165 output_files: Option<(PathBuf, PathBuf)>,
1166 ) -> Result<(Self, File, File), PocketIcStartupError> {
1167 let output_files = output_files
1168 .map(|(stdout, stderr)| {
1169 Ok::<_, PocketIcStartupError>((
1170 std::path::absolute(&stdout)
1171 .map_err(|source| startup_file_error("resolve", &stdout, source))?,
1172 std::path::absolute(&stderr)
1173 .map_err(|source| startup_file_error("resolve", &stderr, source))?,
1174 ))
1175 })
1176 .transpose()?;
1177 loop {
1178 let sequence = STARTUP_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1179 let base = std::env::temp_dir().join(format!(
1180 "ic-testkit-pocket-ic-startup-{}-{sequence}",
1181 std::process::id()
1182 ));
1183 let mut directory = fs::DirBuilder::new();
1184 #[cfg(unix)]
1185 {
1186 directory.mode(0o700);
1187 }
1188 match directory.create(&base) {
1189 Ok(()) => {}
1190 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
1191 Err(source) => return Err(startup_file_error("create", &base, source)),
1192 }
1193 let (stdout_path, stderr_path) =
1194 output_files.unwrap_or_else(|| (base.join("stdout"), base.join("stderr")));
1195 let files = Self {
1196 port: base.join("port"),
1197 stdout: stdout_path,
1198 stderr: stderr_path,
1199 directory: base,
1200 };
1201 let stdout = create_new_file(&files.stdout)
1202 .map_err(|source| startup_file_error("create", &files.stdout, source))?;
1203 let stderr = create_new_file(&files.stderr)
1204 .map_err(|source| startup_file_error("create", &files.stderr, source))?;
1205 return Ok((files, stdout, stderr));
1206 }
1207 }
1208}
1209
1210impl Drop for StartupFiles {
1211 fn drop(&mut self) {
1212 let _ = fs::remove_dir_all(&self.directory);
1213 }
1214}
1215
1216fn create_new_file(path: &Path) -> io::Result<File> {
1217 let mut options = OpenOptions::new();
1218 options.write(true).create_new(true);
1219 #[cfg(unix)]
1220 options.mode(0o600);
1221 options.open(path)
1222}
1223
1224fn startup_file_error(
1225 operation: &'static str,
1226 path: &Path,
1227 source: io::Error,
1228) -> PocketIcStartupError {
1229 PocketIcStartupError::new(PocketIcStartupFailure::Io {
1230 operation,
1231 path: path.to_owned(),
1232 source,
1233 })
1234}
1235
1236fn read_bounded_lossy(path: &Path) -> String {
1237 let Ok(file) = open_regular_startup_file(path) else {
1238 return String::new();
1239 };
1240 let length = file.metadata().map_or(0, |metadata| metadata.len());
1241 let Ok(bytes) = ic_host_artifacts::artifact::read_reader(
1242 file.take(SERVER_OUTPUT_LIMIT as u64),
1243 SERVER_OUTPUT_LIMIT,
1244 ) else {
1245 return String::new();
1246 };
1247 let mut output = String::from_utf8_lossy(&bytes).into_owned();
1248 let omitted = length.saturating_sub(bytes.len() as u64);
1249 if omitted > 0 {
1250 let _ = write!(output, "\n<truncated {omitted} bytes>");
1251 }
1252 output
1253}
1254
1255fn open_regular_startup_file(path: &Path) -> io::Result<File> {
1256 let mut options = OpenOptions::new();
1257 options.read(true);
1258 #[cfg(unix)]
1261 options.custom_flags(libc::O_NONBLOCK);
1262 let file = options.open(path)?;
1263 if !file.metadata()?.is_file() {
1264 return Err(io::Error::new(
1265 io::ErrorKind::InvalidData,
1266 "PocketIC startup reader requires a regular file",
1267 ));
1268 }
1269 Ok(file)
1270}
1271
1272impl std::fmt::Display for PocketIcStartupFailure {
1273 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1274 match self {
1275 Self::NotConfigured => formatter.write_str("configure IC_TESTKIT_POCKET_IC_URL or POCKET_IC_BIN; prepare a verified binary with make install-server"),
1276 Self::InvalidEnvironment { variable } => write!(formatter, "invalid selected environment value: {variable}"),
1277 Self::ServerVersionProbe { source } => write!(formatter, "PocketIC version probe failed: {source}"),
1278 Self::ServerVersionMismatch { expected, observed } => write!(formatter, "PocketIC version mismatch: expected {expected:?}, observed {:?}", String::from_utf8_lossy(observed)),
1279 Self::CommandRun { program, source } => write!(formatter, "command {} failed: {source}", program.display()),
1280 Self::InvalidConfiguration { message } => formatter.write_str(message),
1281 Self::InvalidServerUrl {
1282 server_url,
1283 message,
1284 } => write!(
1285 formatter,
1286 "invalid PocketIC server URL {server_url:?}: {message}"
1287 ),
1288 Self::Io {
1289 operation,
1290 path,
1291 source,
1292 } => write!(
1293 formatter,
1294 "failed to {operation} at {}: {source}",
1295 path.display()
1296 ),
1297 Self::ServerSpawn {
1298 server_binary,
1299 source,
1300 } => write!(
1301 formatter,
1302 "failed to spawn PocketIC server {}: {source}",
1303 server_binary.display()
1304 ),
1305 Self::ServerExited {
1306 server_binary,
1307 status,
1308 elapsed,
1309 } => write!(
1310 formatter,
1311 "PocketIC server {} exited with {status} after {elapsed:?}",
1312 server_binary.display()
1313 ),
1314 Self::ReadinessTimeout {
1315 server_binary,
1316 timeout,
1317 } => write!(
1318 formatter,
1319 "PocketIC server {} was not ready within {timeout:?}",
1320 server_binary.display()
1321 ),
1322 Self::InvalidServerPort {
1323 server_binary,
1324 value,
1325 } => write!(
1326 formatter,
1327 "PocketIC server {} published invalid port {value:?}",
1328 server_binary.display()
1329 ),
1330 Self::InstanceCreationTimeout { timeout } => {
1331 write!(formatter, "PocketIC instance creation exceeded {timeout:?}")
1332 }
1333 Self::BuilderThreadSpawn { source } => {
1334 write!(
1335 formatter,
1336 "failed to spawn PocketIC builder worker: {source}"
1337 )
1338 }
1339 Self::BuilderPanicked { message } => {
1340 write!(formatter, "PocketIC startup panicked: {message}")
1341 }
1342 Self::ServerCleanup { command_status } => write!(formatter, "PocketIC server cleanup failed after command exited with {command_status}"),
1343 Self::BuilderDisconnected => {
1344 formatter.write_str("PocketIC builder worker disconnected without a result")
1345 }
1346 }
1347 }
1348}
1349
1350impl std::error::Error for PocketIcStartupFailure {
1351 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1352 match self {
1353 Self::ServerVersionProbe { source } => Some(source),
1354 Self::Io { source, .. }
1355 | Self::CommandRun { source, .. }
1356 | Self::ServerSpawn { source, .. }
1357 | Self::BuilderThreadSpawn { source } => Some(source),
1358 _ => None,
1359 }
1360 }
1361}
1362
1363impl std::fmt::Display for PocketIcStartupError {
1364 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1365 std::fmt::Display::fmt(self.failure.as_ref(), formatter)?;
1366 if !self.output.stdout.is_empty() {
1367 write!(formatter, "; server stdout: {}", self.output.stdout)?;
1368 }
1369 if !self.output.stderr.is_empty() {
1370 write!(formatter, "; server stderr: {}", self.output.stderr)?;
1371 }
1372 for (owner, cleanup) in [
1373 ("command", self.command_cleanup()),
1374 ("server", self.server_cleanup()),
1375 ] {
1376 if let Some(error) = cleanup {
1377 write!(formatter, "; {owner} cleanup also failed: {error}")?;
1378 }
1379 }
1380 Ok(())
1381 }
1382}
1383
1384impl std::error::Error for PocketIcStartupError {
1385 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1386 std::error::Error::source(self.failure.as_ref())
1387 }
1388}
1389
1390#[cfg(test)]
1391mod tests {
1392 mod instances;
1393
1394 use std::{
1395 fs,
1396 path::PathBuf,
1397 time::{Duration, Instant},
1398 };
1399
1400 use super::{
1401 PocketIcBuilderExt as _, PocketIcStartupConfig, PocketIcStartupError,
1402 PocketIcStartupFailure, StartupFiles,
1403 };
1404 use pocket_ic::PocketIcBuilder;
1405
1406 #[cfg(unix)]
1407 use crate::test_executable::write_executable_script;
1408 #[cfg(unix)]
1409 use std::{
1410 io::Write as _,
1411 os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _},
1412 process::Command,
1413 sync::mpsc,
1414 };
1415
1416 #[test]
1417 fn original_failure_output_and_both_typed_cleanup_reports_survive_projection() {
1418 use std::error::Error as _;
1419 for failure in [
1420 PocketIcStartupFailure::ReadinessTimeout {
1421 server_binary: "server".into(),
1422 timeout: Duration::from_secs(1),
1423 },
1424 PocketIcStartupFailure::InstanceCreationTimeout {
1425 timeout: Duration::from_secs(1),
1426 },
1427 PocketIcStartupFailure::InvalidServerPort {
1428 server_binary: "server".into(),
1429 value: "invalid".into(),
1430 },
1431 PocketIcStartupFailure::BuilderPanicked {
1432 message: "original-panic".into(),
1433 },
1434 PocketIcStartupFailure::BuilderDisconnected,
1435 PocketIcStartupFailure::BuilderThreadSpawn {
1436 source: std::io::Error::from_raw_os_error(9),
1437 },
1438 PocketIcStartupFailure::Io {
1439 operation: "original-io",
1440 path: "port".into(),
1441 source: std::io::Error::from_raw_os_error(9),
1442 },
1443 ] {
1444 let primary_text = failure.to_string();
1445 let output = super::PocketIcManagedServerOutput {
1446 stdout: "original-stdout".into(),
1447 stderr: "original-stderr".into(),
1448 };
1449 let command = super::CleanupError {
1450 status: None,
1451 group_error: None,
1452 term_error: Some(std::io::Error::from_raw_os_error(4)),
1453 kill_error: Some(std::io::Error::from_raw_os_error(5)),
1454 wait_error: None,
1455 };
1456 let server = super::CleanupError {
1457 status: None,
1458 group_error: Some(std::io::Error::from_raw_os_error(1)),
1459 term_error: None,
1460 kill_error: None,
1461 wait_error: Some(std::io::Error::from_raw_os_error(10)),
1462 };
1463 let error = PocketIcStartupError::new(failure)
1464 .with_cleanup(Some(command), Some((output, Some(server))));
1465 assert_eq!(error.failure().to_string(), primary_text);
1466 assert_eq!(error.output().stdout(), "original-stdout");
1467 assert_eq!(error.output().stderr(), "original-stderr");
1468 let command = error.command_cleanup().unwrap();
1469 let server = error.server_cleanup().unwrap();
1470 assert_eq!(command.kill_error.as_ref().unwrap().raw_os_error(), Some(5));
1471 assert_eq!(command.term_error.as_ref().unwrap().raw_os_error(), Some(4));
1472 assert_eq!(server.group_error.as_ref().unwrap().raw_os_error(), Some(1));
1473 assert_eq!(server.wait_error.as_ref().unwrap().raw_os_error(), Some(10));
1474 if matches!(
1475 error.failure(),
1476 PocketIcStartupFailure::Io { .. }
1477 | PocketIcStartupFailure::BuilderThreadSpawn { .. }
1478 ) {
1479 assert_eq!(
1480 error
1481 .source()
1482 .unwrap()
1483 .downcast_ref::<std::io::Error>()
1484 .unwrap()
1485 .raw_os_error(),
1486 Some(9)
1487 );
1488 }
1489 let displayed = error.to_string();
1490 assert!(displayed.starts_with(&primary_text));
1491 for marker in [
1492 "original-stdout",
1493 "original-stderr",
1494 "command cleanup also failed",
1495 "server cleanup also failed",
1496 ] {
1497 assert!(displayed.contains(marker));
1498 }
1499 }
1500 let error = PocketIcStartupError::new(PocketIcStartupFailure::NotConfigured);
1501 assert_eq!(error.to_string(), error.failure().to_string());
1502 assert!(error.command_cleanup().is_none());
1503 assert!(error.server_cleanup().is_none());
1504 }
1505
1506 #[cfg(unix)]
1507 #[test]
1508 fn builder_failures_finalize_the_owned_server_and_keep_output() {
1509 for disconnected in [false, true] {
1510 let script = TestServerScript::new(
1511 "builder-failure",
1512 "#!/bin/sh\nprintf '%s\n%s\n' \"$$\" \"$2\"\nprintf 'builder-diagnostic' >&2\nprintf '34567\n' > \"$2\"\nexec sleep 30\n",
1513 );
1514 let managed = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
1515 .start_managed_server()
1516 .unwrap();
1517 let pid = managed.process_id();
1518 let directory = managed.server.files.directory.clone();
1519 let (sender, receiver) = mpsc::channel();
1520 if !disconnected {
1521 sender
1522 .send(Err("original-builder-panic".to_owned()))
1523 .unwrap();
1524 }
1525 drop(sender);
1526 let error = super::await_builder(
1527 receiver,
1528 "http://127.0.0.1:34567/",
1529 Instant::now() + Duration::from_secs(2),
1530 Duration::from_secs(2),
1531 &mut Some(managed.server),
1532 )
1533 .err()
1534 .unwrap();
1535 assert!(matches!(
1536 (disconnected, error.failure()),
1537 (true, PocketIcStartupFailure::BuilderDisconnected)
1538 | (false, PocketIcStartupFailure::BuilderPanicked { .. })
1539 ));
1540 assert_eq!(error.output().stderr(), "builder-diagnostic");
1541 assert!(error.server_cleanup().is_none());
1542 assert!(!directory.exists());
1543 assert_eq!(process_state(pid), None);
1544 }
1545 }
1546
1547 #[cfg(unix)]
1548 #[test]
1549 fn port_io_and_command_spawn_failures_finalize_the_owned_server() {
1550 for port_io in [true, false] {
1551 let port = if port_io {
1552 "mkfifo \"$2\""
1553 } else {
1554 "printf '34567\n' > \"$2\""
1555 };
1556 let script = TestServerScript::new(
1557 "owned-io-failure",
1558 &format!(
1559 "#!/bin/sh\nprintf '%s\n%s\n' \"$$\" \"$2\"\nprintf 'original-server-diagnostic' >&2\n{port}\nexec sleep 30\n"
1560 ),
1561 );
1562 let config = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2));
1563 let error = if port_io {
1564 config.start_managed_server().err().unwrap()
1565 } else {
1566 config
1567 .run_command(&mut Command::new("/missing/ic-testkit-command"), || false)
1568 .unwrap_err()
1569 };
1570 let PocketIcStartupFailure::Io { source, .. } = error.failure() else {
1571 panic!("expected original IO failure: {error:?}");
1572 };
1573 assert_eq!(
1574 source.kind(),
1575 if port_io {
1576 std::io::ErrorKind::InvalidData
1577 } else {
1578 std::io::ErrorKind::NotFound
1579 }
1580 );
1581 assert_eq!(error.output().stderr(), "original-server-diagnostic");
1582 let mut lines = error.output().stdout().lines();
1583 let pid = lines.next().unwrap().parse().unwrap();
1584 let port = PathBuf::from(lines.next().unwrap());
1585 assert_eq!(process_state(pid), None);
1586 assert!(!port.parent().unwrap().exists());
1587 assert!(error.server_cleanup().is_none());
1588 }
1589 }
1590
1591 #[cfg(unix)]
1592 #[test]
1593 fn command_cancellation_before_startup_has_no_spawn_effects() {
1594 let error = PocketIcStartupConfig::spawn("/missing/server", Duration::from_secs(1))
1595 .run_command(&mut Command::new("/missing/command"), || true)
1596 .unwrap_err();
1597 assert!(
1598 matches!(error.failure(), PocketIcStartupFailure::CommandRun { source, .. } if source.kind() == std::io::ErrorKind::Interrupted)
1599 );
1600 assert_eq!(error.output().stdout(), "");
1601 assert_eq!(error.output().stderr(), "");
1602 assert!(error.command_cleanup().is_none());
1603 assert!(error.server_cleanup().is_none());
1604 }
1605
1606 #[cfg(unix)]
1607 #[test]
1608 fn cancellation_callback_panic_still_reaps_the_owned_command() {
1609 let script = TestServerScript::new(
1610 "cancel-panic",
1611 "#!/bin/sh\nprintf '%s' \"$$\" > \"$1\"\nexec sleep 30\n",
1612 );
1613 let pid_file = script.path().with_extension("pid");
1614 let result = std::panic::catch_unwind(|| {
1615 PocketIcStartupConfig::connect("http://127.0.0.1:12345/", Duration::from_secs(1))
1616 .run_command(Command::new(script.path()).arg(&pid_file), || {
1617 assert!(
1618 !fs::read_to_string(&pid_file).is_ok_and(|value| !value.is_empty()),
1619 "caller cancellation failed"
1620 );
1621 false
1622 })
1623 });
1624 assert!(result.is_err());
1625 let pid = fs::read_to_string(&pid_file).unwrap().parse().unwrap();
1626 assert!(process_state(pid).is_none_or(|state| state == 'Z'));
1627 fs::remove_file(pid_file).unwrap();
1628 }
1629
1630 #[cfg(unix)]
1631 #[test]
1632 fn environment_selection_prefers_urls_and_fails_closed() {
1633 use std::os::unix::ffi::OsStringExt as _;
1634
1635 let timeout = Duration::from_secs(1);
1636 let config = PocketIcStartupConfig::from_environment(timeout, |name| {
1637 Some(
1638 if name == "IC_TESTKIT_POCKET_IC_URL" {
1639 "http://127.0.0.1:12345/"
1640 } else {
1641 "/missing/server"
1642 }
1643 .into(),
1644 )
1645 })
1646 .unwrap();
1647 assert_eq!(config.server_url(), Some("http://127.0.0.1:12345/"));
1648 assert!(config.server_binary().is_none());
1649 assert!(matches!(
1650 PocketIcStartupConfig::from_environment(timeout, |_| None)
1651 .as_ref()
1652 .map_err(PocketIcStartupError::failure),
1653 Err(PocketIcStartupFailure::NotConfigured)
1654 ));
1655 assert!(matches!(
1656 PocketIcStartupConfig::from_environment(timeout, |_| Some("".into()))
1657 .as_ref()
1658 .map_err(PocketIcStartupError::failure),
1659 Err(PocketIcStartupFailure::InvalidEnvironment {
1660 variable: "IC_TESTKIT_POCKET_IC_URL"
1661 })
1662 ));
1663 assert!(matches!(
1664 PocketIcStartupConfig::from_environment(timeout, |_| Some("bad URL".into()))
1665 .as_ref()
1666 .map_err(PocketIcStartupError::failure),
1667 Err(PocketIcStartupFailure::InvalidServerUrl { .. })
1668 ));
1669 assert!(matches!(
1670 PocketIcStartupConfig::from_environment(timeout, |_| Some(
1671 std::ffi::OsString::from_vec(vec![0xff])
1672 ))
1673 .as_ref()
1674 .map_err(PocketIcStartupError::failure),
1675 Err(PocketIcStartupFailure::InvalidEnvironment { .. })
1676 ));
1677 }
1678
1679 #[cfg(unix)]
1680 #[test]
1681 fn version_capture_cleans_wrapper_descendants_on_exit_and_timeout() {
1682 for timeout in [false, true] {
1683 let qualified = format!(
1684 "printf 'pocket-ic-server {}\\n'",
1685 pocket_ic::LATEST_SERVER_VERSION
1686 );
1687 let ending = if timeout { "wait" } else { &qualified };
1688 let script = TestServerScript::new(
1689 "version-group",
1690 &format!(
1691 "#!/bin/sh\nsleep 30 >/dev/null 2>&1 &\nprintf '%s' \"$!\" > \"$0.pid\"\n{ending}\n"
1692 ),
1693 );
1694 let result = PocketIcStartupConfig::from_environment(Duration::from_secs(1), |name| {
1695 (name == "POCKET_IC_BIN").then(|| script.path().into_os_string())
1696 });
1697 if timeout {
1698 assert!(matches!(
1699 result.as_ref().map_err(PocketIcStartupError::failure),
1700 Err(PocketIcStartupFailure::ServerVersionProbe { .. })
1701 ));
1702 } else {
1703 assert!(result.is_ok());
1704 }
1705 let pid_file = script.path().with_extension("pid");
1706 let pid = fs::read_to_string(&pid_file).unwrap().parse().unwrap();
1707 let deadline = Instant::now() + Duration::from_secs(3);
1708 while process_state(pid).is_some_and(|state| state != 'Z') {
1709 assert!(
1710 Instant::now() < deadline,
1711 "version wrapper descendant remained alive"
1712 );
1713 std::thread::sleep(Duration::from_millis(20));
1714 }
1715 fs::remove_file(pid_file).unwrap();
1716 }
1717 }
1718
1719 #[cfg(unix)]
1720 #[test]
1721 fn owned_builder_panic_retains_server_output_and_reaps_child() {
1722 let script = TestServerScript::new(
1723 "builder-panic",
1724 "#!/bin/sh\nprintf '%s' \"$$\" > \"$0.pid\"\nprintf 'builder stdout'\nprintf 'builder stderr' >&2\nprintf '34567\\n' > \"$2\"\nexec sleep 30\n",
1725 );
1726 let error = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
1727 .build_instance(PocketIcBuilder::new())
1728 .err()
1729 .unwrap();
1730 let PocketIcStartupFailure::BuilderPanicked { message } = error.failure() else {
1731 panic!("expected builder failure, got {error:?}");
1732 };
1733 assert_ne!(message, "");
1734 assert_eq!(error.output().stdout(), "builder stdout");
1735 assert_eq!(error.output().stderr(), "builder stderr");
1736 assert!(error.server_cleanup().is_none());
1737 let pid_file = script.path().with_extension("pid");
1738 let pid = fs::read_to_string(&pid_file).unwrap().parse().unwrap();
1739 assert_eq!(process_state(pid), None);
1740 fs::remove_file(pid_file).unwrap();
1741 }
1742
1743 #[cfg(unix)]
1744 #[test]
1745 fn environment_binary_selection_uses_bounded_shared_version_capture() {
1746 let qualified = format!(
1747 "printf 'pocket-ic-server {}\\n'",
1748 pocket_ic::LATEST_SERVER_VERSION
1749 );
1750 let failed = format!("{qualified}; exit 23");
1751 for (label, body, expected) in [
1752 ("qualified", qualified.as_str(), 0),
1753 ("compatible-16.0", "printf 'pocket-ic-server 16.0.0\\n'", 0),
1754 ("compatible-patch", "printf 'pocket-ic-server 16.0.1\\n'", 0),
1755 ("wrong-version", "printf 'pocket-ic-server 15.0.0\\n'", 1),
1756 ("future-major", "printf 'pocket-ic-server 17.0.0\\n'", 1),
1757 ("malformed-version", "printf 'pocket-ic-server 16.1\\n'", 1),
1758 ("failed-version", failed.as_str(), 2),
1759 ("invalid-utf8", "printf '\\377'", 1),
1760 ("version-timeout", "exec sleep 30", 2),
1761 ] {
1762 let script = TestServerScript::new(
1763 label,
1764 &format!("#!/bin/sh\n[ \"$1\" = --version ] || exit 99\n{body}\n"),
1765 );
1766 let result =
1767 PocketIcStartupConfig::from_environment(Duration::from_millis(200), |name| {
1768 (name == "POCKET_IC_BIN").then(|| script.path().into_os_string())
1769 });
1770 match (
1771 expected,
1772 result.as_ref().map_err(PocketIcStartupError::failure),
1773 ) {
1774 (0, Ok(config)) => {
1775 let binary = script.path().canonicalize().unwrap();
1776 assert_eq!(config.server_binary(), Some(binary.as_path()));
1777 }
1778 (1, Err(PocketIcStartupFailure::ServerVersionMismatch { .. }))
1779 | (2, Err(PocketIcStartupFailure::ServerVersionProbe { .. })) => {}
1780 (_, result) => panic!("unexpected {label} result: {result:?}"),
1781 }
1782 }
1783 }
1784
1785 #[cfg(unix)]
1786 fn process_state(pid: u32) -> Option<char> {
1787 let output = Command::new("/bin/ps")
1790 .args(["-p", &pid.to_string(), "-o", "stat="])
1791 .output()
1792 .expect("inspect managed test process state");
1793 assert!(
1794 output.status.success()
1795 || (output.status.code() == Some(1)
1796 && output.stdout.is_empty()
1797 && output.stderr.is_empty()),
1798 "process-state inspection failed: {}: {}",
1799 output.status,
1800 String::from_utf8_lossy(&output.stderr),
1801 );
1802 String::from_utf8(output.stdout)
1803 .expect("process state is ASCII")
1804 .trim()
1805 .chars()
1806 .next()
1807 }
1808
1809 #[cfg(unix)]
1810 #[test]
1811 fn reading_large_sparse_server_output_is_bounded() {
1812 let (files, _, _) = StartupFiles::create(None).expect("allocate startup files");
1813 let mut file = fs::File::create(&files.stdout).expect("create sparse log");
1814 file.write_all(b"server started\n").expect("write prefix");
1815 let size = 8_u64 * 1024 * 1024 * 1024;
1816 file.set_len(size).expect("extend sparse log");
1817 let output = super::read_bounded_lossy(&files.stdout);
1818 assert!(output.starts_with("server started\n"));
1819 assert!(output.ends_with(&format!(
1820 "<truncated {} bytes>",
1821 size - super::SERVER_OUTPUT_LIMIT as u64
1822 )));
1823 assert!(output.len() < super::SERVER_OUTPUT_LIMIT + 100);
1824 }
1825
1826 #[cfg(unix)]
1827 #[test]
1828 fn startup_readers_reject_fifos_without_waiting_for_a_writer() {
1829 let (files, stdout, stderr) = StartupFiles::create(None).expect("allocate startup files");
1830 drop((stdout, stderr));
1831 fs::remove_file(&files.stdout).unwrap();
1832 fs::remove_file(&files.stderr).unwrap();
1833 assert!(
1834 Command::new("mkfifo")
1835 .args([&files.port, &files.stdout, &files.stderr])
1836 .status()
1837 .expect("create FIFO startup files")
1838 .success()
1839 );
1840 let server = super::ManagedServer {
1841 child: None,
1842 binary: PathBuf::from("unused-server"),
1843 files,
1844 started: Instant::now(),
1845 };
1846 for path in [
1847 &server.files.port,
1848 &server.files.stdout,
1849 &server.files.stderr,
1850 ] {
1851 let fifo = path.clone();
1855 let (stop_writer, stopped) = mpsc::channel();
1856 let writer = std::thread::spawn(move || {
1857 loop {
1858 match stopped.recv_timeout(Duration::from_millis(200)) {
1859 Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return false,
1860 Err(mpsc::RecvTimeoutError::Timeout) => {}
1861 }
1862 if fs::OpenOptions::new()
1863 .write(true)
1864 .custom_flags(libc::O_NONBLOCK)
1865 .open(&fifo)
1866 .is_ok()
1867 {
1868 return true;
1869 }
1870 }
1871 });
1872 let result = if path == &server.files.port {
1873 Some(server.read_port())
1874 } else {
1875 assert_eq!(super::read_bounded_lossy(path), "");
1876 None
1877 };
1878 let _ = stop_writer.send(());
1879 assert!(
1880 !writer.join().expect("join delayed FIFO writer"),
1881 "startup reader waited for a writer: {}",
1882 path.display(),
1883 );
1884 if let Some(result) = result {
1885 assert!(matches!(
1886 result.as_ref().map_err(PocketIcStartupError::failure),
1887 Err(PocketIcStartupFailure::Io { source, .. })
1888 if source.kind() == std::io::ErrorKind::InvalidData
1889 ));
1890 }
1891 }
1892 }
1893
1894 #[test]
1895 fn port_file_readiness_preserves_partial_writes_and_rejects_oversized_contents() {
1896 let (files, _, _) = StartupFiles::create(None).expect("allocate startup files");
1897 let server = super::ManagedServer {
1898 child: None,
1899 binary: PathBuf::from("unused-server"),
1900 files,
1901 started: Instant::now(),
1902 };
1903 assert!(matches!(
1904 server.read_port().unwrap(),
1905 super::PortFileState::Pending
1906 ));
1907 for contents in ["", "34567"] {
1908 fs::write(&server.files.port, contents).unwrap();
1909 assert!(matches!(
1910 server.read_port().unwrap(),
1911 super::PortFileState::Pending
1912 ));
1913 }
1914 for (contents, expected) in [("1\n", 1), ("65535\n", 65535), (" 34567\r\n", 34567)] {
1915 fs::write(&server.files.port, contents).unwrap();
1916 assert!(matches!(
1917 server.read_port().unwrap(),
1918 super::PortFileState::Ready(port) if port == expected
1919 ));
1920 }
1921 for contents in ["0\n", "65536\n", "invalid\n", "1\n2\n"] {
1922 fs::write(&server.files.port, contents).unwrap();
1923 assert!(matches!(
1924 server.read_port().unwrap(),
1925 super::PortFileState::Invalid(_)
1926 ));
1927 }
1928 fs::write(&server.files.port, [0xff, b'\n']).unwrap();
1929 assert!(matches!(
1930 server.read_port().as_ref().map_err(PocketIcStartupError::failure),
1931 Err(PocketIcStartupFailure::Io { source, .. })
1932 if source.kind() == std::io::ErrorKind::InvalidData
1933 ));
1934 for contents in ["1\n".to_owned() + &" ".repeat(128), "0".repeat(128)] {
1935 fs::write(&server.files.port, contents).unwrap();
1936 assert!(
1937 matches!(
1938 server.read_port().unwrap(),
1939 super::PortFileState::Invalid(_)
1940 ),
1941 "oversized port contents must fail even without a newline",
1942 );
1943 }
1944 fs::File::options()
1946 .write(true)
1947 .open(&server.files.port)
1948 .unwrap()
1949 .set_len(1024 * 1024)
1950 .unwrap();
1951 assert!(matches!(
1952 server.read_port().unwrap(),
1953 super::PortFileState::Invalid(value) if value.len() < 256
1954 ));
1955 }
1956
1957 #[test]
1958 fn startup_config_requires_positive_bounds() {
1959 for config in [
1960 PocketIcStartupConfig::spawn("pocket-ic", Duration::from_secs(1))
1961 .with_server_idle_ttl(Duration::from_millis(1)),
1962 PocketIcStartupConfig::connect("http://127.0.0.1:1/", Duration::from_secs(1))
1963 .with_server_idle_ttl(Duration::from_secs(120)),
1964 ] {
1965 assert!(matches!(
1966 config
1967 .validate()
1968 .as_ref()
1969 .map_err(PocketIcStartupError::failure),
1970 Err(PocketIcStartupFailure::InvalidConfiguration { .. })
1971 ));
1972 }
1973 let error = PocketIcStartupConfig::connect("http://127.0.0.1:1/", Duration::ZERO)
1974 .validate()
1975 .expect_err("zero startup timeout must fail");
1976 assert!(matches!(
1977 error.failure(),
1978 PocketIcStartupFailure::InvalidConfiguration { .. }
1979 ));
1980
1981 let error = PocketIcStartupConfig::spawn("pocket-ic", Duration::from_secs(1))
1982 .with_server_hard_ttl(Duration::from_millis(1))
1983 .validate()
1984 .expect_err("subsecond server hard TTL must fail");
1985 assert!(matches!(
1986 error.failure(),
1987 PocketIcStartupFailure::InvalidConfiguration { .. }
1988 ));
1989 }
1990
1991 #[test]
1992 fn managed_server_hard_ttl_is_opt_in() {
1993 let default = PocketIcStartupConfig::spawn("pocket-ic", Duration::from_secs(1));
1994 assert_eq!(default.server_idle_ttl(), None);
1995 assert_eq!(
1996 default
1997 .clone()
1998 .with_server_idle_ttl(Duration::from_secs(120))
1999 .server_idle_ttl(),
2000 Some(Duration::from_secs(120))
2001 );
2002 assert_eq!(default.server_hard_ttl(), None);
2003
2004 let explicit = default.with_server_hard_ttl(Duration::from_secs(17));
2005 assert_eq!(explicit.server_hard_ttl(), Some(Duration::from_secs(17)));
2006 }
2007
2008 #[test]
2009 fn startup_files_leave_the_server_owned_port_path_absent() {
2010 let (files, stdout, stderr) = StartupFiles::create(None).expect("allocate startup files");
2011 let directory = files.directory.clone();
2012
2013 assert!(directory.is_dir());
2014 assert!(!files.port.exists());
2015 assert!(files.stdout.is_file());
2016 assert!(files.stderr.is_file());
2017 #[cfg(unix)]
2018 {
2019 let mode = fs::metadata(&directory)
2020 .expect("inspect private startup directory")
2021 .permissions()
2022 .mode();
2023 assert_eq!(mode & 0o077, 0);
2024 }
2025
2026 drop(stdout);
2027 drop(stderr);
2028 drop(files);
2029 assert!(!directory.exists());
2030 }
2031
2032 #[cfg(unix)]
2033 #[test]
2034 fn caller_output_files_survive_startup_command_and_cancellation_cleanup() {
2035 for outcome in [
2036 "timeout",
2037 "startup-exit",
2038 "server-exit",
2039 "command-exit",
2040 "cancel",
2041 "success",
2042 ] {
2043 let (owner, _, _) = StartupFiles::create(None).unwrap();
2044 let stdout = owner.directory.join("retained-stdout");
2045 let stderr = owner.directory.join("retained-stderr");
2046 let ending = match outcome {
2047 "timeout" => "exec sleep 30",
2048 "startup-exit" => "exit 41",
2049 "server-exit" => {
2050 "printf '34567\\n' > \"$2\"; while [ ! -s \"$0.command\" ]; do sleep 0.02; done; exit 42"
2051 }
2052 _ => "printf '34567\\n' > \"$2\"; exec sleep 30",
2053 };
2054 let script = TestServerScript::new(
2055 outcome,
2056 &format!(
2057 "#!/bin/sh\nprintf '%s\\n%s\\n' \"$$\" \"$2\" > \"$0.pid\"\ndd if=/dev/zero bs=1024 count=20 2>/dev/null\nprintf raw-stdout-end\ndd if=/dev/zero bs=1024 count=20 >&2 2>/dev/null\nprintf raw-stderr-end >&2\n{ending}\n"
2058 ),
2059 );
2060 let pid_file = script.path().with_extension("pid");
2061 let command_file = script.path().with_extension("command");
2062 let config = PocketIcStartupConfig::spawn(
2063 script.path(),
2064 Duration::from_millis(if outcome == "timeout" { 1000 } else { 2000 }),
2065 )
2066 .with_server_output_files(&stdout, &stderr);
2067 if outcome == "timeout" || outcome == "startup-exit" {
2068 let error = config.start_managed_server().err().unwrap();
2069 match (outcome, error.failure()) {
2070 ("timeout", PocketIcStartupFailure::ReadinessTimeout { .. }) => {
2071 assert!(error.output().stdout().contains("truncated"));
2072 assert!(!error.output().stderr().contains("raw-stderr-end"));
2073 }
2074 ("startup-exit", PocketIcStartupFailure::ServerExited { status, .. }) => {
2075 assert_eq!(status.code(), Some(41));
2076 }
2077 (_, error) => panic!("unexpected startup result: {error:?}"),
2078 }
2079 } else {
2080 let command_end = match outcome {
2081 "command-exit" => "exit 37",
2082 "success" => "exit 0",
2083 _ => "exec sleep 30",
2084 };
2085 let result = config.run_command(
2086 Command::new("/bin/sh")
2087 .args([
2088 "-c",
2089 &format!("printf '%s' \"$$\" > \"$1\"; {command_end}"),
2090 "fixture",
2091 ])
2092 .arg(&command_file),
2093 || {
2094 outcome == "cancel"
2095 && fs::metadata(&command_file).is_ok_and(|m| m.len() > 0)
2096 },
2097 );
2098 let result = result.as_ref().map_err(PocketIcStartupError::failure);
2099 match (outcome, result) {
2100 ("command-exit", Ok(status)) => assert_eq!(status.code(), Some(37)),
2101 ("success", Ok(status)) => assert!(status.success()),
2102 ("server-exit", Err(PocketIcStartupFailure::ServerExited { status, .. })) => {
2103 assert_eq!(status.code(), Some(42));
2104 }
2105 ("cancel", Err(PocketIcStartupFailure::CommandRun { source, .. })) => {
2106 assert_eq!(source.kind(), std::io::ErrorKind::Interrupted);
2107 }
2108 (_, result) => panic!("unexpected command result: {result:?}"),
2109 }
2110 let pid = fs::read_to_string(&command_file).unwrap().parse().unwrap();
2111 assert!(process_state(pid).is_none_or(|state| state == 'Z'));
2112 fs::remove_file(command_file).unwrap();
2113 }
2114 for (path, suffix) in [(&stdout, b"raw-stdout-end"), (&stderr, b"raw-stderr-end")] {
2115 let bytes = fs::read(path).unwrap();
2116 assert_eq!(bytes.len(), 20 * 1024 + suffix.len());
2117 assert!(bytes.ends_with(suffix));
2118 assert_eq!(
2119 fs::metadata(path).unwrap().permissions().mode() & 0o777,
2120 0o600
2121 );
2122 }
2123 let report = fs::read_to_string(&pid_file).unwrap();
2124 let mut lines = report.lines();
2125 assert_eq!(process_state(lines.next().unwrap().parse().unwrap()), None);
2126 assert!(
2127 !PathBuf::from(lines.next().unwrap())
2128 .parent()
2129 .unwrap()
2130 .exists()
2131 );
2132 fs::remove_file(pid_file).unwrap();
2133 }
2134 }
2135
2136 #[cfg(unix)]
2137 #[test]
2138 fn caller_output_files_refuse_existing_entries_and_keep_partial_preparation() {
2139 use std::os::unix::fs::symlink;
2140
2141 let (owner, _, _) = StartupFiles::create(None).unwrap();
2142 let stdout = owner.directory.join("retained-stdout");
2143 let stderr = owner.directory.join("retained-stderr");
2144 let unrelated = owner.directory.join("unrelated");
2145 fs::write(&unrelated, b"original").unwrap();
2146 symlink(&unrelated, &stderr).unwrap();
2147 let error = StartupFiles::create(Some((stdout.clone(), stderr.clone())))
2148 .err()
2149 .unwrap();
2150 assert!(
2151 matches!(error.failure(), PocketIcStartupFailure::Io { source, .. } if source.kind() == std::io::ErrorKind::AlreadyExists)
2152 );
2153 assert!(stdout.is_file());
2154 assert_eq!(fs::read(&unrelated).unwrap(), b"original");
2155 fs::write(&stdout, b"retained attempt").unwrap();
2156 assert!(StartupFiles::create(Some((stdout.clone(), stderr.clone()))).is_err());
2157 assert_eq!(fs::read(&stdout).unwrap(), b"retained attempt");
2158 fs::remove_file(stderr.clone()).unwrap();
2159 assert!(
2160 Command::new("mkfifo")
2161 .arg(&stderr)
2162 .status()
2163 .unwrap()
2164 .success()
2165 );
2166 fs::remove_file(stdout.clone()).unwrap();
2167 assert!(StartupFiles::create(Some((stdout.clone(), stderr.clone()))).is_err());
2168 assert!(stdout.is_file());
2169 let error =
2170 PocketIcStartupConfig::connect("http://127.0.0.1:12345/", Duration::from_secs(1))
2171 .with_server_output_files(&stdout, &stderr)
2172 .run_command(&mut Command::new("/missing/command"), || false)
2173 .unwrap_err();
2174 assert!(matches!(
2175 error.failure(),
2176 PocketIcStartupFailure::InvalidConfiguration { .. }
2177 ));
2178 }
2179
2180 #[cfg(unix)]
2181 #[test]
2182 fn managed_startup_reports_an_exited_server_with_bounded_output() {
2183 let script = TestServerScript::new(
2184 "exit",
2185 "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nprintf 'synthetic server stdout'\nprintf 'synthetic bind failure' >&2\nexit 23\n",
2186 );
2187
2188 let result = PocketIcBuilder::new().with_application_subnet().try_build(
2189 PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2)),
2190 );
2191
2192 let error = result.err().unwrap();
2193 let PocketIcStartupFailure::ServerExited {
2194 server_binary,
2195 status,
2196 ..
2197 } = error.failure()
2198 else {
2199 panic!("expected a structured server exit, got {error:?}");
2200 };
2201 assert_eq!(server_binary, &script.path());
2202 assert_eq!(status.code(), Some(23));
2203 assert_eq!(error.output().stdout(), "synthetic server stdout");
2204 assert_eq!(error.output().stderr(), "synthetic bind failure");
2205 }
2206
2207 #[cfg(unix)]
2208 #[test]
2209 fn managed_startup_rejects_oversized_port_files_and_cleans_up() {
2210 let script = TestServerScript::new(
2211 "oversized-port",
2212 "#!/bin/sh\nprintf '%s\\n%s\\n' \"$$\" \"$2\"\nprintf '34567\\n%064s' '' > \"$2.pending\"\nmv \"$2.pending\" \"$2\"\nexec sleep 30\n",
2213 );
2214 let result = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
2215 .start_managed_server();
2216 let error = result.err().unwrap();
2217 let PocketIcStartupFailure::InvalidServerPort { value, .. } = error.failure() else {
2218 panic!("oversized port publication must fail readiness");
2219 };
2220 assert!(value.contains("port file exceeds"));
2221 assert!(value.len() < 256);
2222 let mut lines = error.output().stdout().lines();
2223 let pid = lines.next().unwrap().parse::<u32>().unwrap();
2224 let port_path = PathBuf::from(lines.next().unwrap());
2225 assert!(!port_path.parent().unwrap().exists());
2226 assert_eq!(process_state(pid), None, "failed server must be reaped");
2227 }
2228
2229 #[cfg(unix)]
2230 #[test]
2231 fn managed_startup_terminates_a_server_that_never_becomes_ready() {
2232 let script = TestServerScript::new(
2233 "timeout",
2234 "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nexec sleep 30\n",
2235 );
2236 let timeout = Duration::from_millis(100);
2237 let started = Instant::now();
2238
2239 let result = PocketIcBuilder::new()
2240 .with_application_subnet()
2241 .try_build(PocketIcStartupConfig::spawn(script.path(), timeout));
2242
2243 assert!(
2244 started.elapsed() < Duration::from_secs(2),
2245 "bounded startup should not wait for the sleeping child"
2246 );
2247 let error = result.err().unwrap();
2248 assert!(
2249 matches!(error.failure(), PocketIcStartupFailure::ReadinessTimeout {
2250 server_binary, timeout: actual_timeout,
2251 } if server_binary == &script.path() && *actual_timeout == timeout)
2252 );
2253 assert!(error.server_cleanup().is_none());
2254 }
2255
2256 #[cfg(unix)]
2257 #[test]
2258 fn managed_server_handle_exposes_process_id_url_output_and_raii_ownership() {
2259 let script = TestServerScript::new(
2260 "handle",
2261 "#!/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",
2262 );
2263
2264 let server = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
2265 .start_managed_server()
2266 .expect("start caller-owned managed server");
2267
2268 assert_eq!(server.url(), "http://127.0.0.1:34567/");
2269 assert_eq!(
2270 server.output().stdout(),
2271 format!("managed server ready: {}", server.process_id())
2272 );
2273 assert_eq!(server.output().stderr(), "");
2274 let pid = server.process_id();
2275 assert!(process_state(pid).is_some_and(|state| state != 'Z'));
2276 drop(server);
2277 assert_eq!(process_state(pid), None, "owned server must be reaped");
2278 }
2279
2280 #[cfg(unix)]
2281 #[test]
2282 fn managed_server_cleans_descendants_on_drop_timeout_exit_and_background_reap() {
2283 for mode in ["drop", "timeout", "exit", "background"] {
2284 let publish = if matches!(mode, "drop" | "background") {
2285 "printf '34567\\n' > \"$2\"\n"
2286 } else {
2287 ""
2288 };
2289 let finish = if mode == "background" {
2290 "while [ -e \"$2\" ]; do sleep 0.01; done\nexit 23\n"
2293 } else if mode == "exit" {
2294 "sleep 0.03\nexit 23\n"
2295 } else {
2296 "exec sleep 30\n"
2297 };
2298 let script = TestServerScript::new(
2299 mode,
2300 &format!("#!/bin/sh\nsleep 30 &\nprintf '%s' \"$!\"\n{publish}{finish}"),
2301 );
2302 let result = PocketIcStartupConfig::spawn(script.path(), Duration::from_millis(300))
2303 .start_managed_server();
2304 let output = match result {
2305 Ok(server) => {
2306 let output = server.output().stdout().to_owned();
2307 if mode == "background" {
2308 let port = server.server.files.port.clone();
2309 server.server.reap_in_background();
2310 fs::remove_file(port).expect("release the background server after handoff");
2311 } else {
2312 drop(server);
2313 }
2314 output
2315 }
2316 Err(error) => {
2317 match error.failure() {
2318 PocketIcStartupFailure::ReadinessTimeout { .. } => {
2319 assert_eq!(mode, "timeout");
2320 assert!(error.server_cleanup().is_none());
2321 }
2322 PocketIcStartupFailure::ServerExited { status, .. } => {
2323 assert_eq!(mode, "exit");
2324 assert_eq!(status.code(), Some(23));
2325 }
2326 _ => panic!("unexpected {mode} startup result: {error}"),
2327 }
2328 error.output().stdout().to_owned()
2329 }
2330 };
2331 let pid = output
2332 .parse::<u32>()
2333 .expect("server published its descendant PID");
2334 let deadline = Instant::now() + Duration::from_secs(2);
2335 while process_state(pid).is_some_and(|state| state != 'Z') {
2336 assert!(
2337 Instant::now() < deadline,
2338 "{mode} left its descendant running"
2339 );
2340 std::thread::sleep(Duration::from_millis(10));
2341 }
2342 }
2343 }
2344
2345 #[cfg(unix)]
2346 #[test]
2347 fn managed_server_passes_an_explicit_hard_ttl() {
2348 let script = TestServerScript::new(
2349 "hard-ttl",
2350 "#!/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",
2351 );
2352
2353 let server = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
2354 .with_server_hard_ttl(Duration::from_secs(17))
2355 .start_managed_server()
2356 .expect("start managed server with an explicit hard TTL");
2357
2358 assert_eq!(server.url(), "http://127.0.0.1:34567/");
2359 }
2360
2361 #[cfg(unix)]
2362 #[test]
2363 #[ignore = "requires POCKET_IC_BIN=<caller-provided PocketIC server binary>"]
2364 fn caller_provided_server_publishes_port_constructs_instance_and_cleans_up() {
2365 let binary = std::env::var_os("POCKET_IC_BIN")
2366 .map(PathBuf::from)
2367 .expect("set POCKET_IC_BIN to the exact server binary");
2368 let wrapper = TestServerScript::new(
2371 "real-reaper",
2372 "#!/bin/sh\nprintf '%s\n%s\n' \"$$\" \"$4\" > \"$0.record\"\nexec \"$POCKET_IC_BIN\" \"$@\"\n",
2373 );
2374 let one_shot = PocketIcBuilder::new()
2375 .with_application_subnet()
2376 .try_build(
2377 PocketIcStartupConfig::spawn(wrapper.path(), Duration::from_secs(30))
2378 .with_server_hard_ttl(Duration::from_secs(3)),
2379 )
2380 .expect("one-shot managed spawn must construct an instance");
2381 let record_file = wrapper.path().with_extension("record");
2382 let record = fs::read_to_string(&record_file).unwrap();
2383 let mut fields = record.lines();
2384 let pid = fields.next().unwrap().parse().unwrap();
2385 let port_path = PathBuf::from(fields.next().unwrap());
2386 let one_shot_directory = port_path.parent().unwrap();
2387 drop(one_shot);
2388 let cleanup_deadline = Instant::now() + Duration::from_secs(5);
2389 while one_shot_directory.exists() && Instant::now() < cleanup_deadline {
2390 std::thread::sleep(Duration::from_millis(20));
2391 }
2392 assert!(!one_shot_directory.exists());
2393 assert_eq!(process_state(pid), None);
2394 fs::remove_file(record_file).unwrap();
2395
2396 let server = PocketIcStartupConfig::spawn(&binary, Duration::from_secs(30))
2397 .with_server_hard_ttl(Duration::from_secs(60))
2398 .start_managed_server()
2399 .expect("caller-provided PocketIC server must publish its port");
2400 let files = &server.server.files;
2401 let startup_directory = files.directory.clone();
2402
2403 assert!(files.port.is_file());
2404 let pocket_ic = PocketIcBuilder::new()
2405 .with_application_subnet()
2406 .try_build(PocketIcStartupConfig::connect(
2407 server.url(),
2408 Duration::from_secs(30),
2409 ))
2410 .expect("construct instance through caller-provided server");
2411
2412 drop(pocket_ic);
2413 drop(server);
2414 assert!(!startup_directory.exists());
2415 }
2416
2417 #[cfg(unix)]
2418 struct TestServerScript {
2419 path: PathBuf,
2420 }
2421
2422 #[cfg(unix)]
2423 impl TestServerScript {
2424 fn new(label: &str, contents: &str) -> Self {
2425 let path = std::env::temp_dir().join(format!(
2426 "ic-testkit-pocket-ic-{label}-{}-{}",
2427 std::process::id(),
2428 super::STARTUP_FILE_SEQUENCE.fetch_add(1, super::Ordering::Relaxed),
2429 ));
2430 write_executable_script(&path, contents);
2431 Self { path }
2432 }
2433
2434 fn path(&self) -> PathBuf {
2435 self.path.clone()
2436 }
2437 }
2438
2439 #[cfg(unix)]
2440 impl Drop for TestServerScript {
2441 fn drop(&mut self) {
2442 let _ = fs::remove_file(&self.path);
2443 }
2444 }
2445}