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