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