1use std::{
2 fmt::Write as _,
3 fs::{self, File, OpenOptions},
4 io,
5 path::{Path, PathBuf},
6 process::{Command, ExitStatus, Stdio},
7 thread,
8 time::{Duration, Instant},
9};
10
11#[cfg(unix)]
12use std::os::unix::fs::OpenOptionsExt as _;
13
14use crate::pocket_ic::{ClientError, PocketIc, PocketIcBuilder};
15use ic_host_fs::{directory::create_unique_directory, owned_directory::OwnedDirectory};
16use ic_host_process::child::{CleanupError, CleanupPolicy, OwnedChild};
17
18const STARTUP_POLL_INTERVAL: Duration = Duration::from_millis(20);
19const SERVER_OUTPUT_LIMIT: usize = 16 * 1024;
20const SERVER_PORT_FILE_LIMIT: usize = 64;
23
24#[derive(Clone, Debug, Eq, PartialEq)]
29pub enum PocketIcStartupConfig {
30 Spawn {
32 server_binary: PathBuf,
34 timeout: Duration,
36 server_hard_ttl: Option<Duration>,
38 server_idle_ttl: Option<Duration>,
40 server_output_files: Option<(PathBuf, PathBuf)>,
43 },
44 Connect {
46 server_url: String,
48 timeout: Duration,
50 },
51}
52
53pub struct PocketIcManagedServer {
64 server: ManagedServer,
65 url: String,
66}
67
68#[must_use]
89pub struct PocketIcInstance<T> {
90 instance: T,
91 server: Option<PocketIcManagedServer>,
92}
93
94impl<T> PocketIcInstance<T> {
95 pub const fn from_borrowed_server(instance: T) -> Self {
101 Self {
102 instance,
103 server: None,
104 }
105 }
106
107 #[must_use]
109 pub const fn instance(&self) -> &T {
110 &self.instance
111 }
112
113 pub const fn instance_mut(&mut self) -> &mut T {
125 &mut self.instance
126 }
127
128 #[must_use]
130 pub const fn server(&self) -> Option<&PocketIcManagedServer> {
131 self.server.as_ref()
132 }
133
134 #[must_use]
140 pub fn into_parts(self) -> (T, Option<PocketIcManagedServer>) {
141 (self.instance, self.server)
142 }
143}
144
145impl PocketIcInstance<PocketIc> {
146 pub async fn close(self) -> Result<(), ClientError> {
151 let (instance, server) = self.into_parts();
152 let result = instance.drop().await;
153 drop(server);
154 result
155 }
156}
157
158#[derive(Clone, Debug, Default, Eq, PartialEq)]
164pub struct PocketIcManagedServerOutput {
165 stdout: String,
166 stderr: String,
167}
168
169#[derive(Debug)]
175pub struct PocketIcStartupError {
176 failure: Box<PocketIcStartupFailure>,
177 output: PocketIcManagedServerOutput,
178 command_cleanup: Option<Box<CleanupError>>,
179 server_cleanup: Option<Box<CleanupError>>,
180}
181
182impl PocketIcStartupError {
183 fn new(failure: PocketIcStartupFailure) -> Self {
184 Self {
185 failure: Box::new(failure),
186 output: PocketIcManagedServerOutput::default(),
187 command_cleanup: None,
188 server_cleanup: None,
189 }
190 }
191
192 #[must_use]
194 pub fn failure(&self) -> &PocketIcStartupFailure {
195 &self.failure
196 }
197
198 #[must_use]
200 pub const fn output(&self) -> &PocketIcManagedServerOutput {
201 &self.output
202 }
203
204 #[must_use]
206 pub fn command_cleanup(&self) -> Option<&CleanupError> {
207 self.command_cleanup.as_deref()
208 }
209
210 #[must_use]
212 pub fn server_cleanup(&self) -> Option<&CleanupError> {
213 self.server_cleanup.as_deref()
214 }
215
216 fn with_cleanup(
217 mut self,
218 command: Option<CleanupError>,
219 server: Option<(PocketIcManagedServerOutput, Option<CleanupError>)>,
220 ) -> Self {
221 self.command_cleanup = command.map(Box::new);
222 if let Some((output, cleanup)) = server {
223 self.output = output;
224 self.server_cleanup = cleanup.map(Box::new);
225 }
226 self
227 }
228}
229
230#[non_exhaustive]
232#[derive(Debug, thiserror::Error)]
233pub enum PocketIcStartupFailure {
234 #[error(
236 "configure IC_TESTKIT_POCKET_IC_URL or POCKET_IC_BIN; prepare a verified binary with make install-server"
237 )]
238 NotConfigured,
239 #[error("invalid selected environment value: {variable}")]
241 InvalidEnvironment {
242 variable: &'static str,
244 },
245 #[error("PocketIC version probe failed: {source}")]
247 ServerVersionProbe {
248 source: ic_host_process::tool::ToolError,
250 },
251 #[error("PocketIC version mismatch: expected {expected:?}, observed {:?}", String::from_utf8_lossy(.observed))]
253 ServerVersionMismatch {
254 expected: String,
256 observed: Vec<u8>,
258 },
259 #[error("command {} failed: {source}", .program.display())]
261 CommandRun {
262 program: PathBuf,
264 source: io::Error,
266 },
267 #[error("{message}")]
269 InvalidConfiguration {
270 message: String,
272 },
273 #[error("invalid PocketIC server URL {server_url:?}: {message}")]
275 InvalidServerUrl {
276 server_url: String,
278 message: String,
280 },
281 #[error("failed to {operation} at {}: {source}", .path.display())]
283 Io {
284 operation: &'static str,
286 path: PathBuf,
288 source: io::Error,
290 },
291 #[error("failed to spawn PocketIC server {}: {source}", .server_binary.display())]
293 ServerSpawn {
294 server_binary: PathBuf,
296 source: io::Error,
298 },
299 #[error("PocketIC server {} exited with {status} after {elapsed:?}", .server_binary.display())]
301 ServerExited {
302 server_binary: PathBuf,
304 status: ExitStatus,
306 elapsed: Duration,
308 },
309 #[error("PocketIC server {} was not ready within {timeout:?}", .server_binary.display())]
311 ReadinessTimeout {
312 server_binary: PathBuf,
314 timeout: Duration,
316 },
317 #[error("PocketIC server {} published invalid port {value:?}", .server_binary.display())]
319 InvalidServerPort {
320 server_binary: PathBuf,
322 value: String,
324 },
325 #[error("PocketIC instance creation exceeded {timeout:?}")]
327 InstanceCreationTimeout {
328 timeout: Duration,
330 },
331 #[error("PocketIC startup failed: {source}")]
333 BuilderFailed {
334 source: ClientError,
336 },
337 #[error("PocketIC server cleanup failed after command exited with {command_status}")]
339 ServerCleanup {
340 command_status: ExitStatus,
342 },
343}
344
345impl PocketIcStartupConfig {
346 pub fn from_env(timeout: Duration) -> Result<Self, PocketIcStartupError> {
360 Self::from_environment(timeout, |name| std::env::var_os(name))
361 }
362
363 fn from_environment(
364 timeout: Duration,
365 mut variable: impl FnMut(&str) -> Option<std::ffi::OsString>,
366 ) -> Result<Self, PocketIcStartupError> {
367 if let Some(value) = variable("IC_TESTKIT_POCKET_IC_URL") {
368 let server_url = value
369 .into_string()
370 .ok()
371 .filter(|value| !value.is_empty())
372 .ok_or_else(|| {
373 PocketIcStartupError::new(PocketIcStartupFailure::InvalidEnvironment {
374 variable: "IC_TESTKIT_POCKET_IC_URL",
375 })
376 })?;
377 let config = Self::Connect {
378 server_url: (&server_url).into(),
379 timeout,
380 };
381 config.validate()?;
382 let _ = configured_builder(PocketIcBuilder::new(), &server_url)?;
383 return Ok(config);
384 }
385 let value = variable("POCKET_IC_BIN")
386 .ok_or_else(|| PocketIcStartupError::new(PocketIcStartupFailure::NotConfigured))?;
387 if value.is_empty() {
388 return Err(PocketIcStartupError::new(
389 PocketIcStartupFailure::InvalidEnvironment {
390 variable: "POCKET_IC_BIN",
391 },
392 ));
393 }
394 let path = PathBuf::from(value);
395 let binary = fs::canonicalize(&path).map_err(|source| {
396 PocketIcStartupError::new(PocketIcStartupFailure::Io {
397 operation: "resolve configured PocketIC executable",
398 path,
399 source,
400 })
401 })?;
402 let config = Self::Spawn {
403 server_binary: (&binary).into(),
404 timeout,
405 server_hard_ttl: None,
406 server_idle_ttl: None,
407 server_output_files: None,
408 };
409 config.validate()?;
410 let evidence = ic_host_process::tool::capture_group_command(
411 Command::new(&binary).arg("--version"),
412 ic_host_process::tool::OutputLimits {
413 stdout: ic_host_process::tool::OutputLimit::Terminate(SERVER_OUTPUT_LIMIT),
414 stderr: ic_host_process::tool::OutputLimit::Terminate(SERVER_OUTPUT_LIMIT),
415 timeout: Some(timeout),
416 },
417 )
418 .and_then(ic_host_process::tool::CompletedExecution::require_success)
419 .map_err(|source| {
420 PocketIcStartupError::new(PocketIcStartupFailure::ServerVersionProbe { source })
421 })?;
422 let expected = super::server::pocket_ic_server_requirement();
423 if !super::supports_pocket_ic_server(&evidence.stdout) {
424 return Err(PocketIcStartupError::new(
425 PocketIcStartupFailure::ServerVersionMismatch {
426 expected,
427 observed: evidence.stdout,
428 },
429 ));
430 }
431 Ok(config)
432 }
433
434 #[must_use]
436 pub const fn timeout(&self) -> Duration {
437 match self {
438 Self::Spawn { timeout, .. } | Self::Connect { timeout, .. } => *timeout,
439 }
440 }
441
442 pub async fn build_instance(
477 self,
478 builder: PocketIcBuilder,
479 ) -> Result<PocketIcInstance<PocketIc>, PocketIcStartupError> {
480 let startup = StartupDeadline::new(&self)?;
481 let (server, server_url) = match self {
482 Self::Connect { server_url, .. } => (None, server_url),
483 spawn @ Self::Spawn { .. } => {
484 let mut server = spawn.spawn_server(startup.started)?;
485 let server_url = loop {
486 match server.poll_ready(&startup) {
487 Ok(Some(url)) => break url,
488 Ok(None) => {
489 tokio::time::sleep(startup.wait_interval(Instant::now(), true)).await;
490 }
491 Err(error) => return Err(finalize_failure(error, None, Some(server))),
492 }
493 };
494 (Some(server), server_url)
495 }
496 };
497 build_bounded(builder, &server_url, &startup, server).await
498 }
499
500 pub fn start_managed_server(self) -> Result<PocketIcManagedServer, PocketIcStartupError> {
517 if matches!(self, Self::Connect { .. }) {
518 self.validate()?;
519 return Err(PocketIcStartupError::new(
520 PocketIcStartupFailure::InvalidConfiguration {
521 message: "starting a managed PocketIC server requires a spawn configuration"
522 .to_owned(),
523 },
524 ));
525 }
526 let startup = StartupDeadline::new(&self)?;
527 let (server, url) = self.spawn_server(startup.started)?.wait_ready(&startup)?;
528 Ok(PocketIcManagedServer { server, url })
529 }
530
531 fn spawn_server(self, started: Instant) -> Result<ManagedServer, PocketIcStartupError> {
534 let Self::Spawn {
535 server_binary,
536 server_hard_ttl,
537 server_idle_ttl,
538 server_output_files,
539 ..
540 } = self
541 else {
542 unreachable!("connect configurations do not spawn a server")
543 };
544 ManagedServer::spawn(
545 server_binary,
546 server_hard_ttl,
547 server_idle_ttl,
548 server_output_files,
549 started,
550 )
551 }
552
553 pub fn run_command(
573 self,
574 command: &mut Command,
575 mut cancelled: impl FnMut() -> bool,
576 ) -> Result<ExitStatus, PocketIcStartupError> {
577 self.validate()?;
578 let program = PathBuf::from(command.get_program());
579 let command_error = |source| {
580 PocketIcStartupError::new(PocketIcStartupFailure::CommandRun {
581 program: program.clone(),
582 source,
583 })
584 };
585 if cancelled() {
586 return Err(command_error(io::Error::from(io::ErrorKind::Interrupted)));
587 }
588 let (server, url) = match self {
589 Self::Connect { server_url, .. } => (None, server_url),
590 spawn @ Self::Spawn { .. } => {
591 let server = spawn.start_managed_server()?;
592 let url = server.url().to_owned();
593 (Some(server.server), url)
594 }
595 };
596 let mut server = server;
597 if cancelled() {
598 return Err(finalize_failure(
599 command_error(io::Error::from(io::ErrorKind::Interrupted)),
600 None,
601 server,
602 ));
603 }
604 command.env("IC_TESTKIT_POCKET_IC_URL", url);
605 let mut owned_child = match OwnedChild::spawn(command, CleanupPolicy::KillAndWait) {
606 Ok(child) => child,
607 Err(source) => {
608 return Err(finalize_failure(
609 PocketIcStartupError::new(PocketIcStartupFailure::Io {
610 operation: "spawn command with PocketIC server",
611 path: PathBuf::from(command.get_program()),
612 source,
613 }),
614 None,
615 server,
616 ));
617 }
618 };
619 let result = loop {
620 if cancelled() {
621 break Err(command_error(io::Error::from(io::ErrorKind::Interrupted)));
622 }
623 match owned_child.try_wait() {
624 Ok(Some(status)) => break Ok(status),
625 Ok(None) => {
626 if let Some(managed) = server.as_mut()
627 && let Err(error) = managed.check_running()
628 {
629 break Err(error);
630 }
631 thread::sleep(STARTUP_POLL_INTERVAL);
632 }
633 Err(source) => break Err(command_error(source)),
634 }
635 };
636 match result {
637 Err(error) => Err(finalize_failure(error, Some(&mut owned_child), server)),
638 Ok(status) => {
639 if let Some(server) = server {
640 let (output, cleanup) = server.terminate_and_capture();
641 if let Some(cleanup) = cleanup {
642 return Err(PocketIcStartupError::new(
643 PocketIcStartupFailure::ServerCleanup {
644 command_status: status,
645 },
646 )
647 .with_cleanup(None, Some((output, Some(cleanup)))));
648 }
649 }
650 Ok(status)
651 }
652 }
653 }
654
655 fn validate(&self) -> Result<(), PocketIcStartupError> {
656 if self.timeout().is_zero() {
657 return Err(PocketIcStartupError::new(
658 PocketIcStartupFailure::InvalidConfiguration {
659 message: "PocketIC startup timeout must be greater than zero".to_owned(),
660 },
661 ));
662 }
663 if let Self::Spawn {
664 server_hard_ttl,
665 server_idle_ttl,
666 ..
667 } = self
668 {
669 for (ttl, message) in [
670 (
671 server_hard_ttl,
672 "PocketIC server hard TTL must be at least one second",
673 ),
674 (
675 server_idle_ttl,
676 "PocketIC server idle TTL must be at least one second",
677 ),
678 ] {
679 if ttl.is_some_and(|ttl| ttl.as_secs() == 0) {
680 return Err(PocketIcStartupError::new(
681 PocketIcStartupFailure::InvalidConfiguration {
682 message: message.to_owned(),
683 },
684 ));
685 }
686 }
687 }
688 Ok(())
689 }
690}
691
692impl PocketIcManagedServer {
693 #[must_use]
700 pub fn process_id(&self) -> u32 {
701 self.server.child.id()
702 }
703
704 #[must_use]
706 pub fn url(&self) -> &str {
707 &self.url
708 }
709
710 #[must_use]
716 pub fn output(&self) -> PocketIcManagedServerOutput {
717 self.server.files.capture()
718 }
719}
720
721impl PocketIcManagedServerOutput {
722 #[must_use]
724 pub fn stdout(&self) -> &str {
725 &self.stdout
726 }
727
728 #[must_use]
730 pub fn stderr(&self) -> &str {
731 &self.stderr
732 }
733}
734
735struct StartupDeadline {
736 started: Instant,
737 timeout: Duration,
738 deadline: Instant,
739}
740
741impl StartupDeadline {
742 fn new(config: &PocketIcStartupConfig) -> Result<Self, PocketIcStartupError> {
743 config.validate()?;
744 let started = Instant::now();
745 let timeout = config.timeout();
746 Ok(Self {
747 started,
748 timeout,
749 deadline: startup_deadline(started, timeout)?,
750 })
751 }
752
753 fn check_instance_deadline(
754 &self,
755 server: &mut Option<ManagedServer>,
756 ) -> Result<Instant, PocketIcStartupError> {
757 let now = Instant::now();
758 if now >= self.deadline {
759 return Err(finalize_failure(
760 PocketIcStartupError::new(PocketIcStartupFailure::InstanceCreationTimeout {
761 timeout: self.timeout,
762 }),
763 None,
764 server.take(),
765 ));
766 }
767 Ok(now)
768 }
769
770 fn wait_interval(&self, now: Instant, monitor_server: bool) -> Duration {
771 let remaining = self.deadline.saturating_duration_since(now);
772 if monitor_server {
773 remaining.min(STARTUP_POLL_INTERVAL)
774 } else {
775 remaining
776 }
777 }
778
779 fn admit_instance<T>(
780 &self,
781 instance: T,
782 server_url: &str,
783 server: &mut Option<ManagedServer>,
784 ) -> Result<PocketIcInstance<T>, PocketIcStartupError> {
785 self.check_instance_deadline(server)?;
786 check_server(server)?;
787 Ok(PocketIcInstance {
788 instance,
789 server: server.take().map(|server| PocketIcManagedServer {
790 server,
791 url: server_url.to_owned(),
792 }),
793 })
794 }
795}
796
797fn startup_deadline(started: Instant, timeout: Duration) -> Result<Instant, PocketIcStartupError> {
798 started.checked_add(timeout).ok_or_else(|| {
799 PocketIcStartupError::new(PocketIcStartupFailure::InvalidConfiguration {
800 message: "PocketIC startup timeout exceeds the platform clock range".to_owned(),
801 })
802 })
803}
804
805struct InstanceStartup<'a> {
807 deadline: &'a StartupDeadline,
808 server_url: &'a str,
809 server: Option<ManagedServer>,
810}
811
812impl InstanceStartup<'_> {
813 fn configure_builder(
814 &mut self,
815 builder: PocketIcBuilder,
816 ) -> Result<PocketIcBuilder, PocketIcStartupError> {
817 configured_builder(builder, self.server_url).map_err(|error| self.fail(error))
818 }
819
820 fn check_deadline(&mut self) -> Result<Instant, PocketIcStartupError> {
821 self.deadline.check_instance_deadline(&mut self.server)
822 }
823
824 fn wait_interval(&self, now: Instant) -> Duration {
825 self.deadline.wait_interval(now, self.server.is_some())
826 }
827
828 fn check_server(&mut self) -> Result<(), PocketIcStartupError> {
829 check_server(&mut self.server)
830 }
831
832 fn admit<T>(&mut self, instance: T) -> Result<PocketIcInstance<T>, PocketIcStartupError> {
833 self.deadline
834 .admit_instance(instance, self.server_url, &mut self.server)
835 }
836
837 fn fail(&mut self, error: PocketIcStartupError) -> PocketIcStartupError {
838 finalize_failure(error, None, self.server.take())
839 }
840}
841
842fn configured_builder(
843 builder: PocketIcBuilder,
844 server_url: &str,
845) -> Result<PocketIcBuilder, PocketIcStartupError> {
846 server_url
847 .parse()
848 .map(|url| builder.with_server_url(url))
849 .map_err(|error| {
850 PocketIcStartupError::new(PocketIcStartupFailure::InvalidServerUrl {
851 server_url: server_url.to_owned(),
852 message: error.to_string(),
853 })
854 })
855}
856
857fn check_server(server: &mut Option<ManagedServer>) -> Result<(), PocketIcStartupError> {
858 if let Some(managed) = server.as_mut()
859 && let Err(error) = managed.check_running()
860 {
861 return Err(finalize_failure(error, None, server.take()));
862 }
863 Ok(())
864}
865
866async fn build_bounded(
867 builder: PocketIcBuilder,
868 server_url: &str,
869 startup: &StartupDeadline,
870 server: Option<ManagedServer>,
871) -> Result<PocketIcInstance<PocketIc>, PocketIcStartupError> {
872 let mut startup = InstanceStartup {
873 deadline: startup,
874 server_url,
875 server,
876 };
877 let builder = startup.configure_builder(builder)?;
878 let mut construction = std::pin::pin!(builder.build());
879 let mut startup = startup;
881 loop {
882 let now = startup.check_deadline()?;
883 startup.check_server()?;
884 let next_check = now + startup.wait_interval(now);
885 match tokio::time::timeout_at(next_check.into(), construction.as_mut()).await {
886 Ok(Ok(instance)) => {
887 return startup.admit(instance);
888 }
889 Ok(Err(source)) => {
890 return Err(startup.fail(PocketIcStartupError::new(
891 PocketIcStartupFailure::BuilderFailed { source },
892 )));
893 }
894 Err(_) => {}
895 }
896 }
897}
898
899fn finalize_failure(
901 error: PocketIcStartupError,
902 command: Option<&mut OwnedChild>,
903 server: Option<ManagedServer>,
904) -> PocketIcStartupError {
905 error.with_cleanup(
906 command.and_then(|child| child.terminate().err()),
907 server.map(ManagedServer::terminate_and_capture),
908 )
909}
910
911struct ManagedServer {
912 child: OwnedChild,
913 binary: PathBuf,
914 files: StartupFiles,
915 started: Instant,
916}
917
918enum PortFileState {
919 Pending,
920 Ready(u16),
921 Invalid(String),
922}
923
924impl ManagedServer {
925 fn wait_ready(
926 mut self,
927 startup: &StartupDeadline,
928 ) -> Result<(Self, String), PocketIcStartupError> {
929 loop {
930 match self.poll_ready(startup) {
931 Ok(Some(url)) => return Ok((self, url)),
932 Ok(None) => thread::sleep(startup.wait_interval(Instant::now(), true)),
933 Err(error) => return Err(finalize_failure(error, None, Some(self))),
934 }
935 }
936 }
937
938 fn spawn(
939 binary: PathBuf,
940 hard_ttl: Option<Duration>,
941 idle_ttl: Option<Duration>,
942 output_files: Option<(PathBuf, PathBuf)>,
943 started: Instant,
944 ) -> Result<Self, PocketIcStartupError> {
945 let (files, stdout, stderr) = StartupFiles::create(output_files)?;
946 let mut command = Command::new(&binary);
947 if let Some(hard_ttl) = hard_ttl {
948 command
949 .arg("--hard-ttl")
950 .arg(hard_ttl.as_secs().to_string());
951 }
952 if let Some(idle_ttl) = idle_ttl {
953 command.arg("--ttl").arg(idle_ttl.as_secs().to_string());
954 }
955 command
956 .arg("--port-file")
957 .arg(&files.port)
958 .stdout(Stdio::from(stdout))
959 .stderr(Stdio::from(stderr));
960 let child =
961 OwnedChild::spawn(&mut command, CleanupPolicy::KillAndWait).map_err(|source| {
962 PocketIcStartupError::new(PocketIcStartupFailure::ServerSpawn {
963 server_binary: binary.clone(),
964 source,
965 })
966 })?;
967 Ok(Self {
968 child,
969 binary,
970 files,
971 started,
972 })
973 }
974
975 fn poll_ready(
976 &mut self,
977 startup: &StartupDeadline,
978 ) -> Result<Option<String>, PocketIcStartupError> {
979 self.check_running()?;
980 if Instant::now() >= startup.deadline {
981 return Err(PocketIcStartupError::new(
982 PocketIcStartupFailure::ReadinessTimeout {
983 server_binary: self.binary.clone(),
984 timeout: startup.timeout,
985 },
986 ));
987 }
988 match self.files.read_port()? {
989 PortFileState::Pending => Ok(None),
990 PortFileState::Ready(port) => Ok(Some(format!("http://127.0.0.1:{port}/"))),
991 PortFileState::Invalid(value) => Err(PocketIcStartupError::new(
992 PocketIcStartupFailure::InvalidServerPort {
993 server_binary: self.binary.clone(),
994 value,
995 },
996 )),
997 }
998 }
999
1000 fn check_running(&mut self) -> Result<(), PocketIcStartupError> {
1001 if let Some(status) = self.try_wait()? {
1002 return Err(self.exit_failure(status));
1003 }
1004 Ok(())
1005 }
1006
1007 fn try_wait(&mut self) -> Result<Option<ExitStatus>, PocketIcStartupError> {
1008 self.child.try_wait().map_err(|source| {
1009 PocketIcStartupError::new(PocketIcStartupFailure::Io {
1010 operation: "inspect PocketIC server child",
1011 path: self.binary.clone(),
1012 source,
1013 })
1014 })
1015 }
1016
1017 fn exit_failure(&self, status: ExitStatus) -> PocketIcStartupError {
1018 PocketIcStartupError::new(PocketIcStartupFailure::ServerExited {
1019 server_binary: self.binary.clone(),
1020 status,
1021 elapsed: self.started.elapsed(),
1022 })
1023 }
1024
1025 fn terminate_and_capture(self) -> (PocketIcManagedServerOutput, Option<CleanupError>) {
1026 let Self {
1027 mut child, files, ..
1028 } = self;
1029 let cleanup = child.terminate().err();
1030 drop(child);
1032 (files.capture(), cleanup)
1033 }
1034}
1035
1036struct StartupFiles {
1037 #[cfg_attr(
1038 not(test),
1039 expect(
1040 dead_code,
1041 reason = "retains directory ownership until cleanup or drop"
1042 )
1043 )]
1044 directory: OwnedDirectory,
1045 port: PathBuf,
1046 stdout: PathBuf,
1047 stderr: PathBuf,
1048}
1049
1050impl StartupFiles {
1051 fn read_port(&self) -> Result<PortFileState, PocketIcStartupError> {
1052 let port_path = &self.port;
1053 let contents =
1054 match ic_host_fs::read::read_file_prefix(port_path, SERVER_PORT_FILE_LIMIT + 1)
1055 .map_err(|error| match error {
1056 ic_host_artifacts::artifact::ArtifactError::Io(source) => source,
1057 error => io::Error::new(io::ErrorKind::InvalidData, error),
1058 })
1059 .and_then(|(bytes, _)| {
1060 String::from_utf8(bytes)
1061 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
1062 }) {
1063 Ok(contents) => contents,
1064 Err(error) if error.kind() == io::ErrorKind::NotFound => {
1065 return Ok(PortFileState::Pending);
1066 }
1067 Err(source) => {
1068 return Err(PocketIcStartupError::new(PocketIcStartupFailure::Io {
1069 operation: "read PocketIC server port file",
1070 path: port_path.clone(),
1071 source,
1072 }));
1073 }
1074 };
1075 if contents.len() > SERVER_PORT_FILE_LIMIT {
1076 return Ok(PortFileState::Invalid(format!(
1077 "{} (port file exceeds {SERVER_PORT_FILE_LIMIT} bytes)",
1078 contents.trim()
1079 )));
1080 }
1081 if !contents.contains('\n') {
1082 return Ok(PortFileState::Pending);
1083 }
1084 let value = contents.trim();
1085 match value.parse::<u16>() {
1086 Ok(port) if port != 0 => Ok(PortFileState::Ready(port)),
1087 _ => Ok(PortFileState::Invalid(value.to_owned())),
1088 }
1089 }
1090
1091 fn capture(&self) -> PocketIcManagedServerOutput {
1092 PocketIcManagedServerOutput {
1093 stdout: read_bounded_lossy(&self.stdout),
1094 stderr: read_bounded_lossy(&self.stderr),
1095 }
1096 }
1097
1098 fn create(
1099 output_files: Option<(PathBuf, PathBuf)>,
1100 ) -> Result<(Self, File, File), PocketIcStartupError> {
1101 let output_files = output_files
1102 .map(|(stdout, stderr)| {
1103 Ok::<_, PocketIcStartupError>((
1104 std::path::absolute(&stdout)
1105 .map_err(|source| startup_file_error("resolve", &stdout, source))?,
1106 std::path::absolute(&stderr)
1107 .map_err(|source| startup_file_error("resolve", &stderr, source))?,
1108 ))
1109 })
1110 .transpose()?;
1111 let directory = OwnedDirectory::new(
1112 create_unique_directory(
1113 &std::env::temp_dir(),
1114 "ic-testkit-pocket-ic-startup",
1115 0o700,
1116 None,
1117 )
1118 .map_err(|source| startup_file_error("create", &std::env::temp_dir(), source))?,
1119 );
1120 let base = directory.path();
1121 let (stdout_path, stderr_path) =
1122 output_files.unwrap_or_else(|| (base.join("stdout"), base.join("stderr")));
1123 let files = Self {
1124 port: base.join("port"),
1125 stdout: stdout_path,
1126 stderr: stderr_path,
1127 directory,
1128 };
1129 let stdout = create_new_file(&files.stdout)
1130 .map_err(|source| startup_file_error("create", &files.stdout, source))?;
1131 let stderr = create_new_file(&files.stderr)
1132 .map_err(|source| startup_file_error("create", &files.stderr, source))?;
1133 Ok((files, stdout, stderr))
1134 }
1135}
1136
1137fn create_new_file(path: &Path) -> io::Result<File> {
1138 let mut options = OpenOptions::new();
1139 options.write(true).create_new(true);
1140 #[cfg(unix)]
1141 options.mode(0o600);
1142 options.open(path)
1143}
1144
1145fn startup_file_error(
1146 operation: &'static str,
1147 path: &Path,
1148 source: io::Error,
1149) -> PocketIcStartupError {
1150 PocketIcStartupError::new(PocketIcStartupFailure::Io {
1151 operation,
1152 path: path.to_owned(),
1153 source,
1154 })
1155}
1156
1157fn read_bounded_lossy(path: &Path) -> String {
1158 let Ok((bytes, length)) = ic_host_fs::read::read_file_prefix(path, SERVER_OUTPUT_LIMIT) else {
1159 return String::new();
1160 };
1161 let mut output = String::from_utf8_lossy(&bytes).into_owned();
1162 let omitted = length.saturating_sub(bytes.len() as u64);
1163 if omitted > 0 {
1164 let _ = write!(output, "\n<truncated {omitted} bytes>");
1165 }
1166 output
1167}
1168
1169impl std::fmt::Display for PocketIcStartupError {
1170 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1171 std::fmt::Display::fmt(self.failure.as_ref(), formatter)?;
1172 if !self.output.stdout.is_empty() {
1173 write!(formatter, "; server stdout: {}", self.output.stdout)?;
1174 }
1175 if !self.output.stderr.is_empty() {
1176 write!(formatter, "; server stderr: {}", self.output.stderr)?;
1177 }
1178 for (owner, cleanup) in [
1179 ("command", self.command_cleanup()),
1180 ("server", self.server_cleanup()),
1181 ] {
1182 if let Some(error) = cleanup {
1183 write!(formatter, "; {owner} cleanup also failed: {error}")?;
1184 }
1185 }
1186 Ok(())
1187 }
1188}
1189
1190impl std::error::Error for PocketIcStartupError {
1191 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1192 std::error::Error::source(self.failure.as_ref())
1193 }
1194}
1195
1196#[cfg(test)]
1197mod tests;