1use std::{
2 fmt::Write as _,
3 fs::{self, File, OpenOptions},
4 io,
5 path::{Path, PathBuf},
6 process::{Command, ExitStatus, Stdio},
7 sync::mpsc::{self, RecvTimeoutError},
8 thread,
9 time::{Duration, Instant},
10};
11
12#[cfg(unix)]
13use std::os::unix::fs::OpenOptionsExt as _;
14
15use crate::pocket_ic::{ClientError, PocketIc, PocketIcBuilder};
16use ic_host_fs::{directory::create_unique_directory, owned_directory::OwnedDirectory};
17use ic_host_process::child::{CleanupError, CleanupPolicy, OwnedChild};
18
19const STARTUP_POLL_INTERVAL: Duration = Duration::from_millis(20);
20const SERVER_OUTPUT_LIMIT: usize = 16 * 1024;
21const SERVER_PORT_FILE_LIMIT: usize = 64;
24
25#[derive(Clone, Debug, Eq, PartialEq)]
30pub enum PocketIcStartupConfig {
31 Spawn {
33 server_binary: PathBuf,
35 timeout: Duration,
37 server_hard_ttl: Option<Duration>,
39 server_idle_ttl: Option<Duration>,
41 server_output_files: Option<(PathBuf, PathBuf)>,
44 },
45 Connect {
47 server_url: String,
49 timeout: Duration,
51 },
52}
53
54pub struct PocketIcManagedServer {
65 server: ManagedServer,
66 url: String,
67}
68
69#[must_use]
96pub struct PocketIcInstance<T> {
97 instance: T,
98 server: Option<PocketIcManagedServer>,
99}
100
101impl<T> PocketIcInstance<T> {
102 pub const fn from_borrowed_server(instance: T) -> Self {
108 Self {
109 instance,
110 server: None,
111 }
112 }
113
114 #[must_use]
116 pub const fn instance(&self) -> &T {
117 &self.instance
118 }
119
120 pub const fn instance_mut(&mut self) -> &mut T {
132 &mut self.instance
133 }
134
135 #[must_use]
137 pub const fn server(&self) -> Option<&PocketIcManagedServer> {
138 self.server.as_ref()
139 }
140
141 #[must_use]
147 pub fn into_parts(self) -> (T, Option<PocketIcManagedServer>) {
148 (self.instance, self.server)
149 }
150}
151
152#[derive(Clone, Debug, Default, Eq, PartialEq)]
158pub struct PocketIcManagedServerOutput {
159 stdout: String,
160 stderr: String,
161}
162
163#[derive(Debug)]
169pub struct PocketIcStartupError {
170 failure: Box<PocketIcStartupFailure>,
171 output: PocketIcManagedServerOutput,
172 command_cleanup: Option<Box<CleanupError>>,
173 server_cleanup: Option<Box<CleanupError>>,
174}
175
176impl PocketIcStartupError {
177 fn new(failure: PocketIcStartupFailure) -> Self {
178 Self {
179 failure: Box::new(failure),
180 output: PocketIcManagedServerOutput::default(),
181 command_cleanup: None,
182 server_cleanup: None,
183 }
184 }
185
186 #[must_use]
188 pub fn failure(&self) -> &PocketIcStartupFailure {
189 &self.failure
190 }
191
192 #[must_use]
194 pub const fn output(&self) -> &PocketIcManagedServerOutput {
195 &self.output
196 }
197
198 #[must_use]
200 pub fn command_cleanup(&self) -> Option<&CleanupError> {
201 self.command_cleanup.as_deref()
202 }
203
204 #[must_use]
206 pub fn server_cleanup(&self) -> Option<&CleanupError> {
207 self.server_cleanup.as_deref()
208 }
209
210 fn with_cleanup(
211 mut self,
212 command: Option<CleanupError>,
213 server: Option<(PocketIcManagedServerOutput, Option<CleanupError>)>,
214 ) -> Self {
215 self.command_cleanup = command.map(Box::new);
216 if let Some((output, cleanup)) = server {
217 self.output = output;
218 self.server_cleanup = cleanup.map(Box::new);
219 }
220 self
221 }
222}
223
224#[non_exhaustive]
226#[derive(Debug, thiserror::Error)]
227pub enum PocketIcStartupFailure {
228 #[error(
230 "configure IC_TESTKIT_POCKET_IC_URL or POCKET_IC_BIN; prepare a verified binary with make install-server"
231 )]
232 NotConfigured,
233 #[error("invalid selected environment value: {variable}")]
235 InvalidEnvironment {
236 variable: &'static str,
238 },
239 #[error("PocketIC version probe failed: {source}")]
241 ServerVersionProbe {
242 source: ic_host_process::tool::ToolError,
244 },
245 #[error("PocketIC version mismatch: expected {expected:?}, observed {:?}", String::from_utf8_lossy(.observed))]
247 ServerVersionMismatch {
248 expected: String,
250 observed: Vec<u8>,
252 },
253 #[error("command {} failed: {source}", .program.display())]
255 CommandRun {
256 program: PathBuf,
258 source: io::Error,
260 },
261 #[error("{message}")]
263 InvalidConfiguration {
264 message: String,
266 },
267 #[error("invalid PocketIC server URL {server_url:?}: {message}")]
269 InvalidServerUrl {
270 server_url: String,
272 message: String,
274 },
275 #[error("failed to {operation} at {}: {source}", .path.display())]
277 Io {
278 operation: &'static str,
280 path: PathBuf,
282 source: io::Error,
284 },
285 #[error("failed to spawn PocketIC server {}: {source}", .server_binary.display())]
287 ServerSpawn {
288 server_binary: PathBuf,
290 source: io::Error,
292 },
293 #[error("PocketIC server {} exited with {status} after {elapsed:?}", .server_binary.display())]
295 ServerExited {
296 server_binary: PathBuf,
298 status: ExitStatus,
300 elapsed: Duration,
302 },
303 #[error("PocketIC server {} was not ready within {timeout:?}", .server_binary.display())]
305 ReadinessTimeout {
306 server_binary: PathBuf,
308 timeout: Duration,
310 },
311 #[error("PocketIC server {} published invalid port {value:?}", .server_binary.display())]
313 InvalidServerPort {
314 server_binary: PathBuf,
316 value: String,
318 },
319 #[error("PocketIC instance creation exceeded {timeout:?}")]
321 InstanceCreationTimeout {
322 timeout: Duration,
324 },
325 #[error("failed to spawn PocketIC builder worker: {source}")]
327 BuilderThreadSpawn {
328 source: io::Error,
330 },
331 #[error("PocketIC startup failed: {source}")]
333 BuilderFailed {
334 source: ClientError,
336 },
337 #[error("PocketIC builder worker disconnected without a result")]
339 BuilderDisconnected,
340 #[error("PocketIC server cleanup failed after command exited with {command_status}")]
342 ServerCleanup {
343 command_status: ExitStatus,
345 },
346}
347
348impl PocketIcStartupConfig {
349 pub fn from_env(timeout: Duration) -> Result<Self, PocketIcStartupError> {
363 Self::from_environment(timeout, |name| std::env::var_os(name))
364 }
365
366 fn from_environment(
367 timeout: Duration,
368 mut variable: impl FnMut(&str) -> Option<std::ffi::OsString>,
369 ) -> Result<Self, PocketIcStartupError> {
370 if let Some(value) = variable("IC_TESTKIT_POCKET_IC_URL") {
371 let server_url = value
372 .into_string()
373 .ok()
374 .filter(|value| !value.is_empty())
375 .ok_or_else(|| {
376 PocketIcStartupError::new(PocketIcStartupFailure::InvalidEnvironment {
377 variable: "IC_TESTKIT_POCKET_IC_URL",
378 })
379 })?;
380 let config = Self::Connect {
381 server_url: (&server_url).into(),
382 timeout,
383 };
384 config.validate()?;
385 let _ = configured_builder(PocketIcBuilder::new(), &server_url)?;
386 return Ok(config);
387 }
388 let value = variable("POCKET_IC_BIN")
389 .ok_or_else(|| PocketIcStartupError::new(PocketIcStartupFailure::NotConfigured))?;
390 if value.is_empty() {
391 return Err(PocketIcStartupError::new(
392 PocketIcStartupFailure::InvalidEnvironment {
393 variable: "POCKET_IC_BIN",
394 },
395 ));
396 }
397 let path = PathBuf::from(value);
398 let binary = fs::canonicalize(&path).map_err(|source| {
399 PocketIcStartupError::new(PocketIcStartupFailure::Io {
400 operation: "resolve configured PocketIC executable",
401 path,
402 source,
403 })
404 })?;
405 let config = Self::Spawn {
406 server_binary: (&binary).into(),
407 timeout,
408 server_hard_ttl: None,
409 server_idle_ttl: None,
410 server_output_files: None,
411 };
412 config.validate()?;
413 let evidence = ic_host_process::tool::capture_group_command(
414 Command::new(&binary).arg("--version"),
415 ic_host_process::tool::OutputLimits {
416 stdout: ic_host_process::tool::OutputLimit::Terminate(SERVER_OUTPUT_LIMIT),
417 stderr: ic_host_process::tool::OutputLimit::Terminate(SERVER_OUTPUT_LIMIT),
418 timeout: Some(timeout),
419 },
420 )
421 .and_then(ic_host_process::tool::CompletedExecution::require_success)
422 .map_err(|source| {
423 PocketIcStartupError::new(PocketIcStartupFailure::ServerVersionProbe { source })
424 })?;
425 let expected = super::server::pocket_ic_server_requirement();
426 if !super::supports_pocket_ic_server(&evidence.stdout) {
427 return Err(PocketIcStartupError::new(
428 PocketIcStartupFailure::ServerVersionMismatch {
429 expected,
430 observed: evidence.stdout,
431 },
432 ));
433 }
434 Ok(config)
435 }
436
437 #[must_use]
439 pub const fn timeout(&self) -> Duration {
440 match self {
441 Self::Spawn { timeout, .. } | Self::Connect { timeout, .. } => *timeout,
442 }
443 }
444
445 pub fn build_instance(
469 self,
470 builder: PocketIcBuilder,
471 ) -> Result<PocketIcInstance<PocketIc>, PocketIcStartupError> {
472 let startup = StartupDeadline::new(&self)?;
473 let (server, url) = match self {
474 Self::Connect { server_url, .. } => (None, server_url),
475 spawn @ Self::Spawn { .. } => {
476 let (server, url) = spawn.spawn_server(startup.started)?.wait_ready(&startup)?;
477 (Some(server), url)
478 }
479 };
480 build_bounded(builder, &url, &startup, server)
481 }
482
483 pub async fn build_instance_async(
519 self,
520 builder: PocketIcBuilder,
521 ) -> Result<PocketIcInstance<crate::pocket_ic::nonblocking::PocketIc>, PocketIcStartupError>
522 {
523 let startup = StartupDeadline::new(&self)?;
524 let (server, server_url) = match self {
525 Self::Connect { server_url, .. } => (None, server_url),
526 spawn @ Self::Spawn { .. } => {
527 let mut server = spawn.spawn_server(startup.started)?;
528 let server_url = loop {
529 match server.poll_ready(&startup) {
530 Ok(Some(url)) => break url,
531 Ok(None) => {
532 tokio::time::sleep(startup.wait_interval(Instant::now(), true)).await;
533 }
534 Err(error) => return Err(finalize_failure(error, None, Some(server))),
535 }
536 };
537 (Some(server), server_url)
538 }
539 };
540 build_async_bounded(builder, &server_url, &startup, server).await
541 }
542
543 pub fn start_managed_server(self) -> Result<PocketIcManagedServer, PocketIcStartupError> {
560 if matches!(self, Self::Connect { .. }) {
561 self.validate()?;
562 return Err(PocketIcStartupError::new(
563 PocketIcStartupFailure::InvalidConfiguration {
564 message: "starting a managed PocketIC server requires a spawn configuration"
565 .to_owned(),
566 },
567 ));
568 }
569 let startup = StartupDeadline::new(&self)?;
570 let (server, url) = self.spawn_server(startup.started)?.wait_ready(&startup)?;
571 Ok(PocketIcManagedServer { server, url })
572 }
573
574 fn spawn_server(self, started: Instant) -> Result<ManagedServer, PocketIcStartupError> {
577 let Self::Spawn {
578 server_binary,
579 server_hard_ttl,
580 server_idle_ttl,
581 server_output_files,
582 ..
583 } = self
584 else {
585 unreachable!("connect configurations do not spawn a server")
586 };
587 ManagedServer::spawn(
588 server_binary,
589 server_hard_ttl,
590 server_idle_ttl,
591 server_output_files,
592 started,
593 )
594 }
595
596 pub fn run_command(
616 self,
617 command: &mut Command,
618 mut cancelled: impl FnMut() -> bool,
619 ) -> Result<ExitStatus, PocketIcStartupError> {
620 self.validate()?;
621 let program = PathBuf::from(command.get_program());
622 let command_error = |source| {
623 PocketIcStartupError::new(PocketIcStartupFailure::CommandRun {
624 program: program.clone(),
625 source,
626 })
627 };
628 if cancelled() {
629 return Err(command_error(io::Error::from(io::ErrorKind::Interrupted)));
630 }
631 let (server, url) = match self {
632 Self::Connect { server_url, .. } => (None, server_url),
633 spawn @ Self::Spawn { .. } => {
634 let server = spawn.start_managed_server()?;
635 let url = server.url().to_owned();
636 (Some(server.server), url)
637 }
638 };
639 let mut server = server;
640 if cancelled() {
641 return Err(finalize_failure(
642 command_error(io::Error::from(io::ErrorKind::Interrupted)),
643 None,
644 server,
645 ));
646 }
647 command.env("IC_TESTKIT_POCKET_IC_URL", url);
648 let mut owned_child = match OwnedChild::spawn(command, CleanupPolicy::KillAndWait) {
649 Ok(child) => child,
650 Err(source) => {
651 return Err(finalize_failure(
652 PocketIcStartupError::new(PocketIcStartupFailure::Io {
653 operation: "spawn command with PocketIC server",
654 path: PathBuf::from(command.get_program()),
655 source,
656 }),
657 None,
658 server,
659 ));
660 }
661 };
662 let result = loop {
663 if cancelled() {
664 break Err(command_error(io::Error::from(io::ErrorKind::Interrupted)));
665 }
666 match owned_child.try_wait() {
667 Ok(Some(status)) => break Ok(status),
668 Ok(None) => {
669 if let Some(managed) = server.as_mut()
670 && let Err(error) = managed.check_running()
671 {
672 break Err(error);
673 }
674 thread::sleep(STARTUP_POLL_INTERVAL);
675 }
676 Err(source) => break Err(command_error(source)),
677 }
678 };
679 match result {
680 Err(error) => Err(finalize_failure(error, Some(&mut owned_child), server)),
681 Ok(status) => {
682 if let Some(server) = server {
683 let (output, cleanup) = server.terminate_and_capture();
684 if let Some(cleanup) = cleanup {
685 return Err(PocketIcStartupError::new(
686 PocketIcStartupFailure::ServerCleanup {
687 command_status: status,
688 },
689 )
690 .with_cleanup(None, Some((output, Some(cleanup)))));
691 }
692 }
693 Ok(status)
694 }
695 }
696 }
697
698 fn validate(&self) -> Result<(), PocketIcStartupError> {
699 if self.timeout().is_zero() {
700 return Err(PocketIcStartupError::new(
701 PocketIcStartupFailure::InvalidConfiguration {
702 message: "PocketIC startup timeout must be greater than zero".to_owned(),
703 },
704 ));
705 }
706 if let Self::Spawn {
707 server_hard_ttl,
708 server_idle_ttl,
709 ..
710 } = self
711 {
712 for (ttl, message) in [
713 (
714 server_hard_ttl,
715 "PocketIC server hard TTL must be at least one second",
716 ),
717 (
718 server_idle_ttl,
719 "PocketIC server idle TTL must be at least one second",
720 ),
721 ] {
722 if ttl.is_some_and(|ttl| ttl.as_secs() == 0) {
723 return Err(PocketIcStartupError::new(
724 PocketIcStartupFailure::InvalidConfiguration {
725 message: message.to_owned(),
726 },
727 ));
728 }
729 }
730 }
731 Ok(())
732 }
733}
734
735impl PocketIcManagedServer {
736 #[must_use]
743 pub fn process_id(&self) -> u32 {
744 self.server.child.id()
745 }
746
747 #[must_use]
749 pub fn url(&self) -> &str {
750 &self.url
751 }
752
753 #[must_use]
759 pub fn output(&self) -> PocketIcManagedServerOutput {
760 self.server.files.capture()
761 }
762}
763
764impl PocketIcManagedServerOutput {
765 #[must_use]
767 pub fn stdout(&self) -> &str {
768 &self.stdout
769 }
770
771 #[must_use]
773 pub fn stderr(&self) -> &str {
774 &self.stderr
775 }
776}
777
778struct StartupDeadline {
779 started: Instant,
780 timeout: Duration,
781 deadline: Instant,
782}
783
784impl StartupDeadline {
785 fn new(config: &PocketIcStartupConfig) -> Result<Self, PocketIcStartupError> {
786 config.validate()?;
787 let started = Instant::now();
788 let timeout = config.timeout();
789 Ok(Self {
790 started,
791 timeout,
792 deadline: startup_deadline(started, timeout)?,
793 })
794 }
795
796 fn check_instance_deadline(
797 &self,
798 server: &mut Option<ManagedServer>,
799 ) -> Result<Instant, PocketIcStartupError> {
800 let now = Instant::now();
801 if now >= self.deadline {
802 return Err(finalize_failure(
803 PocketIcStartupError::new(PocketIcStartupFailure::InstanceCreationTimeout {
804 timeout: self.timeout,
805 }),
806 None,
807 server.take(),
808 ));
809 }
810 Ok(now)
811 }
812
813 fn wait_interval(&self, now: Instant, monitor_server: bool) -> Duration {
814 let remaining = self.deadline.saturating_duration_since(now);
815 if monitor_server {
816 remaining.min(STARTUP_POLL_INTERVAL)
817 } else {
818 remaining
819 }
820 }
821
822 fn admit_instance<T>(
823 &self,
824 instance: T,
825 server_url: &str,
826 server: &mut Option<ManagedServer>,
827 ) -> Result<PocketIcInstance<T>, PocketIcStartupError> {
828 self.check_instance_deadline(server)?;
829 check_server(server)?;
830 Ok(PocketIcInstance {
831 instance,
832 server: server.take().map(|server| PocketIcManagedServer {
833 server,
834 url: server_url.to_owned(),
835 }),
836 })
837 }
838}
839
840fn startup_deadline(started: Instant, timeout: Duration) -> Result<Instant, PocketIcStartupError> {
841 started.checked_add(timeout).ok_or_else(|| {
842 PocketIcStartupError::new(PocketIcStartupFailure::InvalidConfiguration {
843 message: "PocketIC startup timeout exceeds the platform clock range".to_owned(),
844 })
845 })
846}
847
848struct InstanceStartup<'a> {
850 deadline: &'a StartupDeadline,
851 server_url: &'a str,
852 server: Option<ManagedServer>,
853}
854
855impl InstanceStartup<'_> {
856 fn configure_builder(
857 &mut self,
858 builder: PocketIcBuilder,
859 ) -> Result<PocketIcBuilder, PocketIcStartupError> {
860 configured_builder(builder, self.server_url).map_err(|error| self.fail(error))
861 }
862
863 fn check_deadline(&mut self) -> Result<Instant, PocketIcStartupError> {
864 self.deadline.check_instance_deadline(&mut self.server)
865 }
866
867 fn wait_interval(&self, now: Instant) -> Duration {
868 self.deadline.wait_interval(now, self.server.is_some())
869 }
870
871 fn check_server(&mut self) -> Result<(), PocketIcStartupError> {
872 check_server(&mut self.server)
873 }
874
875 fn admit<T>(&mut self, instance: T) -> Result<PocketIcInstance<T>, PocketIcStartupError> {
876 self.deadline
877 .admit_instance(instance, self.server_url, &mut self.server)
878 }
879
880 fn fail(&mut self, error: PocketIcStartupError) -> PocketIcStartupError {
881 finalize_failure(error, None, self.server.take())
882 }
883}
884
885fn build_bounded(
886 builder: PocketIcBuilder,
887 server_url: &str,
888 startup: &StartupDeadline,
889 server: Option<ManagedServer>,
890) -> Result<PocketIcInstance<PocketIc>, PocketIcStartupError> {
891 let mut startup = InstanceStartup {
892 deadline: startup,
893 server_url,
894 server,
895 };
896 let builder = startup.configure_builder(builder)?;
897 let (sender, receiver) = mpsc::sync_channel(1);
898 if let Err(source) = thread::Builder::new()
899 .name("ic-testkit-pocket-ic-startup".to_owned())
900 .spawn(move || {
901 let result = builder.build();
902 let _ = sender.send(result);
903 })
904 {
905 return Err(startup.fail(PocketIcStartupError::new(
906 PocketIcStartupFailure::BuilderThreadSpawn { source },
907 )));
908 }
909 await_builder(receiver, &mut startup)
910}
911
912#[expect(
913 clippy::needless_pass_by_value,
914 reason = "consume the receiver so failed startup disconnects late builders"
915)]
916fn await_builder(
917 receiver: mpsc::Receiver<Result<PocketIc, ClientError>>,
918 startup: &mut InstanceStartup<'_>,
919) -> Result<PocketIcInstance<PocketIc>, PocketIcStartupError> {
920 loop {
921 let now = startup.check_deadline()?;
922 let wait = startup.wait_interval(now);
923 match receiver.recv_timeout(wait) {
924 Ok(Ok(pocket_ic)) => {
925 return startup.admit(pocket_ic);
926 }
927 Ok(Err(source)) => {
928 return Err(startup.fail(PocketIcStartupError::new(
929 PocketIcStartupFailure::BuilderFailed { source },
930 )));
931 }
932 Err(RecvTimeoutError::Disconnected) => {
933 return Err(startup.fail(PocketIcStartupError::new(
934 PocketIcStartupFailure::BuilderDisconnected,
935 )));
936 }
937 Err(RecvTimeoutError::Timeout) => {
938 startup.check_server()?;
939 }
940 }
941 }
942}
943
944fn configured_builder(
945 builder: PocketIcBuilder,
946 server_url: &str,
947) -> Result<PocketIcBuilder, PocketIcStartupError> {
948 server_url
949 .parse()
950 .map(|url| builder.with_server_url(url))
951 .map_err(|error| {
952 PocketIcStartupError::new(PocketIcStartupFailure::InvalidServerUrl {
953 server_url: server_url.to_owned(),
954 message: error.to_string(),
955 })
956 })
957}
958
959fn check_server(server: &mut Option<ManagedServer>) -> Result<(), PocketIcStartupError> {
960 if let Some(managed) = server.as_mut()
961 && let Err(error) = managed.check_running()
962 {
963 return Err(finalize_failure(error, None, server.take()));
964 }
965 Ok(())
966}
967
968async fn build_async_bounded(
969 builder: PocketIcBuilder,
970 server_url: &str,
971 startup: &StartupDeadline,
972 server: Option<ManagedServer>,
973) -> Result<PocketIcInstance<crate::pocket_ic::nonblocking::PocketIc>, PocketIcStartupError> {
974 let mut startup = InstanceStartup {
975 deadline: startup,
976 server_url,
977 server,
978 };
979 let builder = startup.configure_builder(builder)?;
980 let mut construction = std::pin::pin!(builder.build_async());
981 let mut startup = startup;
983 loop {
984 let now = startup.check_deadline()?;
985 startup.check_server()?;
986 let next_check = now + startup.wait_interval(now);
987 match tokio::time::timeout_at(next_check.into(), construction.as_mut()).await {
988 Ok(Ok(instance)) => {
989 return startup.admit(instance);
990 }
991 Ok(Err(source)) => {
992 return Err(startup.fail(PocketIcStartupError::new(
993 PocketIcStartupFailure::BuilderFailed { source },
994 )));
995 }
996 Err(_) => {}
997 }
998 }
999}
1000
1001fn finalize_failure(
1003 error: PocketIcStartupError,
1004 command: Option<&mut OwnedChild>,
1005 server: Option<ManagedServer>,
1006) -> PocketIcStartupError {
1007 error.with_cleanup(
1008 command.and_then(|child| child.terminate().err()),
1009 server.map(ManagedServer::terminate_and_capture),
1010 )
1011}
1012
1013struct ManagedServer {
1014 child: OwnedChild,
1015 binary: PathBuf,
1016 files: StartupFiles,
1017 started: Instant,
1018}
1019
1020enum PortFileState {
1021 Pending,
1022 Ready(u16),
1023 Invalid(String),
1024}
1025
1026impl ManagedServer {
1027 fn wait_ready(
1028 mut self,
1029 startup: &StartupDeadline,
1030 ) -> Result<(Self, String), PocketIcStartupError> {
1031 loop {
1032 match self.poll_ready(startup) {
1033 Ok(Some(url)) => return Ok((self, url)),
1034 Ok(None) => thread::sleep(startup.wait_interval(Instant::now(), true)),
1035 Err(error) => return Err(finalize_failure(error, None, Some(self))),
1036 }
1037 }
1038 }
1039
1040 fn spawn(
1041 binary: PathBuf,
1042 hard_ttl: Option<Duration>,
1043 idle_ttl: Option<Duration>,
1044 output_files: Option<(PathBuf, PathBuf)>,
1045 started: Instant,
1046 ) -> Result<Self, PocketIcStartupError> {
1047 let (files, stdout, stderr) = StartupFiles::create(output_files)?;
1048 let mut command = Command::new(&binary);
1049 if let Some(hard_ttl) = hard_ttl {
1050 command
1051 .arg("--hard-ttl")
1052 .arg(hard_ttl.as_secs().to_string());
1053 }
1054 if let Some(idle_ttl) = idle_ttl {
1055 command.arg("--ttl").arg(idle_ttl.as_secs().to_string());
1056 }
1057 command
1058 .arg("--port-file")
1059 .arg(&files.port)
1060 .stdout(Stdio::from(stdout))
1061 .stderr(Stdio::from(stderr));
1062 let child =
1063 OwnedChild::spawn(&mut command, CleanupPolicy::KillAndWait).map_err(|source| {
1064 PocketIcStartupError::new(PocketIcStartupFailure::ServerSpawn {
1065 server_binary: binary.clone(),
1066 source,
1067 })
1068 })?;
1069 Ok(Self {
1070 child,
1071 binary,
1072 files,
1073 started,
1074 })
1075 }
1076
1077 fn poll_ready(
1078 &mut self,
1079 startup: &StartupDeadline,
1080 ) -> Result<Option<String>, PocketIcStartupError> {
1081 self.check_running()?;
1082 if Instant::now() >= startup.deadline {
1083 return Err(PocketIcStartupError::new(
1084 PocketIcStartupFailure::ReadinessTimeout {
1085 server_binary: self.binary.clone(),
1086 timeout: startup.timeout,
1087 },
1088 ));
1089 }
1090 match self.files.read_port()? {
1091 PortFileState::Pending => Ok(None),
1092 PortFileState::Ready(port) => Ok(Some(format!("http://127.0.0.1:{port}/"))),
1093 PortFileState::Invalid(value) => Err(PocketIcStartupError::new(
1094 PocketIcStartupFailure::InvalidServerPort {
1095 server_binary: self.binary.clone(),
1096 value,
1097 },
1098 )),
1099 }
1100 }
1101
1102 fn check_running(&mut self) -> Result<(), PocketIcStartupError> {
1103 if let Some(status) = self.try_wait()? {
1104 return Err(self.exit_failure(status));
1105 }
1106 Ok(())
1107 }
1108
1109 fn try_wait(&mut self) -> Result<Option<ExitStatus>, PocketIcStartupError> {
1110 self.child.try_wait().map_err(|source| {
1111 PocketIcStartupError::new(PocketIcStartupFailure::Io {
1112 operation: "inspect PocketIC server child",
1113 path: self.binary.clone(),
1114 source,
1115 })
1116 })
1117 }
1118
1119 fn exit_failure(&self, status: ExitStatus) -> PocketIcStartupError {
1120 PocketIcStartupError::new(PocketIcStartupFailure::ServerExited {
1121 server_binary: self.binary.clone(),
1122 status,
1123 elapsed: self.started.elapsed(),
1124 })
1125 }
1126
1127 fn terminate_and_capture(self) -> (PocketIcManagedServerOutput, Option<CleanupError>) {
1128 let Self {
1129 mut child, files, ..
1130 } = self;
1131 let cleanup = child.terminate().err();
1132 drop(child);
1134 (files.capture(), cleanup)
1135 }
1136}
1137
1138struct StartupFiles {
1139 #[cfg_attr(
1140 not(test),
1141 expect(
1142 dead_code,
1143 reason = "retains directory ownership until cleanup or drop"
1144 )
1145 )]
1146 directory: OwnedDirectory,
1147 port: PathBuf,
1148 stdout: PathBuf,
1149 stderr: PathBuf,
1150}
1151
1152impl StartupFiles {
1153 fn read_port(&self) -> Result<PortFileState, PocketIcStartupError> {
1154 let port_path = &self.port;
1155 let contents =
1156 match ic_host_fs::read::read_file_prefix(port_path, SERVER_PORT_FILE_LIMIT + 1)
1157 .map_err(|error| match error {
1158 ic_host_artifacts::artifact::ArtifactError::Io(source) => source,
1159 error => io::Error::new(io::ErrorKind::InvalidData, error),
1160 })
1161 .and_then(|(bytes, _)| {
1162 String::from_utf8(bytes)
1163 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
1164 }) {
1165 Ok(contents) => contents,
1166 Err(error) if error.kind() == io::ErrorKind::NotFound => {
1167 return Ok(PortFileState::Pending);
1168 }
1169 Err(source) => {
1170 return Err(PocketIcStartupError::new(PocketIcStartupFailure::Io {
1171 operation: "read PocketIC server port file",
1172 path: port_path.clone(),
1173 source,
1174 }));
1175 }
1176 };
1177 if contents.len() > SERVER_PORT_FILE_LIMIT {
1178 return Ok(PortFileState::Invalid(format!(
1179 "{} (port file exceeds {SERVER_PORT_FILE_LIMIT} bytes)",
1180 contents.trim()
1181 )));
1182 }
1183 if !contents.contains('\n') {
1184 return Ok(PortFileState::Pending);
1185 }
1186 let value = contents.trim();
1187 match value.parse::<u16>() {
1188 Ok(port) if port != 0 => Ok(PortFileState::Ready(port)),
1189 _ => Ok(PortFileState::Invalid(value.to_owned())),
1190 }
1191 }
1192
1193 fn capture(&self) -> PocketIcManagedServerOutput {
1194 PocketIcManagedServerOutput {
1195 stdout: read_bounded_lossy(&self.stdout),
1196 stderr: read_bounded_lossy(&self.stderr),
1197 }
1198 }
1199
1200 fn create(
1201 output_files: Option<(PathBuf, PathBuf)>,
1202 ) -> Result<(Self, File, File), PocketIcStartupError> {
1203 let output_files = output_files
1204 .map(|(stdout, stderr)| {
1205 Ok::<_, PocketIcStartupError>((
1206 std::path::absolute(&stdout)
1207 .map_err(|source| startup_file_error("resolve", &stdout, source))?,
1208 std::path::absolute(&stderr)
1209 .map_err(|source| startup_file_error("resolve", &stderr, source))?,
1210 ))
1211 })
1212 .transpose()?;
1213 let directory = OwnedDirectory::new(
1214 create_unique_directory(
1215 &std::env::temp_dir(),
1216 "ic-testkit-pocket-ic-startup",
1217 0o700,
1218 None,
1219 )
1220 .map_err(|source| startup_file_error("create", &std::env::temp_dir(), source))?,
1221 );
1222 let base = directory.path();
1223 let (stdout_path, stderr_path) =
1224 output_files.unwrap_or_else(|| (base.join("stdout"), base.join("stderr")));
1225 let files = Self {
1226 port: base.join("port"),
1227 stdout: stdout_path,
1228 stderr: stderr_path,
1229 directory,
1230 };
1231 let stdout = create_new_file(&files.stdout)
1232 .map_err(|source| startup_file_error("create", &files.stdout, source))?;
1233 let stderr = create_new_file(&files.stderr)
1234 .map_err(|source| startup_file_error("create", &files.stderr, source))?;
1235 Ok((files, stdout, stderr))
1236 }
1237}
1238
1239fn create_new_file(path: &Path) -> io::Result<File> {
1240 let mut options = OpenOptions::new();
1241 options.write(true).create_new(true);
1242 #[cfg(unix)]
1243 options.mode(0o600);
1244 options.open(path)
1245}
1246
1247fn startup_file_error(
1248 operation: &'static str,
1249 path: &Path,
1250 source: io::Error,
1251) -> PocketIcStartupError {
1252 PocketIcStartupError::new(PocketIcStartupFailure::Io {
1253 operation,
1254 path: path.to_owned(),
1255 source,
1256 })
1257}
1258
1259fn read_bounded_lossy(path: &Path) -> String {
1260 let Ok((bytes, length)) = ic_host_fs::read::read_file_prefix(path, SERVER_OUTPUT_LIMIT) else {
1261 return String::new();
1262 };
1263 let mut output = String::from_utf8_lossy(&bytes).into_owned();
1264 let omitted = length.saturating_sub(bytes.len() as u64);
1265 if omitted > 0 {
1266 let _ = write!(output, "\n<truncated {omitted} bytes>");
1267 }
1268 output
1269}
1270
1271impl std::fmt::Display for PocketIcStartupError {
1272 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1273 std::fmt::Display::fmt(self.failure.as_ref(), formatter)?;
1274 if !self.output.stdout.is_empty() {
1275 write!(formatter, "; server stdout: {}", self.output.stdout)?;
1276 }
1277 if !self.output.stderr.is_empty() {
1278 write!(formatter, "; server stderr: {}", self.output.stderr)?;
1279 }
1280 for (owner, cleanup) in [
1281 ("command", self.command_cleanup()),
1282 ("server", self.server_cleanup()),
1283 ] {
1284 if let Some(error) = cleanup {
1285 write!(formatter, "; {owner} cleanup also failed: {error}")?;
1286 }
1287 }
1288 Ok(())
1289 }
1290}
1291
1292impl std::error::Error for PocketIcStartupError {
1293 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1294 std::error::Error::source(self.failure.as_ref())
1295 }
1296}
1297
1298#[cfg(test)]
1299mod tests;