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