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.child.id()
721 }
722
723 #[must_use]
725 pub fn url(&self) -> &str {
726 &self.url
727 }
728
729 #[must_use]
735 pub fn output(&self) -> PocketIcManagedServerOutput {
736 self.server.files.capture()
737 }
738}
739
740impl PocketIcManagedServerOutput {
741 #[must_use]
743 pub fn stdout(&self) -> &str {
744 &self.stdout
745 }
746
747 #[must_use]
749 pub fn stderr(&self) -> &str {
750 &self.stderr
751 }
752}
753
754impl PocketIcBuilderExt for PocketIcBuilder {
755 fn try_build(self, config: PocketIcStartupConfig) -> Result<PocketIc, PocketIcStartupError> {
756 let PocketIcInstance { instance, server } = config.build_instance(self)?;
757 if let Some(server) = server {
758 server.server.reap_in_background();
759 }
760 Ok(instance)
761 }
762}
763
764fn startup_deadline(started: Instant, timeout: Duration) -> Result<Instant, PocketIcStartupError> {
765 started.checked_add(timeout).ok_or_else(|| {
766 PocketIcStartupError::new(PocketIcStartupFailure::InvalidConfiguration {
767 message: "PocketIC startup timeout exceeds the platform clock range".to_owned(),
768 })
769 })
770}
771
772fn build_bounded(
773 builder: PocketIcBuilder,
774 server_url: &str,
775 deadline: Instant,
776 timeout: Duration,
777 mut server: Option<ManagedServer>,
778) -> Result<PocketIcInstance<PocketIc>, PocketIcStartupError> {
779 let builder = configured_builder(builder, server_url)
780 .map_err(|error| finalize_failure(error, None, server.take()))?;
781 let (sender, receiver) = mpsc::sync_channel(1);
782 if let Err(source) = thread::Builder::new()
783 .name("ic-testkit-pocket-ic-startup".to_owned())
784 .spawn(move || {
785 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| builder.build()))
786 .map_err(|payload| transport::panic_payload_to_string(payload.as_ref()));
787 let _ = sender.send(result);
788 })
789 {
790 return Err(finalize_failure(
791 PocketIcStartupError::new(PocketIcStartupFailure::BuilderThreadSpawn { source }),
792 None,
793 server,
794 ));
795 }
796 await_builder(receiver, server_url, deadline, timeout, &mut server)
797}
798
799fn await_builder(
800 receiver: mpsc::Receiver<Result<PocketIc, String>>,
801 server_url: &str,
802 deadline: Instant,
803 timeout: Duration,
804 server: &mut Option<ManagedServer>,
805) -> Result<PocketIcInstance<PocketIc>, PocketIcStartupError> {
806 loop {
807 let now = Instant::now();
808 if now >= deadline {
809 return Err(finalize_failure(
810 PocketIcStartupError::new(PocketIcStartupFailure::InstanceCreationTimeout {
811 timeout,
812 }),
813 None,
814 server.take(),
815 ));
816 }
817 let remaining = deadline.saturating_duration_since(now);
818 let wait = if server.is_some() {
819 remaining.min(STARTUP_POLL_INTERVAL)
820 } else {
821 remaining
822 };
823 match receiver.recv_timeout(wait) {
824 Ok(Ok(pocket_ic)) => {
825 check_server(server)?;
826 return Ok(PocketIcInstance {
827 instance: pocket_ic,
828 server: server.take().map(|server| PocketIcManagedServer {
829 server,
830 url: server_url.to_owned(),
831 }),
832 });
833 }
834 Ok(Err(message)) => {
835 return Err(finalize_failure(
836 PocketIcStartupError::new(PocketIcStartupFailure::BuilderPanicked { message }),
837 None,
838 server.take(),
839 ));
840 }
841 Err(RecvTimeoutError::Disconnected) => {
842 return Err(finalize_failure(
843 PocketIcStartupError::new(PocketIcStartupFailure::BuilderDisconnected),
844 None,
845 server.take(),
846 ));
847 }
848 Err(RecvTimeoutError::Timeout) => {
849 check_server(server)?;
850 }
851 }
852 }
853}
854
855fn configured_builder(
856 builder: PocketIcBuilder,
857 server_url: &str,
858) -> Result<PocketIcBuilder, PocketIcStartupError> {
859 server_url
860 .parse()
861 .map(|url| builder.with_server_url(url))
862 .map_err(|error| {
863 PocketIcStartupError::new(PocketIcStartupFailure::InvalidServerUrl {
864 server_url: server_url.to_owned(),
865 message: error.to_string(),
866 })
867 })
868}
869
870fn check_server(server: &mut Option<ManagedServer>) -> Result<(), PocketIcStartupError> {
871 if let Some(managed) = server.as_mut() {
872 let error = match managed.try_wait() {
873 Ok(Some(status)) => Some(managed.exit_failure(status)),
874 Ok(None) => None,
875 Err(error) => Some(error),
876 };
877 if let Some(error) = error {
878 return Err(finalize_failure(error, None, server.take()));
879 }
880 }
881 Ok(())
882}
883
884async fn build_async_bounded(
885 builder: PocketIcBuilder,
886 server_url: &str,
887 deadline: Instant,
888 timeout: Duration,
889 mut server: Option<ManagedServer>,
890) -> Result<PocketIcInstance<pocket_ic::nonblocking::PocketIc>, PocketIcStartupError> {
891 let builder = configured_builder(builder, server_url)
892 .map_err(|error| finalize_failure(error, None, server.take()))?;
893 let mut construction = std::pin::pin!(builder.build_async());
894 let mut construction = std::pin::pin!(std::future::poll_fn(|context| {
895 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
896 construction.as_mut().poll(context)
897 })) {
898 Ok(poll) => poll.map(Ok),
899 Err(payload) => std::task::Poll::Ready(Err(PocketIcStartupError::new(
900 PocketIcStartupFailure::BuilderPanicked {
901 message: transport::panic_payload_to_string(payload.as_ref()),
902 },
903 ))),
904 }
905 }));
906 let mut server = server;
908 loop {
909 let now = Instant::now();
910 if now >= deadline {
911 return Err(finalize_failure(
912 PocketIcStartupError::new(PocketIcStartupFailure::InstanceCreationTimeout {
913 timeout,
914 }),
915 None,
916 server,
917 ));
918 }
919 check_server(&mut server)?;
920 let next_check = if server.is_some() {
921 now + deadline
922 .saturating_duration_since(now)
923 .min(STARTUP_POLL_INTERVAL)
924 } else {
925 deadline
926 };
927 match tokio::time::timeout_at(next_check.into(), construction.as_mut()).await {
928 Ok(Ok(instance)) => {
929 if Instant::now() >= deadline {
930 return Err(finalize_failure(
931 PocketIcStartupError::new(
932 PocketIcStartupFailure::InstanceCreationTimeout { timeout },
933 ),
934 None,
935 server,
936 ));
937 }
938 check_server(&mut server)?;
939 return Ok(PocketIcInstance {
940 instance,
941 server: server.map(|server| PocketIcManagedServer {
942 server,
943 url: server_url.to_owned(),
944 }),
945 });
946 }
947 Ok(Err(error)) => return Err(finalize_failure(error, None, server)),
948 Err(_) => {}
949 }
950 }
951}
952
953fn finalize_failure(
955 error: PocketIcStartupError,
956 command: Option<&mut OwnedChild>,
957 server: Option<ManagedServer>,
958) -> PocketIcStartupError {
959 error.with_cleanup(
960 command.and_then(|child| child.terminate().err()),
961 server.map(ManagedServer::terminate_and_capture),
962 )
963}
964
965struct ManagedServer {
966 child: OwnedChild,
967 binary: PathBuf,
968 files: StartupFiles,
969 started: Instant,
970}
971
972enum PortFileState {
973 Pending,
974 Ready(u16),
975 Invalid(String),
976}
977
978impl ManagedServer {
979 fn start(
980 binary: PathBuf,
981 hard_ttl: Option<Duration>,
982 idle_ttl: Option<Duration>,
983 output_files: Option<(PathBuf, PathBuf)>,
984 deadline: Instant,
985 timeout: Duration,
986 started: Instant,
987 ) -> Result<(Self, String), PocketIcStartupError> {
988 let mut server = Self::spawn(binary, hard_ttl, idle_ttl, output_files, started)?;
989 loop {
990 match server.poll_ready(deadline, timeout) {
991 Ok(Some(url)) => return Ok((server, url)),
992 Ok(None) => thread::sleep(
993 deadline
994 .saturating_duration_since(Instant::now())
995 .min(STARTUP_POLL_INTERVAL),
996 ),
997 Err(error) => return Err(finalize_failure(error, None, Some(server))),
998 }
999 }
1000 }
1001
1002 fn spawn(
1003 binary: PathBuf,
1004 hard_ttl: Option<Duration>,
1005 idle_ttl: Option<Duration>,
1006 output_files: Option<(PathBuf, PathBuf)>,
1007 started: Instant,
1008 ) -> Result<Self, PocketIcStartupError> {
1009 let (files, stdout, stderr) = StartupFiles::create(output_files)?;
1010 let mut command = Command::new(&binary);
1011 if let Some(hard_ttl) = hard_ttl {
1012 command
1013 .arg("--hard-ttl")
1014 .arg(hard_ttl.as_secs().to_string());
1015 }
1016 if let Some(idle_ttl) = idle_ttl {
1017 command.arg("--ttl").arg(idle_ttl.as_secs().to_string());
1018 }
1019 command
1020 .arg("--port-file")
1021 .arg(&files.port)
1022 .stdout(Stdio::from(stdout))
1023 .stderr(Stdio::from(stderr));
1024 let child = OwnedChild::spawn(&mut command).map_err(|source| {
1025 PocketIcStartupError::new(PocketIcStartupFailure::ServerSpawn {
1026 server_binary: binary.clone(),
1027 source,
1028 })
1029 })?;
1030 Ok(Self {
1031 child,
1032 binary,
1033 files,
1034 started,
1035 })
1036 }
1037
1038 fn poll_ready(
1039 &mut self,
1040 deadline: Instant,
1041 timeout: Duration,
1042 ) -> Result<Option<String>, PocketIcStartupError> {
1043 if let Some(status) = self.try_wait()? {
1044 return Err(self.exit_failure(status));
1045 }
1046 if Instant::now() >= deadline {
1047 return Err(PocketIcStartupError::new(
1048 PocketIcStartupFailure::ReadinessTimeout {
1049 server_binary: self.binary.clone(),
1050 timeout,
1051 },
1052 ));
1053 }
1054 match self.files.read_port()? {
1055 PortFileState::Pending => Ok(None),
1056 PortFileState::Ready(port) => Ok(Some(format!("http://127.0.0.1:{port}/"))),
1057 PortFileState::Invalid(value) => Err(PocketIcStartupError::new(
1058 PocketIcStartupFailure::InvalidServerPort {
1059 server_binary: self.binary.clone(),
1060 value,
1061 },
1062 )),
1063 }
1064 }
1065
1066 fn try_wait(&mut self) -> Result<Option<ExitStatus>, PocketIcStartupError> {
1067 self.child.try_wait().map_err(|source| {
1068 PocketIcStartupError::new(PocketIcStartupFailure::Io {
1069 operation: "inspect PocketIC server child",
1070 path: self.binary.clone(),
1071 source,
1072 })
1073 })
1074 }
1075
1076 fn exit_failure(&self, status: ExitStatus) -> PocketIcStartupError {
1077 PocketIcStartupError::new(PocketIcStartupFailure::ServerExited {
1078 server_binary: self.binary.clone(),
1079 status,
1080 elapsed: self.started.elapsed(),
1081 })
1082 }
1083
1084 fn terminate_and_capture(self) -> (PocketIcManagedServerOutput, Option<CleanupError>) {
1085 let Self {
1086 mut child, files, ..
1087 } = self;
1088 let cleanup = child.terminate().err();
1089 drop(child);
1091 (files.capture(), cleanup)
1092 }
1093
1094 fn reap_in_background(self) {
1095 let _ = thread::Builder::new()
1096 .name("ic-testkit-pocket-ic-server-reaper".to_owned())
1097 .spawn(move || {
1098 let mut server = self;
1101 let _ = server.child.wait();
1102 });
1103 }
1104}
1105
1106struct StartupFiles {
1107 directory: PathBuf,
1108 port: PathBuf,
1109 stdout: PathBuf,
1110 stderr: PathBuf,
1111}
1112
1113impl StartupFiles {
1114 fn read_port(&self) -> Result<PortFileState, PocketIcStartupError> {
1115 let port_path = &self.port;
1116 let mut contents = String::new();
1117 match open_regular_startup_file(port_path).and_then(|file| {
1118 file.take((SERVER_PORT_FILE_LIMIT + 1) as u64)
1119 .read_to_string(&mut contents)
1120 }) {
1121 Ok(_) => {}
1122 Err(error) if error.kind() == io::ErrorKind::NotFound => {
1123 return Ok(PortFileState::Pending);
1124 }
1125 Err(source) => {
1126 return Err(PocketIcStartupError::new(PocketIcStartupFailure::Io {
1127 operation: "read PocketIC server port file",
1128 path: port_path.clone(),
1129 source,
1130 }));
1131 }
1132 }
1133 if contents.len() > SERVER_PORT_FILE_LIMIT {
1134 return Ok(PortFileState::Invalid(format!(
1135 "{} (port file exceeds {SERVER_PORT_FILE_LIMIT} bytes)",
1136 contents.trim()
1137 )));
1138 }
1139 if !contents.contains('\n') {
1140 return Ok(PortFileState::Pending);
1141 }
1142 let value = contents.trim().to_owned();
1143 match value.parse::<u16>() {
1144 Ok(port) if port != 0 => Ok(PortFileState::Ready(port)),
1145 _ => Ok(PortFileState::Invalid(value)),
1146 }
1147 }
1148
1149 fn capture(&self) -> PocketIcManagedServerOutput {
1150 PocketIcManagedServerOutput {
1151 stdout: read_bounded_lossy(&self.stdout),
1152 stderr: read_bounded_lossy(&self.stderr),
1153 }
1154 }
1155
1156 fn create(
1157 output_files: Option<(PathBuf, PathBuf)>,
1158 ) -> Result<(Self, File, File), PocketIcStartupError> {
1159 let output_files = output_files
1160 .map(|(stdout, stderr)| {
1161 Ok::<_, PocketIcStartupError>((
1162 std::path::absolute(&stdout)
1163 .map_err(|source| startup_file_error("resolve", &stdout, source))?,
1164 std::path::absolute(&stderr)
1165 .map_err(|source| startup_file_error("resolve", &stderr, source))?,
1166 ))
1167 })
1168 .transpose()?;
1169 loop {
1170 let sequence = STARTUP_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1171 let base = std::env::temp_dir().join(format!(
1172 "ic-testkit-pocket-ic-startup-{}-{sequence}",
1173 std::process::id()
1174 ));
1175 let mut directory = fs::DirBuilder::new();
1176 #[cfg(unix)]
1177 {
1178 directory.mode(0o700);
1179 }
1180 match directory.create(&base) {
1181 Ok(()) => {}
1182 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
1183 Err(source) => return Err(startup_file_error("create", &base, source)),
1184 }
1185 let (stdout_path, stderr_path) =
1186 output_files.unwrap_or_else(|| (base.join("stdout"), base.join("stderr")));
1187 let files = Self {
1188 port: base.join("port"),
1189 stdout: stdout_path,
1190 stderr: stderr_path,
1191 directory: base,
1192 };
1193 let stdout = create_new_file(&files.stdout)
1194 .map_err(|source| startup_file_error("create", &files.stdout, source))?;
1195 let stderr = create_new_file(&files.stderr)
1196 .map_err(|source| startup_file_error("create", &files.stderr, source))?;
1197 return Ok((files, stdout, stderr));
1198 }
1199 }
1200}
1201
1202impl Drop for StartupFiles {
1203 fn drop(&mut self) {
1204 let _ = fs::remove_dir_all(&self.directory);
1205 }
1206}
1207
1208fn create_new_file(path: &Path) -> io::Result<File> {
1209 let mut options = OpenOptions::new();
1210 options.write(true).create_new(true);
1211 #[cfg(unix)]
1212 options.mode(0o600);
1213 options.open(path)
1214}
1215
1216fn startup_file_error(
1217 operation: &'static str,
1218 path: &Path,
1219 source: io::Error,
1220) -> PocketIcStartupError {
1221 PocketIcStartupError::new(PocketIcStartupFailure::Io {
1222 operation,
1223 path: path.to_owned(),
1224 source,
1225 })
1226}
1227
1228fn read_bounded_lossy(path: &Path) -> String {
1229 let Ok(file) = open_regular_startup_file(path) else {
1230 return String::new();
1231 };
1232 let length = file.metadata().map_or(0, |metadata| metadata.len());
1233 let Ok(bytes) = ic_host_artifacts::artifact::read_reader(
1234 file.take(SERVER_OUTPUT_LIMIT as u64),
1235 SERVER_OUTPUT_LIMIT,
1236 ) else {
1237 return String::new();
1238 };
1239 let mut output = String::from_utf8_lossy(&bytes).into_owned();
1240 let omitted = length.saturating_sub(bytes.len() as u64);
1241 if omitted > 0 {
1242 let _ = write!(output, "\n<truncated {omitted} bytes>");
1243 }
1244 output
1245}
1246
1247fn open_regular_startup_file(path: &Path) -> io::Result<File> {
1248 let mut options = OpenOptions::new();
1249 options.read(true);
1250 #[cfg(unix)]
1253 options.custom_flags(libc::O_NONBLOCK);
1254 let file = options.open(path)?;
1255 if !file.metadata()?.is_file() {
1256 return Err(io::Error::new(
1257 io::ErrorKind::InvalidData,
1258 "PocketIC startup reader requires a regular file",
1259 ));
1260 }
1261 Ok(file)
1262}
1263
1264impl std::fmt::Display for PocketIcStartupFailure {
1265 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1266 match self {
1267 Self::NotConfigured => formatter.write_str("configure IC_TESTKIT_POCKET_IC_URL or POCKET_IC_BIN; prepare a verified binary with make install-server"),
1268 Self::InvalidEnvironment { variable } => write!(formatter, "invalid selected environment value: {variable}"),
1269 Self::ServerVersionProbe { source } => write!(formatter, "PocketIC version probe failed: {source}"),
1270 Self::ServerVersionMismatch { expected, observed } => write!(formatter, "PocketIC version mismatch: expected {expected:?}, observed {:?}", String::from_utf8_lossy(observed)),
1271 Self::CommandRun { program, source } => write!(formatter, "command {} failed: {source}", program.display()),
1272 Self::InvalidConfiguration { message } => formatter.write_str(message),
1273 Self::InvalidServerUrl {
1274 server_url,
1275 message,
1276 } => write!(
1277 formatter,
1278 "invalid PocketIC server URL {server_url:?}: {message}"
1279 ),
1280 Self::Io {
1281 operation,
1282 path,
1283 source,
1284 } => write!(
1285 formatter,
1286 "failed to {operation} at {}: {source}",
1287 path.display()
1288 ),
1289 Self::ServerSpawn {
1290 server_binary,
1291 source,
1292 } => write!(
1293 formatter,
1294 "failed to spawn PocketIC server {}: {source}",
1295 server_binary.display()
1296 ),
1297 Self::ServerExited {
1298 server_binary,
1299 status,
1300 elapsed,
1301 } => write!(
1302 formatter,
1303 "PocketIC server {} exited with {status} after {elapsed:?}",
1304 server_binary.display()
1305 ),
1306 Self::ReadinessTimeout {
1307 server_binary,
1308 timeout,
1309 } => write!(
1310 formatter,
1311 "PocketIC server {} was not ready within {timeout:?}",
1312 server_binary.display()
1313 ),
1314 Self::InvalidServerPort {
1315 server_binary,
1316 value,
1317 } => write!(
1318 formatter,
1319 "PocketIC server {} published invalid port {value:?}",
1320 server_binary.display()
1321 ),
1322 Self::InstanceCreationTimeout { timeout } => {
1323 write!(formatter, "PocketIC instance creation exceeded {timeout:?}")
1324 }
1325 Self::BuilderThreadSpawn { source } => {
1326 write!(
1327 formatter,
1328 "failed to spawn PocketIC builder worker: {source}"
1329 )
1330 }
1331 Self::BuilderPanicked { message } => {
1332 write!(formatter, "PocketIC startup panicked: {message}")
1333 }
1334 Self::ServerCleanup { command_status } => write!(formatter, "PocketIC server cleanup failed after command exited with {command_status}"),
1335 Self::BuilderDisconnected => {
1336 formatter.write_str("PocketIC builder worker disconnected without a result")
1337 }
1338 }
1339 }
1340}
1341
1342impl std::error::Error for PocketIcStartupFailure {
1343 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1344 match self {
1345 Self::ServerVersionProbe { source } => Some(source),
1346 Self::Io { source, .. }
1347 | Self::CommandRun { source, .. }
1348 | Self::ServerSpawn { source, .. }
1349 | Self::BuilderThreadSpawn { source } => Some(source),
1350 _ => None,
1351 }
1352 }
1353}
1354
1355impl std::fmt::Display for PocketIcStartupError {
1356 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1357 std::fmt::Display::fmt(self.failure.as_ref(), formatter)?;
1358 if !self.output.stdout.is_empty() {
1359 write!(formatter, "; server stdout: {}", self.output.stdout)?;
1360 }
1361 if !self.output.stderr.is_empty() {
1362 write!(formatter, "; server stderr: {}", self.output.stderr)?;
1363 }
1364 for (owner, cleanup) in [
1365 ("command", self.command_cleanup()),
1366 ("server", self.server_cleanup()),
1367 ] {
1368 if let Some(error) = cleanup {
1369 write!(formatter, "; {owner} cleanup also failed: {error}")?;
1370 }
1371 }
1372 Ok(())
1373 }
1374}
1375
1376impl std::error::Error for PocketIcStartupError {
1377 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1378 std::error::Error::source(self.failure.as_ref())
1379 }
1380}
1381
1382#[cfg(test)]
1383mod tests {
1384 mod instances;
1385
1386 use std::{
1387 fs,
1388 path::PathBuf,
1389 time::{Duration, Instant},
1390 };
1391
1392 use super::{
1393 PocketIcBuilderExt as _, PocketIcStartupConfig, PocketIcStartupError,
1394 PocketIcStartupFailure, StartupFiles,
1395 };
1396 use pocket_ic::PocketIcBuilder;
1397
1398 #[cfg(unix)]
1399 use crate::test_executable::write_executable_script;
1400 #[cfg(unix)]
1401 use std::{
1402 io::Write as _,
1403 os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _},
1404 process::Command,
1405 sync::mpsc,
1406 };
1407
1408 #[test]
1409 fn original_failure_output_and_both_typed_cleanup_reports_survive_projection() {
1410 use std::error::Error as _;
1411 for failure in [
1412 PocketIcStartupFailure::ReadinessTimeout {
1413 server_binary: "server".into(),
1414 timeout: Duration::from_secs(1),
1415 },
1416 PocketIcStartupFailure::InstanceCreationTimeout {
1417 timeout: Duration::from_secs(1),
1418 },
1419 PocketIcStartupFailure::InvalidServerPort {
1420 server_binary: "server".into(),
1421 value: "invalid".into(),
1422 },
1423 PocketIcStartupFailure::BuilderPanicked {
1424 message: "original-panic".into(),
1425 },
1426 PocketIcStartupFailure::BuilderDisconnected,
1427 PocketIcStartupFailure::BuilderThreadSpawn {
1428 source: std::io::Error::from_raw_os_error(9),
1429 },
1430 PocketIcStartupFailure::Io {
1431 operation: "original-io",
1432 path: "port".into(),
1433 source: std::io::Error::from_raw_os_error(9),
1434 },
1435 ] {
1436 let primary_text = failure.to_string();
1437 let output = super::PocketIcManagedServerOutput {
1438 stdout: "original-stdout".into(),
1439 stderr: "original-stderr".into(),
1440 };
1441 let command = super::CleanupError {
1442 status: None,
1443 group_error: None,
1444 term_error: Some(std::io::Error::from_raw_os_error(4)),
1445 kill_error: Some(std::io::Error::from_raw_os_error(5)),
1446 wait_error: None,
1447 };
1448 let server = super::CleanupError {
1449 status: None,
1450 group_error: Some(std::io::Error::from_raw_os_error(1)),
1451 term_error: None,
1452 kill_error: None,
1453 wait_error: Some(std::io::Error::from_raw_os_error(10)),
1454 };
1455 let error = PocketIcStartupError::new(failure)
1456 .with_cleanup(Some(command), Some((output, Some(server))));
1457 assert_eq!(error.failure().to_string(), primary_text);
1458 assert_eq!(error.output().stdout(), "original-stdout");
1459 assert_eq!(error.output().stderr(), "original-stderr");
1460 let command = error.command_cleanup().unwrap();
1461 let server = error.server_cleanup().unwrap();
1462 assert_eq!(command.kill_error.as_ref().unwrap().raw_os_error(), Some(5));
1463 assert_eq!(command.term_error.as_ref().unwrap().raw_os_error(), Some(4));
1464 assert_eq!(server.group_error.as_ref().unwrap().raw_os_error(), Some(1));
1465 assert_eq!(server.wait_error.as_ref().unwrap().raw_os_error(), Some(10));
1466 if matches!(
1467 error.failure(),
1468 PocketIcStartupFailure::Io { .. }
1469 | PocketIcStartupFailure::BuilderThreadSpawn { .. }
1470 ) {
1471 assert_eq!(
1472 error
1473 .source()
1474 .unwrap()
1475 .downcast_ref::<std::io::Error>()
1476 .unwrap()
1477 .raw_os_error(),
1478 Some(9)
1479 );
1480 }
1481 let displayed = error.to_string();
1482 assert!(displayed.starts_with(&primary_text));
1483 for marker in [
1484 "original-stdout",
1485 "original-stderr",
1486 "command cleanup also failed",
1487 "server cleanup also failed",
1488 ] {
1489 assert!(displayed.contains(marker));
1490 }
1491 }
1492 let error = PocketIcStartupError::new(PocketIcStartupFailure::NotConfigured);
1493 assert_eq!(error.to_string(), error.failure().to_string());
1494 assert!(error.command_cleanup().is_none());
1495 assert!(error.server_cleanup().is_none());
1496 }
1497
1498 #[cfg(unix)]
1499 #[test]
1500 fn builder_failures_finalize_the_owned_server_and_keep_output() {
1501 for disconnected in [false, true] {
1502 let script = TestServerScript::new(
1503 "builder-failure",
1504 "#!/bin/sh\nprintf '%s\n%s\n' \"$$\" \"$2\"\nprintf 'builder-diagnostic' >&2\nprintf '34567\n' > \"$2\"\nexec sleep 30\n",
1505 );
1506 let managed = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
1507 .start_managed_server()
1508 .unwrap();
1509 let pid = managed.process_id();
1510 let directory = managed.server.files.directory.clone();
1511 let (sender, receiver) = mpsc::channel();
1512 if !disconnected {
1513 sender
1514 .send(Err("original-builder-panic".to_owned()))
1515 .unwrap();
1516 }
1517 drop(sender);
1518 let error = super::await_builder(
1519 receiver,
1520 "http://127.0.0.1:34567/",
1521 Instant::now() + Duration::from_secs(2),
1522 Duration::from_secs(2),
1523 &mut Some(managed.server),
1524 )
1525 .err()
1526 .unwrap();
1527 assert!(matches!(
1528 (disconnected, error.failure()),
1529 (true, PocketIcStartupFailure::BuilderDisconnected)
1530 | (false, PocketIcStartupFailure::BuilderPanicked { .. })
1531 ));
1532 assert_eq!(error.output().stderr(), "builder-diagnostic");
1533 assert!(error.server_cleanup().is_none());
1534 assert!(!directory.exists());
1535 assert_eq!(process_state(pid), None);
1536 }
1537 }
1538
1539 #[cfg(unix)]
1540 #[test]
1541 fn port_io_and_command_spawn_failures_finalize_the_owned_server() {
1542 for port_io in [true, false] {
1543 let port = if port_io {
1544 "mkfifo \"$2\""
1545 } else {
1546 "printf '34567\n' > \"$2\""
1547 };
1548 let script = TestServerScript::new(
1549 "owned-io-failure",
1550 &format!(
1551 "#!/bin/sh\nprintf '%s\n%s\n' \"$$\" \"$2\"\nprintf 'original-server-diagnostic' >&2\n{port}\nexec sleep 30\n"
1552 ),
1553 );
1554 let config = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2));
1555 let error = if port_io {
1556 config.start_managed_server().err().unwrap()
1557 } else {
1558 config
1559 .run_command(&mut Command::new("/missing/ic-testkit-command"), || false)
1560 .unwrap_err()
1561 };
1562 let PocketIcStartupFailure::Io { source, .. } = error.failure() else {
1563 panic!("expected original IO failure: {error:?}");
1564 };
1565 assert_eq!(
1566 source.kind(),
1567 if port_io {
1568 std::io::ErrorKind::InvalidData
1569 } else {
1570 std::io::ErrorKind::NotFound
1571 }
1572 );
1573 assert_eq!(error.output().stderr(), "original-server-diagnostic");
1574 let mut lines = error.output().stdout().lines();
1575 let pid = lines.next().unwrap().parse().unwrap();
1576 let port = PathBuf::from(lines.next().unwrap());
1577 assert_eq!(process_state(pid), None);
1578 assert!(!port.parent().unwrap().exists());
1579 assert!(error.server_cleanup().is_none());
1580 }
1581 }
1582
1583 #[cfg(unix)]
1584 #[test]
1585 fn command_cancellation_before_startup_has_no_spawn_effects() {
1586 let error = PocketIcStartupConfig::spawn("/missing/server", Duration::from_secs(1))
1587 .run_command(&mut Command::new("/missing/command"), || true)
1588 .unwrap_err();
1589 assert!(
1590 matches!(error.failure(), PocketIcStartupFailure::CommandRun { source, .. } if source.kind() == std::io::ErrorKind::Interrupted)
1591 );
1592 assert_eq!(error.output().stdout(), "");
1593 assert_eq!(error.output().stderr(), "");
1594 assert!(error.command_cleanup().is_none());
1595 assert!(error.server_cleanup().is_none());
1596 }
1597
1598 #[cfg(unix)]
1599 #[test]
1600 fn cancellation_callback_panic_still_reaps_the_owned_command() {
1601 let script = TestServerScript::new(
1602 "cancel-panic",
1603 "#!/bin/sh\nprintf '%s' \"$$\" > \"$1\"\nexec sleep 30\n",
1604 );
1605 let pid_file = script.path().with_extension("pid");
1606 let result = std::panic::catch_unwind(|| {
1607 PocketIcStartupConfig::connect("http://127.0.0.1:12345/", Duration::from_secs(1))
1608 .run_command(Command::new(script.path()).arg(&pid_file), || {
1609 assert!(
1610 !fs::read_to_string(&pid_file).is_ok_and(|value| !value.is_empty()),
1611 "caller cancellation failed"
1612 );
1613 false
1614 })
1615 });
1616 assert!(result.is_err());
1617 let pid = fs::read_to_string(&pid_file).unwrap().parse().unwrap();
1618 assert!(process_state(pid).is_none_or(|state| state == 'Z'));
1619 fs::remove_file(pid_file).unwrap();
1620 }
1621
1622 #[cfg(unix)]
1623 #[test]
1624 fn environment_selection_prefers_urls_and_fails_closed() {
1625 use std::os::unix::ffi::OsStringExt as _;
1626
1627 let timeout = Duration::from_secs(1);
1628 let config = PocketIcStartupConfig::from_environment(timeout, |name| {
1629 Some(
1630 if name == "IC_TESTKIT_POCKET_IC_URL" {
1631 "http://127.0.0.1:12345/"
1632 } else {
1633 "/missing/server"
1634 }
1635 .into(),
1636 )
1637 })
1638 .unwrap();
1639 assert_eq!(config.server_url(), Some("http://127.0.0.1:12345/"));
1640 assert!(config.server_binary().is_none());
1641 assert!(matches!(
1642 PocketIcStartupConfig::from_environment(timeout, |_| None)
1643 .as_ref()
1644 .map_err(PocketIcStartupError::failure),
1645 Err(PocketIcStartupFailure::NotConfigured)
1646 ));
1647 assert!(matches!(
1648 PocketIcStartupConfig::from_environment(timeout, |_| Some("".into()))
1649 .as_ref()
1650 .map_err(PocketIcStartupError::failure),
1651 Err(PocketIcStartupFailure::InvalidEnvironment {
1652 variable: "IC_TESTKIT_POCKET_IC_URL"
1653 })
1654 ));
1655 assert!(matches!(
1656 PocketIcStartupConfig::from_environment(timeout, |_| Some("bad URL".into()))
1657 .as_ref()
1658 .map_err(PocketIcStartupError::failure),
1659 Err(PocketIcStartupFailure::InvalidServerUrl { .. })
1660 ));
1661 assert!(matches!(
1662 PocketIcStartupConfig::from_environment(timeout, |_| Some(
1663 std::ffi::OsString::from_vec(vec![0xff])
1664 ))
1665 .as_ref()
1666 .map_err(PocketIcStartupError::failure),
1667 Err(PocketIcStartupFailure::InvalidEnvironment { .. })
1668 ));
1669 }
1670
1671 #[cfg(unix)]
1672 #[test]
1673 fn version_capture_cleans_wrapper_descendants_on_exit_and_timeout() {
1674 for timeout in [false, true] {
1675 let qualified = format!(
1676 "printf 'pocket-ic-server {}\\n'",
1677 pocket_ic::LATEST_SERVER_VERSION
1678 );
1679 let ending = if timeout { "wait" } else { &qualified };
1680 let script = TestServerScript::new(
1681 "version-group",
1682 &format!(
1683 "#!/bin/sh\nsleep 30 >/dev/null 2>&1 &\nprintf '%s' \"$!\" > \"$0.pid\"\n{ending}\n"
1684 ),
1685 );
1686 let result = PocketIcStartupConfig::from_environment(Duration::from_secs(1), |name| {
1687 (name == "POCKET_IC_BIN").then(|| script.path().into_os_string())
1688 });
1689 if timeout {
1690 assert!(matches!(
1691 result.as_ref().map_err(PocketIcStartupError::failure),
1692 Err(PocketIcStartupFailure::ServerVersionProbe { .. })
1693 ));
1694 } else {
1695 assert!(result.is_ok());
1696 }
1697 let pid_file = script.path().with_extension("pid");
1698 let pid = fs::read_to_string(&pid_file).unwrap().parse().unwrap();
1699 let deadline = Instant::now() + Duration::from_secs(3);
1700 while process_state(pid).is_some_and(|state| state != 'Z') {
1701 assert!(
1702 Instant::now() < deadline,
1703 "version wrapper descendant remained alive"
1704 );
1705 std::thread::sleep(Duration::from_millis(20));
1706 }
1707 fs::remove_file(pid_file).unwrap();
1708 }
1709 }
1710
1711 #[cfg(unix)]
1712 #[test]
1713 fn owned_builder_panic_retains_server_output_and_reaps_child() {
1714 let script = TestServerScript::new(
1715 "builder-panic",
1716 "#!/bin/sh\nprintf '%s' \"$$\" > \"$0.pid\"\nprintf 'builder stdout'\nprintf 'builder stderr' >&2\nprintf '34567\\n' > \"$2\"\nexec sleep 30\n",
1717 );
1718 let error = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
1719 .build_instance(PocketIcBuilder::new())
1720 .err()
1721 .unwrap();
1722 let PocketIcStartupFailure::BuilderPanicked { message } = error.failure() else {
1723 panic!("expected builder failure, got {error:?}");
1724 };
1725 assert_ne!(message, "");
1726 assert_eq!(error.output().stdout(), "builder stdout");
1727 assert_eq!(error.output().stderr(), "builder stderr");
1728 assert!(error.server_cleanup().is_none());
1729 let pid_file = script.path().with_extension("pid");
1730 let pid = fs::read_to_string(&pid_file).unwrap().parse().unwrap();
1731 assert_eq!(process_state(pid), None);
1732 fs::remove_file(pid_file).unwrap();
1733 }
1734
1735 #[cfg(unix)]
1736 #[test]
1737 fn environment_binary_selection_uses_bounded_shared_version_capture() {
1738 let qualified = format!(
1739 "printf 'pocket-ic-server {}\\n'",
1740 pocket_ic::LATEST_SERVER_VERSION
1741 );
1742 let failed = format!("{qualified}; exit 23");
1743 for (label, body, expected) in [
1744 ("qualified", qualified.as_str(), 0),
1745 ("compatible-16.0", "printf 'pocket-ic-server 16.0.0\\n'", 0),
1746 ("compatible-patch", "printf 'pocket-ic-server 16.0.1\\n'", 0),
1747 ("wrong-version", "printf 'pocket-ic-server 15.0.0\\n'", 1),
1748 ("future-major", "printf 'pocket-ic-server 17.0.0\\n'", 1),
1749 ("malformed-version", "printf 'pocket-ic-server 16.1\\n'", 1),
1750 ("failed-version", failed.as_str(), 2),
1751 ("invalid-utf8", "printf '\\377'", 1),
1752 ("version-timeout", "exec sleep 30", 2),
1753 ] {
1754 let script = TestServerScript::new(
1755 label,
1756 &format!("#!/bin/sh\n[ \"$1\" = --version ] || exit 99\n{body}\n"),
1757 );
1758 let result =
1759 PocketIcStartupConfig::from_environment(Duration::from_millis(200), |name| {
1760 (name == "POCKET_IC_BIN").then(|| script.path().into_os_string())
1761 });
1762 match (
1763 expected,
1764 result.as_ref().map_err(PocketIcStartupError::failure),
1765 ) {
1766 (0, Ok(config)) => {
1767 let binary = script.path().canonicalize().unwrap();
1768 assert_eq!(config.server_binary(), Some(binary.as_path()));
1769 }
1770 (1, Err(PocketIcStartupFailure::ServerVersionMismatch { .. }))
1771 | (2, Err(PocketIcStartupFailure::ServerVersionProbe { .. })) => {}
1772 (_, result) => panic!("unexpected {label} result: {result:?}"),
1773 }
1774 }
1775 }
1776
1777 #[cfg(unix)]
1778 fn process_state(pid: u32) -> Option<char> {
1779 let output = Command::new("/bin/ps")
1782 .args(["-p", &pid.to_string(), "-o", "stat="])
1783 .output()
1784 .expect("inspect managed test process state");
1785 assert!(
1786 output.status.success()
1787 || (output.status.code() == Some(1)
1788 && output.stdout.is_empty()
1789 && output.stderr.is_empty()),
1790 "process-state inspection failed: {}: {}",
1791 output.status,
1792 String::from_utf8_lossy(&output.stderr),
1793 );
1794 String::from_utf8(output.stdout)
1795 .expect("process state is ASCII")
1796 .trim()
1797 .chars()
1798 .next()
1799 }
1800
1801 #[cfg(unix)]
1802 #[test]
1803 fn reading_large_sparse_server_output_is_bounded() {
1804 let (files, _, _) = StartupFiles::create(None).expect("allocate startup files");
1805 let mut file = fs::File::create(&files.stdout).expect("create sparse log");
1806 file.write_all(b"server started\n").expect("write prefix");
1807 let size = 8_u64 * 1024 * 1024 * 1024;
1808 file.set_len(size).expect("extend sparse log");
1809 let output = super::read_bounded_lossy(&files.stdout);
1810 assert!(output.starts_with("server started\n"));
1811 assert!(output.ends_with(&format!(
1812 "<truncated {} bytes>",
1813 size - super::SERVER_OUTPUT_LIMIT as u64
1814 )));
1815 assert!(output.len() < super::SERVER_OUTPUT_LIMIT + 100);
1816 }
1817
1818 #[cfg(unix)]
1819 #[test]
1820 fn startup_readers_reject_fifos_without_waiting_for_a_writer() {
1821 let (files, stdout, stderr) = StartupFiles::create(None).expect("allocate startup files");
1822 drop((stdout, stderr));
1823 fs::remove_file(&files.stdout).unwrap();
1824 fs::remove_file(&files.stderr).unwrap();
1825 assert!(
1826 Command::new("mkfifo")
1827 .args([&files.port, &files.stdout, &files.stderr])
1828 .status()
1829 .expect("create FIFO startup files")
1830 .success()
1831 );
1832 for path in [&files.port, &files.stdout, &files.stderr] {
1833 let fifo = path.clone();
1837 let (stop_writer, stopped) = mpsc::channel();
1838 let writer = std::thread::spawn(move || {
1839 loop {
1840 match stopped.recv_timeout(Duration::from_millis(200)) {
1841 Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return false,
1842 Err(mpsc::RecvTimeoutError::Timeout) => {}
1843 }
1844 if fs::OpenOptions::new()
1845 .write(true)
1846 .custom_flags(libc::O_NONBLOCK)
1847 .open(&fifo)
1848 .is_ok()
1849 {
1850 return true;
1851 }
1852 }
1853 });
1854 let result = if path == &files.port {
1855 Some(files.read_port())
1856 } else {
1857 assert_eq!(super::read_bounded_lossy(path), "");
1858 None
1859 };
1860 let _ = stop_writer.send(());
1861 assert!(
1862 !writer.join().expect("join delayed FIFO writer"),
1863 "startup reader waited for a writer: {}",
1864 path.display(),
1865 );
1866 if let Some(result) = result {
1867 assert!(matches!(
1868 result.as_ref().map_err(PocketIcStartupError::failure),
1869 Err(PocketIcStartupFailure::Io { source, .. })
1870 if source.kind() == std::io::ErrorKind::InvalidData
1871 ));
1872 }
1873 }
1874 }
1875
1876 #[test]
1877 fn port_file_readiness_preserves_partial_writes_and_rejects_oversized_contents() {
1878 let (files, _, _) = StartupFiles::create(None).expect("allocate startup files");
1879 assert!(matches!(
1880 files.read_port().unwrap(),
1881 super::PortFileState::Pending
1882 ));
1883 for contents in ["", "34567"] {
1884 fs::write(&files.port, contents).unwrap();
1885 assert!(matches!(
1886 files.read_port().unwrap(),
1887 super::PortFileState::Pending
1888 ));
1889 }
1890 for (contents, expected) in [("1\n", 1), ("65535\n", 65535), (" 34567\r\n", 34567)] {
1891 fs::write(&files.port, contents).unwrap();
1892 assert!(matches!(
1893 files.read_port().unwrap(),
1894 super::PortFileState::Ready(port) if port == expected
1895 ));
1896 }
1897 for contents in ["0\n", "65536\n", "invalid\n", "1\n2\n"] {
1898 fs::write(&files.port, contents).unwrap();
1899 assert!(matches!(
1900 files.read_port().unwrap(),
1901 super::PortFileState::Invalid(_)
1902 ));
1903 }
1904 fs::write(&files.port, [0xff, b'\n']).unwrap();
1905 assert!(matches!(
1906 files.read_port().as_ref().map_err(PocketIcStartupError::failure),
1907 Err(PocketIcStartupFailure::Io { source, .. })
1908 if source.kind() == std::io::ErrorKind::InvalidData
1909 ));
1910 for contents in ["1\n".to_owned() + &" ".repeat(128), "0".repeat(128)] {
1911 fs::write(&files.port, contents).unwrap();
1912 assert!(
1913 matches!(files.read_port().unwrap(), super::PortFileState::Invalid(_)),
1914 "oversized port contents must fail even without a newline",
1915 );
1916 }
1917 fs::File::options()
1919 .write(true)
1920 .open(&files.port)
1921 .unwrap()
1922 .set_len(1024 * 1024)
1923 .unwrap();
1924 assert!(matches!(
1925 files.read_port().unwrap(),
1926 super::PortFileState::Invalid(value) if value.len() < 256
1927 ));
1928 }
1929
1930 #[test]
1931 fn startup_config_requires_positive_bounds() {
1932 for config in [
1933 PocketIcStartupConfig::spawn("pocket-ic", Duration::from_secs(1))
1934 .with_server_idle_ttl(Duration::from_millis(1)),
1935 PocketIcStartupConfig::connect("http://127.0.0.1:1/", Duration::from_secs(1))
1936 .with_server_idle_ttl(Duration::from_secs(120)),
1937 ] {
1938 assert!(matches!(
1939 config
1940 .validate()
1941 .as_ref()
1942 .map_err(PocketIcStartupError::failure),
1943 Err(PocketIcStartupFailure::InvalidConfiguration { .. })
1944 ));
1945 }
1946 let error = PocketIcStartupConfig::connect("http://127.0.0.1:1/", Duration::ZERO)
1947 .validate()
1948 .expect_err("zero startup timeout must fail");
1949 assert!(matches!(
1950 error.failure(),
1951 PocketIcStartupFailure::InvalidConfiguration { .. }
1952 ));
1953
1954 let error = PocketIcStartupConfig::spawn("pocket-ic", Duration::from_secs(1))
1955 .with_server_hard_ttl(Duration::from_millis(1))
1956 .validate()
1957 .expect_err("subsecond server hard TTL must fail");
1958 assert!(matches!(
1959 error.failure(),
1960 PocketIcStartupFailure::InvalidConfiguration { .. }
1961 ));
1962 }
1963
1964 #[test]
1965 fn managed_server_hard_ttl_is_opt_in() {
1966 let default = PocketIcStartupConfig::spawn("pocket-ic", Duration::from_secs(1));
1967 assert_eq!(default.server_idle_ttl(), None);
1968 assert_eq!(
1969 default
1970 .clone()
1971 .with_server_idle_ttl(Duration::from_secs(120))
1972 .server_idle_ttl(),
1973 Some(Duration::from_secs(120))
1974 );
1975 assert_eq!(default.server_hard_ttl(), None);
1976
1977 let explicit = default.with_server_hard_ttl(Duration::from_secs(17));
1978 assert_eq!(explicit.server_hard_ttl(), Some(Duration::from_secs(17)));
1979 }
1980
1981 #[test]
1982 fn startup_files_leave_the_server_owned_port_path_absent() {
1983 let (files, stdout, stderr) = StartupFiles::create(None).expect("allocate startup files");
1984 let directory = files.directory.clone();
1985
1986 assert!(directory.is_dir());
1987 assert!(!files.port.exists());
1988 assert!(files.stdout.is_file());
1989 assert!(files.stderr.is_file());
1990 #[cfg(unix)]
1991 {
1992 let mode = fs::metadata(&directory)
1993 .expect("inspect private startup directory")
1994 .permissions()
1995 .mode();
1996 assert_eq!(mode & 0o077, 0);
1997 }
1998
1999 drop(stdout);
2000 drop(stderr);
2001 drop(files);
2002 assert!(!directory.exists());
2003 }
2004
2005 #[cfg(unix)]
2006 #[test]
2007 fn caller_output_files_survive_startup_command_and_cancellation_cleanup() {
2008 for outcome in [
2009 "timeout",
2010 "startup-exit",
2011 "server-exit",
2012 "command-exit",
2013 "cancel",
2014 "success",
2015 ] {
2016 let (owner, _, _) = StartupFiles::create(None).unwrap();
2017 let stdout = owner.directory.join("retained-stdout");
2018 let stderr = owner.directory.join("retained-stderr");
2019 let ending = match outcome {
2020 "timeout" => "exec sleep 30",
2021 "startup-exit" => "exit 41",
2022 "server-exit" => {
2023 "printf '34567\\n' > \"$2\"; while [ ! -s \"$0.command\" ]; do sleep 0.02; done; exit 42"
2024 }
2025 _ => "printf '34567\\n' > \"$2\"; exec sleep 30",
2026 };
2027 let script = TestServerScript::new(
2028 outcome,
2029 &format!(
2030 "#!/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"
2031 ),
2032 );
2033 let pid_file = script.path().with_extension("pid");
2034 let command_file = script.path().with_extension("command");
2035 let config = PocketIcStartupConfig::spawn(
2036 script.path(),
2037 Duration::from_millis(if outcome == "timeout" { 1000 } else { 2000 }),
2038 )
2039 .with_server_output_files(&stdout, &stderr);
2040 if outcome == "timeout" || outcome == "startup-exit" {
2041 let error = config.start_managed_server().err().unwrap();
2042 match (outcome, error.failure()) {
2043 ("timeout", PocketIcStartupFailure::ReadinessTimeout { .. }) => {
2044 assert!(error.output().stdout().contains("truncated"));
2045 assert!(!error.output().stderr().contains("raw-stderr-end"));
2046 }
2047 ("startup-exit", PocketIcStartupFailure::ServerExited { status, .. }) => {
2048 assert_eq!(status.code(), Some(41));
2049 }
2050 (_, error) => panic!("unexpected startup result: {error:?}"),
2051 }
2052 } else {
2053 let command_end = match outcome {
2054 "command-exit" => "exit 37",
2055 "success" => "exit 0",
2056 _ => "exec sleep 30",
2057 };
2058 let result = config.run_command(
2059 Command::new("/bin/sh")
2060 .args([
2061 "-c",
2062 &format!("printf '%s' \"$$\" > \"$1\"; {command_end}"),
2063 "fixture",
2064 ])
2065 .arg(&command_file),
2066 || {
2067 outcome == "cancel"
2068 && fs::metadata(&command_file).is_ok_and(|m| m.len() > 0)
2069 },
2070 );
2071 let result = result.as_ref().map_err(PocketIcStartupError::failure);
2072 match (outcome, result) {
2073 ("command-exit", Ok(status)) => assert_eq!(status.code(), Some(37)),
2074 ("success", Ok(status)) => assert!(status.success()),
2075 ("server-exit", Err(PocketIcStartupFailure::ServerExited { status, .. })) => {
2076 assert_eq!(status.code(), Some(42));
2077 }
2078 ("cancel", Err(PocketIcStartupFailure::CommandRun { source, .. })) => {
2079 assert_eq!(source.kind(), std::io::ErrorKind::Interrupted);
2080 }
2081 (_, result) => panic!("unexpected command result: {result:?}"),
2082 }
2083 let pid = fs::read_to_string(&command_file).unwrap().parse().unwrap();
2084 assert!(process_state(pid).is_none_or(|state| state == 'Z'));
2085 fs::remove_file(command_file).unwrap();
2086 }
2087 for (path, suffix) in [(&stdout, b"raw-stdout-end"), (&stderr, b"raw-stderr-end")] {
2088 let bytes = fs::read(path).unwrap();
2089 assert_eq!(bytes.len(), 20 * 1024 + suffix.len());
2090 assert!(bytes.ends_with(suffix));
2091 assert_eq!(
2092 fs::metadata(path).unwrap().permissions().mode() & 0o777,
2093 0o600
2094 );
2095 }
2096 let report = fs::read_to_string(&pid_file).unwrap();
2097 let mut lines = report.lines();
2098 assert_eq!(process_state(lines.next().unwrap().parse().unwrap()), None);
2099 assert!(
2100 !PathBuf::from(lines.next().unwrap())
2101 .parent()
2102 .unwrap()
2103 .exists()
2104 );
2105 fs::remove_file(pid_file).unwrap();
2106 }
2107 }
2108
2109 #[cfg(unix)]
2110 #[test]
2111 fn caller_output_files_refuse_existing_entries_and_keep_partial_preparation() {
2112 use std::os::unix::fs::symlink;
2113
2114 let (owner, _, _) = StartupFiles::create(None).unwrap();
2115 let stdout = owner.directory.join("retained-stdout");
2116 let stderr = owner.directory.join("retained-stderr");
2117 let unrelated = owner.directory.join("unrelated");
2118 fs::write(&unrelated, b"original").unwrap();
2119 symlink(&unrelated, &stderr).unwrap();
2120 let error = StartupFiles::create(Some((stdout.clone(), stderr.clone())))
2121 .err()
2122 .unwrap();
2123 assert!(
2124 matches!(error.failure(), PocketIcStartupFailure::Io { source, .. } if source.kind() == std::io::ErrorKind::AlreadyExists)
2125 );
2126 assert!(stdout.is_file());
2127 assert_eq!(fs::read(&unrelated).unwrap(), b"original");
2128 fs::write(&stdout, b"retained attempt").unwrap();
2129 assert!(StartupFiles::create(Some((stdout.clone(), stderr.clone()))).is_err());
2130 assert_eq!(fs::read(&stdout).unwrap(), b"retained attempt");
2131 fs::remove_file(stderr.clone()).unwrap();
2132 assert!(
2133 Command::new("mkfifo")
2134 .arg(&stderr)
2135 .status()
2136 .unwrap()
2137 .success()
2138 );
2139 fs::remove_file(stdout.clone()).unwrap();
2140 assert!(StartupFiles::create(Some((stdout.clone(), stderr.clone()))).is_err());
2141 assert!(stdout.is_file());
2142 let error =
2143 PocketIcStartupConfig::connect("http://127.0.0.1:12345/", Duration::from_secs(1))
2144 .with_server_output_files(&stdout, &stderr)
2145 .run_command(&mut Command::new("/missing/command"), || false)
2146 .unwrap_err();
2147 assert!(matches!(
2148 error.failure(),
2149 PocketIcStartupFailure::InvalidConfiguration { .. }
2150 ));
2151 }
2152
2153 #[cfg(unix)]
2154 #[test]
2155 fn managed_startup_reports_an_exited_server_with_bounded_output() {
2156 let script = TestServerScript::new(
2157 "exit",
2158 "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nprintf 'synthetic server stdout'\nprintf 'synthetic bind failure' >&2\nexit 23\n",
2159 );
2160
2161 let result = PocketIcBuilder::new().with_application_subnet().try_build(
2162 PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2)),
2163 );
2164
2165 let error = result.err().unwrap();
2166 let PocketIcStartupFailure::ServerExited {
2167 server_binary,
2168 status,
2169 ..
2170 } = error.failure()
2171 else {
2172 panic!("expected a structured server exit, got {error:?}");
2173 };
2174 assert_eq!(server_binary, &script.path());
2175 assert_eq!(status.code(), Some(23));
2176 assert_eq!(error.output().stdout(), "synthetic server stdout");
2177 assert_eq!(error.output().stderr(), "synthetic bind failure");
2178 }
2179
2180 #[cfg(unix)]
2181 #[test]
2182 fn managed_startup_rejects_oversized_port_files_and_cleans_up() {
2183 let script = TestServerScript::new(
2184 "oversized-port",
2185 "#!/bin/sh\nprintf '%s\\n%s\\n' \"$$\" \"$2\"\nprintf '34567\\n%064s' '' > \"$2.pending\"\nmv \"$2.pending\" \"$2\"\nexec sleep 30\n",
2186 );
2187 let result = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
2188 .start_managed_server();
2189 let error = result.err().unwrap();
2190 let PocketIcStartupFailure::InvalidServerPort { value, .. } = error.failure() else {
2191 panic!("oversized port publication must fail readiness");
2192 };
2193 assert!(value.contains("port file exceeds"));
2194 assert!(value.len() < 256);
2195 let mut lines = error.output().stdout().lines();
2196 let pid = lines.next().unwrap().parse::<u32>().unwrap();
2197 let port_path = PathBuf::from(lines.next().unwrap());
2198 assert!(!port_path.parent().unwrap().exists());
2199 assert_eq!(process_state(pid), None, "failed server must be reaped");
2200 }
2201
2202 #[cfg(unix)]
2203 #[test]
2204 fn managed_startup_terminates_a_server_that_never_becomes_ready() {
2205 let script = TestServerScript::new(
2206 "timeout",
2207 "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nexec sleep 30\n",
2208 );
2209 let timeout = Duration::from_millis(100);
2210 let started = Instant::now();
2211
2212 let result = PocketIcBuilder::new()
2213 .with_application_subnet()
2214 .try_build(PocketIcStartupConfig::spawn(script.path(), timeout));
2215
2216 assert!(
2217 started.elapsed() < Duration::from_secs(2),
2218 "bounded startup should not wait for the sleeping child"
2219 );
2220 let error = result.err().unwrap();
2221 assert!(
2222 matches!(error.failure(), PocketIcStartupFailure::ReadinessTimeout {
2223 server_binary, timeout: actual_timeout,
2224 } if server_binary == &script.path() && *actual_timeout == timeout)
2225 );
2226 assert!(error.server_cleanup().is_none());
2227 }
2228
2229 #[cfg(unix)]
2230 #[test]
2231 fn managed_server_handle_exposes_process_id_url_output_and_raii_ownership() {
2232 let script = TestServerScript::new(
2233 "handle",
2234 "#!/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",
2235 );
2236
2237 let server = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
2238 .start_managed_server()
2239 .expect("start caller-owned managed server");
2240
2241 assert_eq!(server.url(), "http://127.0.0.1:34567/");
2242 assert_eq!(
2243 server.output().stdout(),
2244 format!("managed server ready: {}", server.process_id())
2245 );
2246 assert_eq!(server.output().stderr(), "");
2247 let pid = server.process_id();
2248 assert!(process_state(pid).is_some_and(|state| state != 'Z'));
2249 drop(server);
2250 assert_eq!(process_state(pid), None, "owned server must be reaped");
2251 }
2252
2253 #[cfg(unix)]
2254 #[test]
2255 fn managed_server_cleans_descendants_on_drop_timeout_exit_and_background_reap() {
2256 for mode in ["drop", "timeout", "exit", "background"] {
2257 let publish = if matches!(mode, "drop" | "background") {
2258 "printf '34567\\n' > \"$2\"\n"
2259 } else {
2260 ""
2261 };
2262 let finish = if mode == "background" {
2263 "while [ -e \"$2\" ]; do sleep 0.01; done\nexit 23\n"
2266 } else if mode == "exit" {
2267 "sleep 0.03\nexit 23\n"
2268 } else {
2269 "exec sleep 30\n"
2270 };
2271 let script = TestServerScript::new(
2272 mode,
2273 &format!("#!/bin/sh\nsleep 30 &\nprintf '%s' \"$!\"\n{publish}{finish}"),
2274 );
2275 let result = PocketIcStartupConfig::spawn(script.path(), Duration::from_millis(300))
2276 .start_managed_server();
2277 let output = match result {
2278 Ok(server) => {
2279 let output = server.output().stdout().to_owned();
2280 if mode == "background" {
2281 let port = server.server.files.port.clone();
2282 server.server.reap_in_background();
2283 fs::remove_file(port).expect("release the background server after handoff");
2284 } else {
2285 drop(server);
2286 }
2287 output
2288 }
2289 Err(error) => {
2290 match error.failure() {
2291 PocketIcStartupFailure::ReadinessTimeout { .. } => {
2292 assert_eq!(mode, "timeout");
2293 assert!(error.server_cleanup().is_none());
2294 }
2295 PocketIcStartupFailure::ServerExited { status, .. } => {
2296 assert_eq!(mode, "exit");
2297 assert_eq!(status.code(), Some(23));
2298 }
2299 _ => panic!("unexpected {mode} startup result: {error}"),
2300 }
2301 error.output().stdout().to_owned()
2302 }
2303 };
2304 let pid = output
2305 .parse::<u32>()
2306 .expect("server published its descendant PID");
2307 let deadline = Instant::now() + Duration::from_secs(2);
2308 while process_state(pid).is_some_and(|state| state != 'Z') {
2309 assert!(
2310 Instant::now() < deadline,
2311 "{mode} left its descendant running"
2312 );
2313 std::thread::sleep(Duration::from_millis(10));
2314 }
2315 }
2316 }
2317
2318 #[cfg(unix)]
2319 #[test]
2320 fn managed_server_passes_an_explicit_hard_ttl() {
2321 let script = TestServerScript::new(
2322 "hard-ttl",
2323 "#!/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",
2324 );
2325
2326 let server = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
2327 .with_server_hard_ttl(Duration::from_secs(17))
2328 .start_managed_server()
2329 .expect("start managed server with an explicit hard TTL");
2330
2331 assert_eq!(server.url(), "http://127.0.0.1:34567/");
2332 }
2333
2334 #[cfg(unix)]
2335 #[test]
2336 #[ignore = "requires POCKET_IC_BIN=<caller-provided PocketIC server binary>"]
2337 fn caller_provided_server_publishes_port_constructs_instance_and_cleans_up() {
2338 let binary = std::env::var_os("POCKET_IC_BIN")
2339 .map(PathBuf::from)
2340 .expect("set POCKET_IC_BIN to the exact server binary");
2341 let wrapper = TestServerScript::new(
2344 "real-reaper",
2345 "#!/bin/sh\nprintf '%s\n%s\n' \"$$\" \"$4\" > \"$0.record\"\nexec \"$POCKET_IC_BIN\" \"$@\"\n",
2346 );
2347 let one_shot = PocketIcBuilder::new()
2348 .with_application_subnet()
2349 .try_build(
2350 PocketIcStartupConfig::spawn(wrapper.path(), Duration::from_secs(30))
2351 .with_server_hard_ttl(Duration::from_secs(3)),
2352 )
2353 .expect("one-shot managed spawn must construct an instance");
2354 let record_file = wrapper.path().with_extension("record");
2355 let record = fs::read_to_string(&record_file).unwrap();
2356 let mut fields = record.lines();
2357 let pid = fields.next().unwrap().parse().unwrap();
2358 let port_path = PathBuf::from(fields.next().unwrap());
2359 let one_shot_directory = port_path.parent().unwrap();
2360 drop(one_shot);
2361 let cleanup_deadline = Instant::now() + Duration::from_secs(5);
2362 while one_shot_directory.exists() && Instant::now() < cleanup_deadline {
2363 std::thread::sleep(Duration::from_millis(20));
2364 }
2365 assert!(!one_shot_directory.exists());
2366 assert_eq!(process_state(pid), None);
2367 fs::remove_file(record_file).unwrap();
2368
2369 let server = PocketIcStartupConfig::spawn(&binary, Duration::from_secs(30))
2370 .with_server_hard_ttl(Duration::from_secs(60))
2371 .start_managed_server()
2372 .expect("caller-provided PocketIC server must publish its port");
2373 let files = &server.server.files;
2374 let startup_directory = files.directory.clone();
2375
2376 assert!(files.port.is_file());
2377 let pocket_ic = PocketIcBuilder::new()
2378 .with_application_subnet()
2379 .try_build(PocketIcStartupConfig::connect(
2380 server.url(),
2381 Duration::from_secs(30),
2382 ))
2383 .expect("construct instance through caller-provided server");
2384
2385 drop(pocket_ic);
2386 drop(server);
2387 assert!(!startup_directory.exists());
2388 }
2389
2390 #[cfg(unix)]
2391 struct TestServerScript {
2392 path: PathBuf,
2393 }
2394
2395 #[cfg(unix)]
2396 impl TestServerScript {
2397 fn new(label: &str, contents: &str) -> Self {
2398 let path = std::env::temp_dir().join(format!(
2399 "ic-testkit-pocket-ic-{label}-{}-{}",
2400 std::process::id(),
2401 super::STARTUP_FILE_SEQUENCE.fetch_add(1, super::Ordering::Relaxed),
2402 ));
2403 write_executable_script(&path, contents);
2404 Self { path }
2405 }
2406
2407 fn path(&self) -> PathBuf {
2408 self.path.clone()
2409 }
2410 }
2411
2412 #[cfg(unix)]
2413 impl Drop for TestServerScript {
2414 fn drop(&mut self) {
2415 let _ = fs::remove_file(&self.path);
2416 }
2417 }
2418}