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