1use std::{
2 fmt::Write as _,
3 fs::{self, File, OpenOptions},
4 future::Future as _,
5 io,
6 path::{Path, PathBuf},
7 process::{Command, ExitStatus, Stdio},
8 sync::mpsc::{self, RecvTimeoutError},
9 thread,
10 time::{Duration, Instant},
11};
12
13#[cfg(unix)]
14use std::os::unix::fs::OpenOptionsExt as _;
15
16use ic_host_fs::{directory::create_unique_directory, owned_directory::OwnedDirectory};
17use ic_host_process::child::{CleanupError, CleanupPolicy, OwnedChild};
18use ic_platform_pocket_ic::{PocketIc, PocketIcBuilder};
19
20use super::transport;
21
22const STARTUP_POLL_INTERVAL: Duration = Duration::from_millis(20);
23const SERVER_OUTPUT_LIMIT: usize = 16 * 1024;
24const SERVER_PORT_FILE_LIMIT: usize = 64;
27
28#[derive(Clone, Debug, Eq, PartialEq)]
33pub enum PocketIcStartupConfig {
34 Spawn {
36 server_binary: PathBuf,
38 timeout: Duration,
40 server_hard_ttl: Option<Duration>,
42 server_idle_ttl: Option<Duration>,
44 server_output_files: Option<(PathBuf, PathBuf)>,
47 },
48 Connect {
50 server_url: String,
52 timeout: Duration,
54 },
55}
56
57pub struct PocketIcManagedServer {
68 server: ManagedServer,
69 url: String,
70}
71
72#[must_use]
99pub struct PocketIcInstance<T> {
100 instance: T,
101 server: Option<PocketIcManagedServer>,
102}
103
104impl<T> PocketIcInstance<T> {
105 pub const fn from_borrowed_server(instance: T) -> Self {
111 Self {
112 instance,
113 server: None,
114 }
115 }
116
117 #[must_use]
119 pub const fn instance(&self) -> &T {
120 &self.instance
121 }
122
123 pub const fn instance_mut(&mut self) -> &mut T {
135 &mut self.instance
136 }
137
138 #[must_use]
140 pub const fn server(&self) -> Option<&PocketIcManagedServer> {
141 self.server.as_ref()
142 }
143
144 #[must_use]
150 pub fn into_parts(self) -> (T, Option<PocketIcManagedServer>) {
151 (self.instance, self.server)
152 }
153}
154
155#[derive(Clone, Debug, Default, Eq, PartialEq)]
161pub struct PocketIcManagedServerOutput {
162 stdout: String,
163 stderr: String,
164}
165
166#[derive(Debug)]
172pub struct PocketIcStartupError {
173 failure: Box<PocketIcStartupFailure>,
174 output: PocketIcManagedServerOutput,
175 command_cleanup: Option<Box<CleanupError>>,
176 server_cleanup: Option<Box<CleanupError>>,
177}
178
179impl PocketIcStartupError {
180 fn new(failure: PocketIcStartupFailure) -> Self {
181 Self {
182 failure: Box::new(failure),
183 output: PocketIcManagedServerOutput::default(),
184 command_cleanup: None,
185 server_cleanup: None,
186 }
187 }
188
189 #[must_use]
191 pub fn failure(&self) -> &PocketIcStartupFailure {
192 &self.failure
193 }
194
195 #[must_use]
197 pub const fn output(&self) -> &PocketIcManagedServerOutput {
198 &self.output
199 }
200
201 #[must_use]
203 pub fn command_cleanup(&self) -> Option<&CleanupError> {
204 self.command_cleanup.as_deref()
205 }
206
207 #[must_use]
209 pub fn server_cleanup(&self) -> Option<&CleanupError> {
210 self.server_cleanup.as_deref()
211 }
212
213 fn with_cleanup(
214 mut self,
215 command: Option<CleanupError>,
216 server: Option<(PocketIcManagedServerOutput, Option<CleanupError>)>,
217 ) -> Self {
218 self.command_cleanup = command.map(Box::new);
219 if let Some((output, cleanup)) = server {
220 self.output = output;
221 self.server_cleanup = cleanup.map(Box::new);
222 }
223 self
224 }
225}
226
227#[non_exhaustive]
229#[derive(Debug, thiserror::Error)]
230pub enum PocketIcStartupFailure {
231 #[error(
233 "configure IC_TESTKIT_POCKET_IC_URL or POCKET_IC_BIN; prepare a verified binary with make install-server"
234 )]
235 NotConfigured,
236 #[error("invalid selected environment value: {variable}")]
238 InvalidEnvironment {
239 variable: &'static str,
241 },
242 #[error("PocketIC version probe failed: {source}")]
244 ServerVersionProbe {
245 source: ic_host_process::tool::ToolError,
247 },
248 #[error("PocketIC version mismatch: expected {expected:?}, observed {:?}", String::from_utf8_lossy(.observed))]
250 ServerVersionMismatch {
251 expected: String,
253 observed: Vec<u8>,
255 },
256 #[error("command {} failed: {source}", .program.display())]
258 CommandRun {
259 program: PathBuf,
261 source: io::Error,
263 },
264 #[error("{message}")]
266 InvalidConfiguration {
267 message: String,
269 },
270 #[error("invalid PocketIC server URL {server_url:?}: {message}")]
272 InvalidServerUrl {
273 server_url: String,
275 message: String,
277 },
278 #[error("failed to {operation} at {}: {source}", .path.display())]
280 Io {
281 operation: &'static str,
283 path: PathBuf,
285 source: io::Error,
287 },
288 #[error("failed to spawn PocketIC server {}: {source}", .server_binary.display())]
290 ServerSpawn {
291 server_binary: PathBuf,
293 source: io::Error,
295 },
296 #[error("PocketIC server {} exited with {status} after {elapsed:?}", .server_binary.display())]
298 ServerExited {
299 server_binary: PathBuf,
301 status: ExitStatus,
303 elapsed: Duration,
305 },
306 #[error("PocketIC server {} was not ready within {timeout:?}", .server_binary.display())]
308 ReadinessTimeout {
309 server_binary: PathBuf,
311 timeout: Duration,
313 },
314 #[error("PocketIC server {} published invalid port {value:?}", .server_binary.display())]
316 InvalidServerPort {
317 server_binary: PathBuf,
319 value: String,
321 },
322 #[error("PocketIC instance creation exceeded {timeout:?}")]
324 InstanceCreationTimeout {
325 timeout: Duration,
327 },
328 #[error("failed to spawn PocketIC builder worker: {source}")]
330 BuilderThreadSpawn {
331 source: io::Error,
333 },
334 #[error("PocketIC startup panicked: {source}")]
336 BuilderPanicked {
337 source: super::PocketIcOperationError,
339 },
340 #[error("PocketIC builder worker disconnected without a result")]
342 BuilderDisconnected,
343 #[error("PocketIC server cleanup failed after command exited with {command_status}")]
345 ServerCleanup {
346 command_status: ExitStatus,
348 },
349}
350
351impl PocketIcStartupConfig {
352 pub fn from_env(timeout: Duration) -> Result<Self, PocketIcStartupError> {
366 Self::from_environment(timeout, |name| std::env::var_os(name))
367 }
368
369 fn from_environment(
370 timeout: Duration,
371 mut variable: impl FnMut(&str) -> Option<std::ffi::OsString>,
372 ) -> Result<Self, PocketIcStartupError> {
373 if let Some(value) = variable("IC_TESTKIT_POCKET_IC_URL") {
374 let server_url = value
375 .into_string()
376 .ok()
377 .filter(|value| !value.is_empty())
378 .ok_or_else(|| {
379 PocketIcStartupError::new(PocketIcStartupFailure::InvalidEnvironment {
380 variable: "IC_TESTKIT_POCKET_IC_URL",
381 })
382 })?;
383 let config = Self::Connect {
384 server_url: (&server_url).into(),
385 timeout,
386 };
387 config.validate()?;
388 let _ = configured_builder(PocketIcBuilder::new(), &server_url)?;
389 return Ok(config);
390 }
391 let value = variable("POCKET_IC_BIN")
392 .ok_or_else(|| PocketIcStartupError::new(PocketIcStartupFailure::NotConfigured))?;
393 if value.is_empty() {
394 return Err(PocketIcStartupError::new(
395 PocketIcStartupFailure::InvalidEnvironment {
396 variable: "POCKET_IC_BIN",
397 },
398 ));
399 }
400 let path = PathBuf::from(value);
401 let binary = fs::canonicalize(&path).map_err(|source| {
402 PocketIcStartupError::new(PocketIcStartupFailure::Io {
403 operation: "resolve configured PocketIC executable",
404 path,
405 source,
406 })
407 })?;
408 let config = Self::Spawn {
409 server_binary: (&binary).into(),
410 timeout,
411 server_hard_ttl: None,
412 server_idle_ttl: None,
413 server_output_files: None,
414 };
415 config.validate()?;
416 let evidence = ic_host_process::tool::capture_group_command(
417 Command::new(&binary).arg("--version"),
418 ic_host_process::tool::OutputLimits {
419 stdout: ic_host_process::tool::OutputLimit::Terminate(SERVER_OUTPUT_LIMIT),
420 stderr: ic_host_process::tool::OutputLimit::Terminate(SERVER_OUTPUT_LIMIT),
421 timeout: Some(timeout),
422 },
423 )
424 .and_then(ic_host_process::tool::CompletedExecution::require_success)
425 .map_err(|source| {
426 PocketIcStartupError::new(PocketIcStartupFailure::ServerVersionProbe { source })
427 })?;
428 let expected = super::server::pocket_ic_server_requirement();
429 if !super::supports_pocket_ic_server(&evidence.stdout) {
430 return Err(PocketIcStartupError::new(
431 PocketIcStartupFailure::ServerVersionMismatch {
432 expected,
433 observed: evidence.stdout,
434 },
435 ));
436 }
437 Ok(config)
438 }
439
440 #[must_use]
442 pub const fn timeout(&self) -> Duration {
443 match self {
444 Self::Spawn { timeout, .. } | Self::Connect { timeout, .. } => *timeout,
445 }
446 }
447
448 pub fn build_instance(
472 self,
473 builder: PocketIcBuilder,
474 ) -> Result<PocketIcInstance<PocketIc>, PocketIcStartupError> {
475 let startup = StartupDeadline::new(&self)?;
476 let (server, url) = match self {
477 Self::Connect { server_url, .. } => (None, server_url),
478 spawn @ Self::Spawn { .. } => {
479 let (server, url) = spawn.spawn_server(startup.started)?.wait_ready(&startup)?;
480 (Some(server), url)
481 }
482 };
483 build_bounded(builder, &url, &startup, server)
484 }
485
486 pub async fn build_instance_async(
522 self,
523 builder: PocketIcBuilder,
524 ) -> Result<PocketIcInstance<ic_platform_pocket_ic::nonblocking::PocketIc>, PocketIcStartupError>
525 {
526 let startup = StartupDeadline::new(&self)?;
527 let (server, server_url) = match self {
528 Self::Connect { server_url, .. } => (None, server_url),
529 spawn @ Self::Spawn { .. } => {
530 let mut server = spawn.spawn_server(startup.started)?;
531 let server_url = loop {
532 match server.poll_ready(&startup) {
533 Ok(Some(url)) => break url,
534 Ok(None) => {
535 tokio::time::sleep(startup.wait_interval(Instant::now(), true)).await;
536 }
537 Err(error) => return Err(finalize_failure(error, None, Some(server))),
538 }
539 };
540 (Some(server), server_url)
541 }
542 };
543 build_async_bounded(builder, &server_url, &startup, server).await
544 }
545
546 pub fn start_managed_server(self) -> Result<PocketIcManagedServer, PocketIcStartupError> {
563 if matches!(self, Self::Connect { .. }) {
564 self.validate()?;
565 return Err(PocketIcStartupError::new(
566 PocketIcStartupFailure::InvalidConfiguration {
567 message: "starting a managed PocketIC server requires a spawn configuration"
568 .to_owned(),
569 },
570 ));
571 }
572 let startup = StartupDeadline::new(&self)?;
573 let (server, url) = self.spawn_server(startup.started)?.wait_ready(&startup)?;
574 Ok(PocketIcManagedServer { server, url })
575 }
576
577 fn spawn_server(self, started: Instant) -> Result<ManagedServer, PocketIcStartupError> {
580 let Self::Spawn {
581 server_binary,
582 server_hard_ttl,
583 server_idle_ttl,
584 server_output_files,
585 ..
586 } = self
587 else {
588 unreachable!("connect configurations do not spawn a server")
589 };
590 ManagedServer::spawn(
591 server_binary,
592 server_hard_ttl,
593 server_idle_ttl,
594 server_output_files,
595 started,
596 )
597 }
598
599 pub fn run_command(
619 self,
620 command: &mut Command,
621 mut cancelled: impl FnMut() -> bool,
622 ) -> Result<ExitStatus, PocketIcStartupError> {
623 self.validate()?;
624 let program = PathBuf::from(command.get_program());
625 let command_error = |source| {
626 PocketIcStartupError::new(PocketIcStartupFailure::CommandRun {
627 program: program.clone(),
628 source,
629 })
630 };
631 if cancelled() {
632 return Err(command_error(io::Error::from(io::ErrorKind::Interrupted)));
633 }
634 let (server, url) = match self {
635 Self::Connect { server_url, .. } => (None, server_url),
636 spawn @ Self::Spawn { .. } => {
637 let server = spawn.start_managed_server()?;
638 let url = server.url().to_owned();
639 (Some(server.server), url)
640 }
641 };
642 let mut server = server;
643 if cancelled() {
644 return Err(finalize_failure(
645 command_error(io::Error::from(io::ErrorKind::Interrupted)),
646 None,
647 server,
648 ));
649 }
650 command.env("IC_TESTKIT_POCKET_IC_URL", url);
651 let mut owned_child = match OwnedChild::spawn(command, CleanupPolicy::KillAndWait) {
652 Ok(child) => child,
653 Err(source) => {
654 return Err(finalize_failure(
655 PocketIcStartupError::new(PocketIcStartupFailure::Io {
656 operation: "spawn command with PocketIC server",
657 path: PathBuf::from(command.get_program()),
658 source,
659 }),
660 None,
661 server,
662 ));
663 }
664 };
665 let result = loop {
666 if cancelled() {
667 break Err(command_error(io::Error::from(io::ErrorKind::Interrupted)));
668 }
669 match owned_child.try_wait() {
670 Ok(Some(status)) => break Ok(status),
671 Ok(None) => {
672 if let Some(managed) = server.as_mut()
673 && let Err(error) = managed.check_running()
674 {
675 break Err(error);
676 }
677 thread::sleep(STARTUP_POLL_INTERVAL);
678 }
679 Err(source) => break Err(command_error(source)),
680 }
681 };
682 match result {
683 Err(error) => Err(finalize_failure(error, Some(&mut owned_child), server)),
684 Ok(status) => {
685 if let Some(server) = server {
686 let (output, cleanup) = server.terminate_and_capture();
687 if let Some(cleanup) = cleanup {
688 return Err(PocketIcStartupError::new(
689 PocketIcStartupFailure::ServerCleanup {
690 command_status: status,
691 },
692 )
693 .with_cleanup(None, Some((output, Some(cleanup)))));
694 }
695 }
696 Ok(status)
697 }
698 }
699 }
700
701 fn validate(&self) -> Result<(), PocketIcStartupError> {
702 if self.timeout().is_zero() {
703 return Err(PocketIcStartupError::new(
704 PocketIcStartupFailure::InvalidConfiguration {
705 message: "PocketIC startup timeout must be greater than zero".to_owned(),
706 },
707 ));
708 }
709 if let Self::Spawn {
710 server_hard_ttl,
711 server_idle_ttl,
712 ..
713 } = self
714 {
715 for (ttl, message) in [
716 (
717 server_hard_ttl,
718 "PocketIC server hard TTL must be at least one second",
719 ),
720 (
721 server_idle_ttl,
722 "PocketIC server idle TTL must be at least one second",
723 ),
724 ] {
725 if ttl.is_some_and(|ttl| ttl.as_secs() == 0) {
726 return Err(PocketIcStartupError::new(
727 PocketIcStartupFailure::InvalidConfiguration {
728 message: message.to_owned(),
729 },
730 ));
731 }
732 }
733 }
734 Ok(())
735 }
736}
737
738impl PocketIcManagedServer {
739 #[must_use]
746 pub fn process_id(&self) -> u32 {
747 self.server.child.id()
748 }
749
750 #[must_use]
752 pub fn url(&self) -> &str {
753 &self.url
754 }
755
756 #[must_use]
762 pub fn output(&self) -> PocketIcManagedServerOutput {
763 self.server.files.capture()
764 }
765}
766
767impl PocketIcManagedServerOutput {
768 #[must_use]
770 pub fn stdout(&self) -> &str {
771 &self.stdout
772 }
773
774 #[must_use]
776 pub fn stderr(&self) -> &str {
777 &self.stderr
778 }
779}
780
781struct StartupDeadline {
782 started: Instant,
783 timeout: Duration,
784 deadline: Instant,
785}
786
787impl StartupDeadline {
788 fn new(config: &PocketIcStartupConfig) -> Result<Self, PocketIcStartupError> {
789 config.validate()?;
790 let started = Instant::now();
791 let timeout = config.timeout();
792 Ok(Self {
793 started,
794 timeout,
795 deadline: startup_deadline(started, timeout)?,
796 })
797 }
798
799 fn check_instance_deadline(
800 &self,
801 server: &mut Option<ManagedServer>,
802 ) -> Result<Instant, PocketIcStartupError> {
803 let now = Instant::now();
804 if now >= self.deadline {
805 return Err(finalize_failure(
806 PocketIcStartupError::new(PocketIcStartupFailure::InstanceCreationTimeout {
807 timeout: self.timeout,
808 }),
809 None,
810 server.take(),
811 ));
812 }
813 Ok(now)
814 }
815
816 fn wait_interval(&self, now: Instant, monitor_server: bool) -> Duration {
817 let remaining = self.deadline.saturating_duration_since(now);
818 if monitor_server {
819 remaining.min(STARTUP_POLL_INTERVAL)
820 } else {
821 remaining
822 }
823 }
824
825 fn admit_instance<T>(
826 &self,
827 instance: T,
828 server_url: &str,
829 server: &mut Option<ManagedServer>,
830 ) -> Result<PocketIcInstance<T>, PocketIcStartupError> {
831 self.check_instance_deadline(server)?;
832 check_server(server)?;
833 Ok(PocketIcInstance {
834 instance,
835 server: server.take().map(|server| PocketIcManagedServer {
836 server,
837 url: server_url.to_owned(),
838 }),
839 })
840 }
841}
842
843fn startup_deadline(started: Instant, timeout: Duration) -> Result<Instant, PocketIcStartupError> {
844 started.checked_add(timeout).ok_or_else(|| {
845 PocketIcStartupError::new(PocketIcStartupFailure::InvalidConfiguration {
846 message: "PocketIC startup timeout exceeds the platform clock range".to_owned(),
847 })
848 })
849}
850
851struct InstanceStartup<'a> {
853 deadline: &'a StartupDeadline,
854 server_url: &'a str,
855 server: Option<ManagedServer>,
856}
857
858impl InstanceStartup<'_> {
859 fn configure_builder(
860 &mut self,
861 builder: PocketIcBuilder,
862 ) -> Result<PocketIcBuilder, PocketIcStartupError> {
863 configured_builder(builder, self.server_url).map_err(|error| self.fail(error))
864 }
865
866 fn check_deadline(&mut self) -> Result<Instant, PocketIcStartupError> {
867 self.deadline.check_instance_deadline(&mut self.server)
868 }
869
870 fn wait_interval(&self, now: Instant) -> Duration {
871 self.deadline.wait_interval(now, self.server.is_some())
872 }
873
874 fn check_server(&mut self) -> Result<(), PocketIcStartupError> {
875 check_server(&mut self.server)
876 }
877
878 fn admit<T>(&mut self, instance: T) -> Result<PocketIcInstance<T>, PocketIcStartupError> {
879 self.deadline
880 .admit_instance(instance, self.server_url, &mut self.server)
881 }
882
883 fn fail(&mut self, error: PocketIcStartupError) -> PocketIcStartupError {
884 finalize_failure(error, None, self.server.take())
885 }
886}
887
888fn build_bounded(
889 builder: PocketIcBuilder,
890 server_url: &str,
891 startup: &StartupDeadline,
892 server: Option<ManagedServer>,
893) -> Result<PocketIcInstance<PocketIc>, PocketIcStartupError> {
894 let mut startup = InstanceStartup {
895 deadline: startup,
896 server_url,
897 server,
898 };
899 let builder = startup.configure_builder(builder)?;
900 let (sender, receiver) = mpsc::sync_channel(1);
901 if let Err(source) = thread::Builder::new()
902 .name("ic-testkit-pocket-ic-startup".to_owned())
903 .spawn(move || {
904 let result = transport::capture_operation(|| builder.build());
905 let _ = sender.send(result);
906 })
907 {
908 return Err(startup.fail(PocketIcStartupError::new(
909 PocketIcStartupFailure::BuilderThreadSpawn { source },
910 )));
911 }
912 await_builder(receiver, &mut startup)
913}
914
915#[expect(
916 clippy::needless_pass_by_value,
917 reason = "consume the receiver so failed startup disconnects late builders"
918)]
919fn await_builder(
920 receiver: mpsc::Receiver<Result<PocketIc, super::PocketIcOperationError>>,
921 startup: &mut InstanceStartup<'_>,
922) -> Result<PocketIcInstance<PocketIc>, PocketIcStartupError> {
923 loop {
924 let now = startup.check_deadline()?;
925 let wait = startup.wait_interval(now);
926 match receiver.recv_timeout(wait) {
927 Ok(Ok(pocket_ic)) => {
928 return startup.admit(pocket_ic);
929 }
930 Ok(Err(source)) => {
931 return Err(startup.fail(PocketIcStartupError::new(
932 PocketIcStartupFailure::BuilderPanicked { source },
933 )));
934 }
935 Err(RecvTimeoutError::Disconnected) => {
936 return Err(startup.fail(PocketIcStartupError::new(
937 PocketIcStartupFailure::BuilderDisconnected,
938 )));
939 }
940 Err(RecvTimeoutError::Timeout) => {
941 startup.check_server()?;
942 }
943 }
944 }
945}
946
947fn configured_builder(
948 builder: PocketIcBuilder,
949 server_url: &str,
950) -> Result<PocketIcBuilder, PocketIcStartupError> {
951 server_url
952 .parse()
953 .map(|url| builder.with_server_url(url))
954 .map_err(|error| {
955 PocketIcStartupError::new(PocketIcStartupFailure::InvalidServerUrl {
956 server_url: server_url.to_owned(),
957 message: error.to_string(),
958 })
959 })
960}
961
962fn check_server(server: &mut Option<ManagedServer>) -> Result<(), PocketIcStartupError> {
963 if let Some(managed) = server.as_mut()
964 && let Err(error) = managed.check_running()
965 {
966 return Err(finalize_failure(error, None, server.take()));
967 }
968 Ok(())
969}
970
971async fn build_async_bounded(
972 builder: PocketIcBuilder,
973 server_url: &str,
974 startup: &StartupDeadline,
975 server: Option<ManagedServer>,
976) -> Result<PocketIcInstance<ic_platform_pocket_ic::nonblocking::PocketIc>, PocketIcStartupError> {
977 let mut startup = InstanceStartup {
978 deadline: startup,
979 server_url,
980 server,
981 };
982 let builder = startup.configure_builder(builder)?;
983 let mut construction = std::pin::pin!(builder.build_async());
984 let mut construction = std::pin::pin!(std::future::poll_fn(|context| {
985 match transport::capture_operation(|| construction.as_mut().poll(context)) {
986 Ok(poll) => poll.map(Ok),
987 Err(source) => std::task::Poll::Ready(Err(PocketIcStartupError::new(
988 PocketIcStartupFailure::BuilderPanicked { source },
989 ))),
990 }
991 }));
992 let mut startup = startup;
994 loop {
995 let now = startup.check_deadline()?;
996 startup.check_server()?;
997 let next_check = now + startup.wait_interval(now);
998 match tokio::time::timeout_at(next_check.into(), construction.as_mut()).await {
999 Ok(Ok(instance)) => {
1000 return startup.admit(instance);
1001 }
1002 Ok(Err(error)) => return Err(startup.fail(error)),
1003 Err(_) => {}
1004 }
1005 }
1006}
1007
1008fn finalize_failure(
1010 error: PocketIcStartupError,
1011 command: Option<&mut OwnedChild>,
1012 server: Option<ManagedServer>,
1013) -> PocketIcStartupError {
1014 error.with_cleanup(
1015 command.and_then(|child| child.terminate().err()),
1016 server.map(ManagedServer::terminate_and_capture),
1017 )
1018}
1019
1020struct ManagedServer {
1021 child: OwnedChild,
1022 binary: PathBuf,
1023 files: StartupFiles,
1024 started: Instant,
1025}
1026
1027enum PortFileState {
1028 Pending,
1029 Ready(u16),
1030 Invalid(String),
1031}
1032
1033impl ManagedServer {
1034 fn wait_ready(
1035 mut self,
1036 startup: &StartupDeadline,
1037 ) -> Result<(Self, String), PocketIcStartupError> {
1038 loop {
1039 match self.poll_ready(startup) {
1040 Ok(Some(url)) => return Ok((self, url)),
1041 Ok(None) => thread::sleep(startup.wait_interval(Instant::now(), true)),
1042 Err(error) => return Err(finalize_failure(error, None, Some(self))),
1043 }
1044 }
1045 }
1046
1047 fn spawn(
1048 binary: PathBuf,
1049 hard_ttl: Option<Duration>,
1050 idle_ttl: Option<Duration>,
1051 output_files: Option<(PathBuf, PathBuf)>,
1052 started: Instant,
1053 ) -> Result<Self, PocketIcStartupError> {
1054 let (files, stdout, stderr) = StartupFiles::create(output_files)?;
1055 let mut command = Command::new(&binary);
1056 if let Some(hard_ttl) = hard_ttl {
1057 command
1058 .arg("--hard-ttl")
1059 .arg(hard_ttl.as_secs().to_string());
1060 }
1061 if let Some(idle_ttl) = idle_ttl {
1062 command.arg("--ttl").arg(idle_ttl.as_secs().to_string());
1063 }
1064 command
1065 .arg("--port-file")
1066 .arg(&files.port)
1067 .stdout(Stdio::from(stdout))
1068 .stderr(Stdio::from(stderr));
1069 let child =
1070 OwnedChild::spawn(&mut command, CleanupPolicy::KillAndWait).map_err(|source| {
1071 PocketIcStartupError::new(PocketIcStartupFailure::ServerSpawn {
1072 server_binary: binary.clone(),
1073 source,
1074 })
1075 })?;
1076 Ok(Self {
1077 child,
1078 binary,
1079 files,
1080 started,
1081 })
1082 }
1083
1084 fn poll_ready(
1085 &mut self,
1086 startup: &StartupDeadline,
1087 ) -> Result<Option<String>, PocketIcStartupError> {
1088 self.check_running()?;
1089 if Instant::now() >= startup.deadline {
1090 return Err(PocketIcStartupError::new(
1091 PocketIcStartupFailure::ReadinessTimeout {
1092 server_binary: self.binary.clone(),
1093 timeout: startup.timeout,
1094 },
1095 ));
1096 }
1097 match self.files.read_port()? {
1098 PortFileState::Pending => Ok(None),
1099 PortFileState::Ready(port) => Ok(Some(format!("http://127.0.0.1:{port}/"))),
1100 PortFileState::Invalid(value) => Err(PocketIcStartupError::new(
1101 PocketIcStartupFailure::InvalidServerPort {
1102 server_binary: self.binary.clone(),
1103 value,
1104 },
1105 )),
1106 }
1107 }
1108
1109 fn check_running(&mut self) -> Result<(), PocketIcStartupError> {
1110 if let Some(status) = self.try_wait()? {
1111 return Err(self.exit_failure(status));
1112 }
1113 Ok(())
1114 }
1115
1116 fn try_wait(&mut self) -> Result<Option<ExitStatus>, PocketIcStartupError> {
1117 self.child.try_wait().map_err(|source| {
1118 PocketIcStartupError::new(PocketIcStartupFailure::Io {
1119 operation: "inspect PocketIC server child",
1120 path: self.binary.clone(),
1121 source,
1122 })
1123 })
1124 }
1125
1126 fn exit_failure(&self, status: ExitStatus) -> PocketIcStartupError {
1127 PocketIcStartupError::new(PocketIcStartupFailure::ServerExited {
1128 server_binary: self.binary.clone(),
1129 status,
1130 elapsed: self.started.elapsed(),
1131 })
1132 }
1133
1134 fn terminate_and_capture(self) -> (PocketIcManagedServerOutput, Option<CleanupError>) {
1135 let Self {
1136 mut child, files, ..
1137 } = self;
1138 let cleanup = child.terminate().err();
1139 drop(child);
1141 (files.capture(), cleanup)
1142 }
1143}
1144
1145struct StartupFiles {
1146 #[cfg_attr(
1147 not(test),
1148 expect(
1149 dead_code,
1150 reason = "retains directory ownership until cleanup or drop"
1151 )
1152 )]
1153 directory: OwnedDirectory,
1154 port: PathBuf,
1155 stdout: PathBuf,
1156 stderr: PathBuf,
1157}
1158
1159impl StartupFiles {
1160 fn read_port(&self) -> Result<PortFileState, PocketIcStartupError> {
1161 let port_path = &self.port;
1162 let contents =
1163 match ic_host_fs::read::read_file_prefix(port_path, SERVER_PORT_FILE_LIMIT + 1)
1164 .map_err(|error| match error {
1165 ic_host_artifacts::artifact::ArtifactError::Io(source) => source,
1166 error => io::Error::new(io::ErrorKind::InvalidData, error),
1167 })
1168 .and_then(|(bytes, _)| {
1169 String::from_utf8(bytes)
1170 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
1171 }) {
1172 Ok(contents) => contents,
1173 Err(error) if error.kind() == io::ErrorKind::NotFound => {
1174 return Ok(PortFileState::Pending);
1175 }
1176 Err(source) => {
1177 return Err(PocketIcStartupError::new(PocketIcStartupFailure::Io {
1178 operation: "read PocketIC server port file",
1179 path: port_path.clone(),
1180 source,
1181 }));
1182 }
1183 };
1184 if contents.len() > SERVER_PORT_FILE_LIMIT {
1185 return Ok(PortFileState::Invalid(format!(
1186 "{} (port file exceeds {SERVER_PORT_FILE_LIMIT} bytes)",
1187 contents.trim()
1188 )));
1189 }
1190 if !contents.contains('\n') {
1191 return Ok(PortFileState::Pending);
1192 }
1193 let value = contents.trim();
1194 match value.parse::<u16>() {
1195 Ok(port) if port != 0 => Ok(PortFileState::Ready(port)),
1196 _ => Ok(PortFileState::Invalid(value.to_owned())),
1197 }
1198 }
1199
1200 fn capture(&self) -> PocketIcManagedServerOutput {
1201 PocketIcManagedServerOutput {
1202 stdout: read_bounded_lossy(&self.stdout),
1203 stderr: read_bounded_lossy(&self.stderr),
1204 }
1205 }
1206
1207 fn create(
1208 output_files: Option<(PathBuf, PathBuf)>,
1209 ) -> Result<(Self, File, File), PocketIcStartupError> {
1210 let output_files = output_files
1211 .map(|(stdout, stderr)| {
1212 Ok::<_, PocketIcStartupError>((
1213 std::path::absolute(&stdout)
1214 .map_err(|source| startup_file_error("resolve", &stdout, source))?,
1215 std::path::absolute(&stderr)
1216 .map_err(|source| startup_file_error("resolve", &stderr, source))?,
1217 ))
1218 })
1219 .transpose()?;
1220 let directory = OwnedDirectory::new(
1221 create_unique_directory(
1222 &std::env::temp_dir(),
1223 "ic-testkit-pocket-ic-startup",
1224 0o700,
1225 None,
1226 )
1227 .map_err(|source| startup_file_error("create", &std::env::temp_dir(), source))?,
1228 );
1229 let base = directory.path();
1230 let (stdout_path, stderr_path) =
1231 output_files.unwrap_or_else(|| (base.join("stdout"), base.join("stderr")));
1232 let files = Self {
1233 port: base.join("port"),
1234 stdout: stdout_path,
1235 stderr: stderr_path,
1236 directory,
1237 };
1238 let stdout = create_new_file(&files.stdout)
1239 .map_err(|source| startup_file_error("create", &files.stdout, source))?;
1240 let stderr = create_new_file(&files.stderr)
1241 .map_err(|source| startup_file_error("create", &files.stderr, source))?;
1242 Ok((files, stdout, stderr))
1243 }
1244}
1245
1246fn create_new_file(path: &Path) -> io::Result<File> {
1247 let mut options = OpenOptions::new();
1248 options.write(true).create_new(true);
1249 #[cfg(unix)]
1250 options.mode(0o600);
1251 options.open(path)
1252}
1253
1254fn startup_file_error(
1255 operation: &'static str,
1256 path: &Path,
1257 source: io::Error,
1258) -> PocketIcStartupError {
1259 PocketIcStartupError::new(PocketIcStartupFailure::Io {
1260 operation,
1261 path: path.to_owned(),
1262 source,
1263 })
1264}
1265
1266fn read_bounded_lossy(path: &Path) -> String {
1267 let Ok((bytes, length)) = ic_host_fs::read::read_file_prefix(path, SERVER_OUTPUT_LIMIT) else {
1268 return String::new();
1269 };
1270 let mut output = String::from_utf8_lossy(&bytes).into_owned();
1271 let omitted = length.saturating_sub(bytes.len() as u64);
1272 if omitted > 0 {
1273 let _ = write!(output, "\n<truncated {omitted} bytes>");
1274 }
1275 output
1276}
1277
1278impl std::fmt::Display for PocketIcStartupError {
1279 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1280 std::fmt::Display::fmt(self.failure.as_ref(), formatter)?;
1281 if !self.output.stdout.is_empty() {
1282 write!(formatter, "; server stdout: {}", self.output.stdout)?;
1283 }
1284 if !self.output.stderr.is_empty() {
1285 write!(formatter, "; server stderr: {}", self.output.stderr)?;
1286 }
1287 for (owner, cleanup) in [
1288 ("command", self.command_cleanup()),
1289 ("server", self.server_cleanup()),
1290 ] {
1291 if let Some(error) = cleanup {
1292 write!(formatter, "; {owner} cleanup also failed: {error}")?;
1293 }
1294 }
1295 Ok(())
1296 }
1297}
1298
1299impl std::error::Error for PocketIcStartupError {
1300 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1301 std::error::Error::source(self.failure.as_ref())
1302 }
1303}
1304
1305#[cfg(test)]
1306mod tests {
1307 mod instances;
1308
1309 use std::{
1310 fs,
1311 path::PathBuf,
1312 time::{Duration, Instant},
1313 };
1314
1315 use super::{
1316 OwnedDirectory, PocketIcStartupConfig, PocketIcStartupError, PocketIcStartupFailure,
1317 StartupFiles, create_unique_directory,
1318 };
1319 use ic_platform_pocket_ic::PocketIcBuilder;
1320
1321 #[cfg(unix)]
1322 use ic_host_process::testing::write_executable_script;
1323 #[cfg(unix)]
1324 use std::{
1325 io::Write as _,
1326 os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _},
1327 process::Command,
1328 sync::mpsc,
1329 };
1330
1331 #[test]
1332 fn completed_instances_must_still_meet_the_startup_deadline() {
1333 let started = Instant::now();
1334 let startup = super::StartupDeadline {
1335 started,
1336 timeout: Duration::from_secs(60),
1337 deadline: started + Duration::from_secs(60),
1338 };
1339 let mut server = None;
1340 let instance = startup
1341 .admit_instance(7_u8, "http://localhost/", &mut server)
1342 .unwrap();
1343 assert_eq!(*instance.instance(), 7);
1344 assert!(server.is_none());
1345 let expired = super::StartupDeadline {
1346 deadline: started,
1347 ..startup
1348 };
1349 let error = expired
1350 .admit_instance(7_u8, "http://localhost/", &mut server)
1351 .err()
1352 .expect("completed instance after the deadline must refuse admission");
1353 assert!(
1354 matches!(error.failure(), PocketIcStartupFailure::InstanceCreationTimeout { timeout } if *timeout == Duration::from_secs(60))
1355 );
1356 assert!(server.is_none());
1357 }
1358
1359 #[test]
1360 fn original_failure_output_and_both_typed_cleanup_reports_survive_projection() {
1361 use std::error::Error as _;
1362 for failure in [
1363 PocketIcStartupFailure::ReadinessTimeout {
1364 server_binary: "server".into(),
1365 timeout: Duration::from_secs(1),
1366 },
1367 PocketIcStartupFailure::InstanceCreationTimeout {
1368 timeout: Duration::from_secs(1),
1369 },
1370 PocketIcStartupFailure::InvalidServerPort {
1371 server_binary: "server".into(),
1372 value: "invalid".into(),
1373 },
1374 PocketIcStartupFailure::BuilderPanicked {
1375 source: super::super::PocketIcOperationError::new("original-panic"),
1376 },
1377 PocketIcStartupFailure::BuilderDisconnected,
1378 PocketIcStartupFailure::BuilderThreadSpawn {
1379 source: std::io::Error::from_raw_os_error(9),
1380 },
1381 PocketIcStartupFailure::Io {
1382 operation: "original-io",
1383 path: "port".into(),
1384 source: std::io::Error::from_raw_os_error(9),
1385 },
1386 ] {
1387 let primary_text = failure.to_string();
1388 let output = super::PocketIcManagedServerOutput {
1389 stdout: "original-stdout".into(),
1390 stderr: "original-stderr".into(),
1391 };
1392 let command = super::CleanupError {
1393 status: None,
1394 group_error: None,
1395 term_error: Some(std::io::Error::from_raw_os_error(4)),
1396 kill_error: Some(std::io::Error::from_raw_os_error(5)),
1397 wait_error: None,
1398 };
1399 let server = super::CleanupError {
1400 status: None,
1401 group_error: Some(std::io::Error::from_raw_os_error(1)),
1402 term_error: None,
1403 kill_error: None,
1404 wait_error: Some(std::io::Error::from_raw_os_error(10)),
1405 };
1406 let error = PocketIcStartupError::new(failure)
1407 .with_cleanup(Some(command), Some((output, Some(server))));
1408 assert_eq!(error.failure().to_string(), primary_text);
1409 assert_eq!(error.output().stdout(), "original-stdout");
1410 assert_eq!(error.output().stderr(), "original-stderr");
1411 let command = error.command_cleanup().unwrap();
1412 let server = error.server_cleanup().unwrap();
1413 assert_eq!(command.kill_error.as_ref().unwrap().raw_os_error(), Some(5));
1414 assert_eq!(command.term_error.as_ref().unwrap().raw_os_error(), Some(4));
1415 assert_eq!(server.group_error.as_ref().unwrap().raw_os_error(), Some(1));
1416 assert_eq!(server.wait_error.as_ref().unwrap().raw_os_error(), Some(10));
1417 if matches!(
1418 error.failure(),
1419 PocketIcStartupFailure::Io { .. }
1420 | PocketIcStartupFailure::BuilderThreadSpawn { .. }
1421 ) {
1422 assert_eq!(
1423 error
1424 .source()
1425 .unwrap()
1426 .downcast_ref::<std::io::Error>()
1427 .unwrap()
1428 .raw_os_error(),
1429 Some(9)
1430 );
1431 }
1432 if let PocketIcStartupFailure::BuilderPanicked { source } = error.failure() {
1433 assert_eq!(
1434 error
1435 .source()
1436 .unwrap()
1437 .downcast_ref::<super::super::PocketIcOperationError>(),
1438 Some(source)
1439 );
1440 }
1441 let displayed = error.to_string();
1442 assert!(displayed.starts_with(&primary_text));
1443 for marker in [
1444 "original-stdout",
1445 "original-stderr",
1446 "command cleanup also failed",
1447 "server cleanup also failed",
1448 ] {
1449 assert!(displayed.contains(marker));
1450 }
1451 }
1452 let error = PocketIcStartupError::new(PocketIcStartupFailure::NotConfigured);
1453 assert_eq!(error.to_string(), error.failure().to_string());
1454 assert!(error.command_cleanup().is_none());
1455 assert!(error.server_cleanup().is_none());
1456 }
1457
1458 #[cfg(unix)]
1459 #[test]
1460 fn builder_failures_finalize_the_owned_server_and_keep_output() {
1461 for disconnected in [false, true] {
1462 let script = TestServerScript::new(
1463 "builder-failure",
1464 "#!/bin/sh\nprintf '%s\n%s\n' \"$$\" \"$2\"\nprintf 'builder-diagnostic' >&2\nprintf '34567\n' > \"$2\"\nexec sleep 30\n",
1465 );
1466 let managed = script
1467 .config(Duration::from_secs(2))
1468 .start_managed_server()
1469 .unwrap();
1470 let pid = managed.process_id();
1471 let directory = managed.server.files.directory.path().to_path_buf();
1472 let (sender, receiver) = mpsc::channel();
1473 if !disconnected {
1474 sender
1475 .send(Err(super::super::PocketIcOperationError::new(
1476 "original-builder-panic",
1477 )))
1478 .unwrap();
1479 }
1480 drop(sender);
1481 let error = super::await_builder(
1482 receiver,
1483 &mut super::InstanceStartup {
1484 deadline: &super::StartupDeadline {
1485 started: Instant::now(),
1486 deadline: Instant::now() + Duration::from_secs(2),
1487 timeout: Duration::from_secs(2),
1488 },
1489 server_url: "http://127.0.0.1:34567/",
1490 server: Some(managed.server),
1491 },
1492 )
1493 .err()
1494 .unwrap();
1495 assert!(matches!(
1496 (disconnected, error.failure()),
1497 (true, PocketIcStartupFailure::BuilderDisconnected)
1498 | (false, PocketIcStartupFailure::BuilderPanicked { .. })
1499 ));
1500 assert_eq!(error.output().stderr(), "builder-diagnostic");
1501 assert!(error.server_cleanup().is_none());
1502 assert!(!directory.exists());
1503 assert_eq!(process_state(pid), None);
1504 }
1505 }
1506
1507 #[cfg(unix)]
1508 #[test]
1509 fn port_io_and_command_spawn_failures_finalize_the_owned_server() {
1510 for port_io in [true, false] {
1511 let port = if port_io {
1512 "mkfifo \"$2\""
1513 } else {
1514 "printf '34567\n' > \"$2\""
1515 };
1516 let script = TestServerScript::new(
1517 "owned-io-failure",
1518 &format!(
1519 "#!/bin/sh\nprintf '%s\n%s\n' \"$$\" \"$2\"\nprintf 'original-server-diagnostic' >&2\n{port}\nexec sleep 30\n"
1520 ),
1521 );
1522 let config = script.config(Duration::from_secs(2));
1523 let error = if port_io {
1524 config.start_managed_server().err().unwrap()
1525 } else {
1526 config
1527 .run_command(&mut Command::new("/missing/ic-testkit-command"), || false)
1528 .unwrap_err()
1529 };
1530 let PocketIcStartupFailure::Io { source, .. } = error.failure() else {
1531 panic!("expected original IO failure: {error:?}");
1532 };
1533 assert_eq!(
1534 source.kind(),
1535 if port_io {
1536 std::io::ErrorKind::InvalidData
1537 } else {
1538 std::io::ErrorKind::NotFound
1539 }
1540 );
1541 assert_eq!(error.output().stderr(), "original-server-diagnostic");
1542 let mut lines = error.output().stdout().lines();
1543 let pid = lines.next().unwrap().parse().unwrap();
1544 let port = PathBuf::from(lines.next().unwrap());
1545 assert_eq!(process_state(pid), None);
1546 assert!(!port.parent().unwrap().exists());
1547 assert!(error.server_cleanup().is_none());
1548 }
1549 }
1550
1551 #[cfg(unix)]
1552 #[test]
1553 fn command_cancellation_before_startup_has_no_spawn_effects() {
1554 let error = PocketIcStartupConfig::Spawn {
1555 server_binary: ("/missing/server").into(),
1556 timeout: Duration::from_secs(1),
1557 server_hard_ttl: None,
1558 server_idle_ttl: None,
1559 server_output_files: None,
1560 }
1561 .run_command(&mut Command::new("/missing/command"), || true)
1562 .unwrap_err();
1563 assert!(
1564 matches!(error.failure(), PocketIcStartupFailure::CommandRun { source, .. } if source.kind() == std::io::ErrorKind::Interrupted)
1565 );
1566 assert_eq!(error.output().stdout(), "");
1567 assert_eq!(error.output().stderr(), "");
1568 assert!(error.command_cleanup().is_none());
1569 assert!(error.server_cleanup().is_none());
1570 }
1571
1572 #[cfg(unix)]
1573 #[test]
1574 fn cancellation_callback_panic_still_reaps_the_owned_command() {
1575 let script = TestServerScript::new(
1576 "cancel-panic",
1577 "#!/bin/sh\nprintf '%s' \"$$\" > \"$1\"\nexec sleep 30\n",
1578 );
1579 let pid_file = script.path().with_extension("pid");
1580 let result = std::panic::catch_unwind(|| {
1581 PocketIcStartupConfig::Connect {
1582 server_url: ("http://127.0.0.1:12345/").into(),
1583 timeout: Duration::from_secs(1),
1584 }
1585 .run_command(Command::new(script.path()).arg(&pid_file), || {
1586 assert!(
1587 !fs::read_to_string(&pid_file).is_ok_and(|value| !value.is_empty()),
1588 "caller cancellation failed"
1589 );
1590 false
1591 })
1592 });
1593 assert!(result.is_err());
1594 let pid = fs::read_to_string(&pid_file).unwrap().parse().unwrap();
1595 assert!(process_state(pid).is_none_or(|state| state == 'Z'));
1596 fs::remove_file(pid_file).unwrap();
1597 }
1598
1599 #[cfg(unix)]
1600 #[test]
1601 fn environment_selection_prefers_urls_and_fails_closed() {
1602 use std::os::unix::ffi::OsStringExt as _;
1603
1604 let timeout = Duration::from_secs(1);
1605 let config = PocketIcStartupConfig::from_environment(timeout, |name| {
1606 Some(
1607 if name == "IC_TESTKIT_POCKET_IC_URL" {
1608 "http://127.0.0.1:12345/"
1609 } else {
1610 "/missing/server"
1611 }
1612 .into(),
1613 )
1614 })
1615 .unwrap();
1616 assert!(
1617 matches!(config, PocketIcStartupConfig::Connect { ref server_url, .. } if server_url == "http://127.0.0.1:12345/")
1618 );
1619 assert!(matches!(
1620 PocketIcStartupConfig::from_environment(timeout, |_| None)
1621 .as_ref()
1622 .map_err(PocketIcStartupError::failure),
1623 Err(PocketIcStartupFailure::NotConfigured)
1624 ));
1625 assert!(matches!(
1626 PocketIcStartupConfig::from_environment(timeout, |_| Some("".into()))
1627 .as_ref()
1628 .map_err(PocketIcStartupError::failure),
1629 Err(PocketIcStartupFailure::InvalidEnvironment {
1630 variable: "IC_TESTKIT_POCKET_IC_URL"
1631 })
1632 ));
1633 assert!(matches!(
1634 PocketIcStartupConfig::from_environment(timeout, |_| Some("bad URL".into()))
1635 .as_ref()
1636 .map_err(PocketIcStartupError::failure),
1637 Err(PocketIcStartupFailure::InvalidServerUrl { .. })
1638 ));
1639 assert!(matches!(
1640 PocketIcStartupConfig::from_environment(timeout, |_| Some(
1641 std::ffi::OsString::from_vec(vec![0xff])
1642 ))
1643 .as_ref()
1644 .map_err(PocketIcStartupError::failure),
1645 Err(PocketIcStartupFailure::InvalidEnvironment { .. })
1646 ));
1647 }
1648
1649 #[cfg(unix)]
1650 #[test]
1651 fn version_capture_cleans_wrapper_descendants_on_exit_and_timeout() {
1652 for timeout in [false, true] {
1653 let qualified = format!(
1654 "printf 'pocket-ic-server {}\\n'",
1655 crate::pic::POCKET_IC_SERVER_VERSION
1656 );
1657 let ending = if timeout { "wait" } else { &qualified };
1658 let script = TestServerScript::new(
1659 "version-group",
1660 &format!(
1661 "#!/bin/sh\nsleep 30 >/dev/null 2>&1 &\nprintf '%s' \"$!\" > \"$0.pid\"\n{ending}\n"
1662 ),
1663 );
1664 let result = PocketIcStartupConfig::from_environment(Duration::from_secs(1), |name| {
1665 (name == "POCKET_IC_BIN").then(|| script.path().into_os_string())
1666 });
1667 if timeout {
1668 assert!(matches!(
1669 result.as_ref().map_err(PocketIcStartupError::failure),
1670 Err(PocketIcStartupFailure::ServerVersionProbe { .. })
1671 ));
1672 } else {
1673 assert!(result.is_ok());
1674 }
1675 let pid_file = script.path().with_extension("pid");
1676 let pid = fs::read_to_string(&pid_file).unwrap().parse().unwrap();
1677 let deadline = Instant::now() + Duration::from_secs(3);
1678 while process_state(pid).is_some_and(|state| state != 'Z') {
1679 assert!(
1680 Instant::now() < deadline,
1681 "version wrapper descendant remained alive"
1682 );
1683 std::thread::sleep(Duration::from_millis(20));
1684 }
1685 fs::remove_file(pid_file).unwrap();
1686 }
1687 }
1688
1689 #[cfg(unix)]
1690 #[test]
1691 fn owned_builder_panic_retains_server_output_and_reaps_child() {
1692 let script = TestServerScript::new(
1693 "builder-panic",
1694 "#!/bin/sh\nprintf '%s' \"$$\" > \"$0.pid\"\nprintf 'builder stdout'\nprintf 'builder stderr' >&2\nprintf '34567\\n' > \"$2\"\nexec sleep 30\n",
1695 );
1696 let error = script
1697 .config(Duration::from_secs(2))
1698 .build_instance(PocketIcBuilder::new())
1699 .err()
1700 .unwrap();
1701 let PocketIcStartupFailure::BuilderPanicked { source } = error.failure() else {
1702 panic!("expected builder failure, got {error:?}");
1703 };
1704 assert_ne!(source.message(), "");
1705 assert_eq!(
1706 std::error::Error::source(&error)
1707 .unwrap()
1708 .downcast_ref::<super::super::PocketIcOperationError>(),
1709 Some(source)
1710 );
1711 assert_eq!(error.output().stdout(), "builder stdout");
1712 assert_eq!(error.output().stderr(), "builder stderr");
1713 assert!(error.server_cleanup().is_none());
1714 let pid_file = script.path().with_extension("pid");
1715 let pid = fs::read_to_string(&pid_file).unwrap().parse().unwrap();
1716 assert_eq!(process_state(pid), None);
1717 fs::remove_file(pid_file).unwrap();
1718 }
1719
1720 #[cfg(unix)]
1721 #[test]
1722 fn environment_binary_selection_uses_bounded_shared_version_capture() {
1723 let qualified = format!(
1724 "printf 'pocket-ic-server {}\\n'",
1725 crate::pic::POCKET_IC_SERVER_VERSION
1726 );
1727 let failed = format!("{qualified}; exit 23");
1728 for (label, body, expected) in [
1729 ("qualified", qualified.as_str(), 0),
1730 ("compatible-16.0", "printf 'pocket-ic-server 16.0.0\\n'", 0),
1731 ("compatible-patch", "printf 'pocket-ic-server 16.0.1\\n'", 0),
1732 ("wrong-version", "printf 'pocket-ic-server 15.0.0\\n'", 1),
1733 ("future-major", "printf 'pocket-ic-server 17.0.0\\n'", 1),
1734 ("malformed-version", "printf 'pocket-ic-server 16.1\\n'", 1),
1735 ("failed-version", failed.as_str(), 2),
1736 ("invalid-utf8", "printf '\\377'", 1),
1737 ("version-timeout", "exec sleep 30", 2),
1738 ] {
1739 let script = TestServerScript::new(
1740 label,
1741 &format!("#!/bin/sh\n[ \"$1\" = --version ] || exit 99\n{body}\n"),
1742 );
1743 let timeout = if label == "version-timeout" {
1746 Duration::from_millis(200)
1747 } else {
1748 Duration::from_secs(5)
1749 };
1750 let result = PocketIcStartupConfig::from_environment(timeout, |name| {
1751 (name == "POCKET_IC_BIN").then(|| script.path().into_os_string())
1752 });
1753 match (
1754 expected,
1755 result.as_ref().map_err(PocketIcStartupError::failure),
1756 ) {
1757 (0, Ok(config)) => {
1758 let binary = script.path().canonicalize().unwrap();
1759 assert!(
1760 matches!(config, PocketIcStartupConfig::Spawn { server_binary, .. } if server_binary == &binary)
1761 );
1762 }
1763 (1, Err(PocketIcStartupFailure::ServerVersionMismatch { .. }))
1764 | (2, Err(PocketIcStartupFailure::ServerVersionProbe { .. })) => {}
1765 (_, result) => panic!("unexpected {label} result: {result:?}"),
1766 }
1767 }
1768 }
1769
1770 #[cfg(unix)]
1771 fn process_state(pid: u32) -> Option<char> {
1772 ic_host_process::testing::process_state(pid).expect("inspect managed test process state")
1773 }
1774
1775 #[cfg(unix)]
1776 #[test]
1777 fn reading_large_sparse_server_output_is_bounded() {
1778 let (files, _, _) = StartupFiles::create(None).expect("allocate startup files");
1779 let mut file = fs::File::create(&files.stdout).expect("create sparse log");
1780 file.write_all(b"server started\n").expect("write prefix");
1781 let size = 8_u64 * 1024 * 1024 * 1024;
1782 file.set_len(size).expect("extend sparse log");
1783 let output = super::read_bounded_lossy(&files.stdout);
1784 assert!(output.starts_with("server started\n"));
1785 assert!(output.ends_with(&format!(
1786 "<truncated {} bytes>",
1787 size - super::SERVER_OUTPUT_LIMIT as u64
1788 )));
1789 assert!(output.len() < super::SERVER_OUTPUT_LIMIT + 100);
1790 }
1791
1792 #[cfg(unix)]
1793 #[test]
1794 fn startup_readers_reject_fifos_without_waiting_for_a_writer() {
1795 let (files, stdout, stderr) = StartupFiles::create(None).expect("allocate startup files");
1796 drop((stdout, stderr));
1797 fs::remove_file(&files.stdout).unwrap();
1798 fs::remove_file(&files.stderr).unwrap();
1799 for path in [&files.port, &files.stdout, &files.stderr] {
1800 ic_host_fs::testing::create_fifo(path, None).expect("create FIFO startup file");
1801 }
1802 for path in [&files.port, &files.stdout, &files.stderr] {
1803 let fifo = path.clone();
1807 let (stop_writer, stopped) = mpsc::channel();
1808 let writer = std::thread::spawn(move || {
1809 loop {
1810 match stopped.recv_timeout(Duration::from_millis(200)) {
1811 Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return false,
1812 Err(mpsc::RecvTimeoutError::Timeout) => {}
1813 }
1814 if fs::OpenOptions::new()
1815 .write(true)
1816 .custom_flags(libc::O_NONBLOCK)
1817 .open(&fifo)
1818 .is_ok()
1819 {
1820 return true;
1821 }
1822 }
1823 });
1824 let result = if path == &files.port {
1825 Some(files.read_port())
1826 } else {
1827 assert_eq!(super::read_bounded_lossy(path), "");
1828 None
1829 };
1830 let _ = stop_writer.send(());
1831 assert!(
1832 !writer.join().expect("join delayed FIFO writer"),
1833 "startup reader waited for a writer: {}",
1834 path.display(),
1835 );
1836 if let Some(result) = result {
1837 assert!(matches!(
1838 result.as_ref().map_err(PocketIcStartupError::failure),
1839 Err(PocketIcStartupFailure::Io { source, .. })
1840 if source.kind() == std::io::ErrorKind::InvalidData
1841 ));
1842 }
1843 }
1844 }
1845
1846 #[test]
1847 fn port_file_readiness_preserves_partial_writes_and_rejects_oversized_contents() {
1848 let (files, _, _) = StartupFiles::create(None).expect("allocate startup files");
1849 assert!(matches!(
1850 files.read_port().unwrap(),
1851 super::PortFileState::Pending
1852 ));
1853 for contents in ["", "34567"] {
1854 fs::write(&files.port, contents).unwrap();
1855 assert!(matches!(
1856 files.read_port().unwrap(),
1857 super::PortFileState::Pending
1858 ));
1859 }
1860 for (contents, expected) in [("1\n", 1), ("65535\n", 65535), (" 34567\r\n", 34567)] {
1861 fs::write(&files.port, contents).unwrap();
1862 assert!(matches!(
1863 files.read_port().unwrap(),
1864 super::PortFileState::Ready(port) if port == expected
1865 ));
1866 }
1867 for contents in ["0\n", "65536\n", "invalid\n", "1\n2\n"] {
1868 fs::write(&files.port, contents).unwrap();
1869 assert!(matches!(
1870 files.read_port().unwrap(),
1871 super::PortFileState::Invalid(_)
1872 ));
1873 }
1874 fs::write(&files.port, [0xff, b'\n']).unwrap();
1875 assert!(matches!(
1876 files.read_port().as_ref().map_err(PocketIcStartupError::failure),
1877 Err(PocketIcStartupFailure::Io { source, .. })
1878 if source.kind() == std::io::ErrorKind::InvalidData
1879 ));
1880 for contents in ["1\n".to_owned() + &" ".repeat(128), "0".repeat(128)] {
1881 fs::write(&files.port, contents).unwrap();
1882 assert!(
1883 matches!(files.read_port().unwrap(), super::PortFileState::Invalid(_)),
1884 "oversized port contents must fail even without a newline",
1885 );
1886 }
1887 fs::File::options()
1889 .write(true)
1890 .open(&files.port)
1891 .unwrap()
1892 .set_len(1024 * 1024)
1893 .unwrap();
1894 assert!(matches!(
1895 files.read_port().unwrap(),
1896 super::PortFileState::Invalid(value) if value.len() < 256
1897 ));
1898 }
1899
1900 #[test]
1901 fn startup_config_requires_positive_bounds() {
1902 let config = PocketIcStartupConfig::Spawn {
1903 server_binary: "pocket-ic".into(),
1904 timeout: Duration::from_secs(1),
1905 server_hard_ttl: None,
1906 server_idle_ttl: Some(Duration::from_millis(1)),
1907 server_output_files: None,
1908 };
1909 assert!(matches!(
1910 config.validate().unwrap_err().failure(),
1911 PocketIcStartupFailure::InvalidConfiguration { .. }
1912 ));
1913 let error = PocketIcStartupConfig::Connect {
1914 server_url: ("http://127.0.0.1:1/").into(),
1915 timeout: Duration::ZERO,
1916 }
1917 .validate()
1918 .expect_err("zero startup timeout must fail");
1919 assert!(matches!(
1920 error.failure(),
1921 PocketIcStartupFailure::InvalidConfiguration { .. }
1922 ));
1923
1924 let error = PocketIcStartupConfig::Spawn {
1925 server_binary: ("pocket-ic").into(),
1926 timeout: Duration::from_secs(1),
1927 server_hard_ttl: Some(Duration::from_millis(1)),
1928 server_idle_ttl: None,
1929 server_output_files: None,
1930 }
1931 .validate()
1932 .expect_err("subsecond server hard TTL must fail");
1933 assert!(matches!(
1934 error.failure(),
1935 PocketIcStartupFailure::InvalidConfiguration { .. }
1936 ));
1937 }
1938
1939 #[test]
1940 fn startup_files_leave_the_server_owned_port_path_absent() {
1941 let (files, stdout, stderr) = StartupFiles::create(None).expect("allocate startup files");
1942 let directory = files.directory.path().to_path_buf();
1943
1944 assert!(directory.is_dir());
1945 assert!(!files.port.exists());
1946 assert!(files.stdout.is_file());
1947 assert!(files.stderr.is_file());
1948 #[cfg(unix)]
1949 {
1950 let mode = fs::metadata(&directory)
1951 .expect("inspect private startup directory")
1952 .permissions()
1953 .mode();
1954 assert_eq!(mode & 0o077, 0);
1955 }
1956
1957 drop(stdout);
1958 drop(stderr);
1959 drop(files);
1960 assert!(!directory.exists());
1961 }
1962
1963 #[cfg(unix)]
1964 #[test]
1965 fn caller_output_files_survive_startup_command_and_cancellation_cleanup() {
1966 for outcome in [
1967 "timeout",
1968 "startup-exit",
1969 "server-exit",
1970 "command-exit",
1971 "cancel",
1972 "success",
1973 ] {
1974 let (owner, _, _) = StartupFiles::create(None).unwrap();
1975 let stdout = owner.directory.path().join("retained-stdout");
1976 let stderr = owner.directory.path().join("retained-stderr");
1977 let ending = match outcome {
1978 "timeout" => "exec sleep 30",
1979 "startup-exit" => "exit 41",
1980 "server-exit" => {
1981 "printf '34567\\n' > \"$2\"; while [ ! -s \"$0.command\" ]; do sleep 0.02; done; exit 42"
1982 }
1983 _ => "printf '34567\\n' > \"$2\"; exec sleep 30",
1984 };
1985 let script = TestServerScript::new(
1986 outcome,
1987 &format!(
1988 "#!/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"
1989 ),
1990 );
1991 let pid_file = script.path().with_extension("pid");
1992 let command_file = script.path().with_extension("command");
1993 let config = PocketIcStartupConfig::Spawn {
1994 server_binary: script.path(),
1995 timeout: Duration::from_millis(if outcome == "timeout" { 1000 } else { 2000 }),
1996 server_hard_ttl: None,
1997 server_idle_ttl: None,
1998 server_output_files: Some(((&stdout).into(), (&stderr).into())),
1999 };
2000 if outcome == "timeout" || outcome == "startup-exit" {
2001 let error = config.start_managed_server().err().unwrap();
2002 match (outcome, error.failure()) {
2003 ("timeout", PocketIcStartupFailure::ReadinessTimeout { .. }) => {
2004 assert!(error.output().stdout().contains("truncated"));
2005 assert!(!error.output().stderr().contains("raw-stderr-end"));
2006 }
2007 ("startup-exit", PocketIcStartupFailure::ServerExited { status, .. }) => {
2008 assert_eq!(status.code(), Some(41));
2009 }
2010 (_, error) => panic!("unexpected startup result: {error:?}"),
2011 }
2012 } else {
2013 let command_end = match outcome {
2014 "command-exit" => "exit 37",
2015 "success" => "exit 0",
2016 _ => "exec sleep 30",
2017 };
2018 let result = config.run_command(
2019 Command::new("/bin/sh")
2020 .args([
2021 "-c",
2022 &format!("printf '%s' \"$$\" > \"$1\"; {command_end}"),
2023 "fixture",
2024 ])
2025 .arg(&command_file),
2026 || {
2027 outcome == "cancel"
2028 && fs::metadata(&command_file).is_ok_and(|m| m.len() > 0)
2029 },
2030 );
2031 let result = result.as_ref().map_err(PocketIcStartupError::failure);
2032 match (outcome, result) {
2033 ("command-exit", Ok(status)) => assert_eq!(status.code(), Some(37)),
2034 ("success", Ok(status)) => assert!(status.success()),
2035 ("server-exit", Err(PocketIcStartupFailure::ServerExited { status, .. })) => {
2036 assert_eq!(status.code(), Some(42));
2037 }
2038 ("cancel", Err(PocketIcStartupFailure::CommandRun { source, .. })) => {
2039 assert_eq!(source.kind(), std::io::ErrorKind::Interrupted);
2040 }
2041 (_, result) => panic!("unexpected command result: {result:?}"),
2042 }
2043 let pid = fs::read_to_string(&command_file).unwrap().parse().unwrap();
2044 assert!(process_state(pid).is_none_or(|state| state == 'Z'));
2045 }
2046 for (path, suffix) in [(&stdout, b"raw-stdout-end"), (&stderr, b"raw-stderr-end")] {
2047 let bytes = fs::read(path).unwrap();
2048 assert_eq!(bytes.len(), 20 * 1024 + suffix.len());
2049 assert!(bytes.ends_with(suffix));
2050 assert_eq!(
2051 fs::metadata(path).unwrap().permissions().mode() & 0o777,
2052 0o600
2053 );
2054 }
2055 let report = fs::read_to_string(&pid_file).unwrap();
2056 let mut lines = report.lines();
2057 assert_eq!(process_state(lines.next().unwrap().parse().unwrap()), None);
2058 assert!(
2059 !PathBuf::from(lines.next().unwrap())
2060 .parent()
2061 .unwrap()
2062 .exists()
2063 );
2064 fs::remove_file(pid_file).unwrap();
2065 }
2066 }
2067
2068 #[cfg(unix)]
2069 #[test]
2070 fn caller_output_files_refuse_existing_entries_and_keep_partial_preparation() {
2071 use std::os::unix::fs::symlink;
2072
2073 let (owner, _, _) = StartupFiles::create(None).unwrap();
2074 let stdout = owner.directory.path().join("retained-stdout");
2075 let stderr = owner.directory.path().join("retained-stderr");
2076 let unrelated = owner.directory.path().join("unrelated");
2077 fs::write(&unrelated, b"original").unwrap();
2078 symlink(&unrelated, &stderr).unwrap();
2079 let error = StartupFiles::create(Some((stdout.clone(), stderr.clone())))
2080 .err()
2081 .unwrap();
2082 assert!(
2083 matches!(error.failure(), PocketIcStartupFailure::Io { source, .. } if source.kind() == std::io::ErrorKind::AlreadyExists)
2084 );
2085 assert!(stdout.is_file());
2086 assert_eq!(fs::read(&unrelated).unwrap(), b"original");
2087 fs::write(&stdout, b"retained attempt").unwrap();
2088 assert!(StartupFiles::create(Some((stdout.clone(), stderr.clone()))).is_err());
2089 assert_eq!(fs::read(&stdout).unwrap(), b"retained attempt");
2090 fs::remove_file(stderr.clone()).unwrap();
2091 ic_host_fs::testing::create_fifo(&stderr, None).unwrap();
2092 fs::remove_file(stdout.clone()).unwrap();
2093 assert!(StartupFiles::create(Some((stdout.clone(), stderr))).is_err());
2094 assert!(stdout.is_file());
2095 }
2096
2097 #[cfg(unix)]
2098 #[test]
2099 fn managed_startup_reports_an_exited_server_with_bounded_output() {
2100 let script = TestServerScript::new(
2101 "exit",
2102 "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nprintf 'synthetic server stdout'\nprintf 'synthetic bind failure' >&2\nexit 23\n",
2103 );
2104
2105 let result = script
2106 .config(Duration::from_secs(2))
2107 .build_instance(PocketIcBuilder::new().with_application_subnet());
2108
2109 let error = result.err().unwrap();
2110 let PocketIcStartupFailure::ServerExited {
2111 server_binary,
2112 status,
2113 ..
2114 } = error.failure()
2115 else {
2116 panic!("expected a structured server exit, got {error:?}");
2117 };
2118 assert_eq!(server_binary, &script.path());
2119 assert_eq!(status.code(), Some(23));
2120 assert_eq!(error.output().stdout(), "synthetic server stdout");
2121 assert_eq!(error.output().stderr(), "synthetic bind failure");
2122 }
2123
2124 #[cfg(unix)]
2125 #[test]
2126 fn managed_startup_rejects_oversized_port_files_and_cleans_up() {
2127 let script = TestServerScript::new(
2128 "oversized-port",
2129 "#!/bin/sh\nprintf '%s\\n%s\\n' \"$$\" \"$2\"\nprintf '34567\\n%064s' '' > \"$2.pending\"\nmv \"$2.pending\" \"$2\"\nexec sleep 30\n",
2130 );
2131 let result = script.config(Duration::from_secs(2)).start_managed_server();
2132 let error = result.err().unwrap();
2133 let PocketIcStartupFailure::InvalidServerPort { value, .. } = error.failure() else {
2134 panic!("oversized port publication must fail readiness");
2135 };
2136 assert!(value.contains("port file exceeds"));
2137 assert!(value.len() < 256);
2138 let mut lines = error.output().stdout().lines();
2139 let pid = lines.next().unwrap().parse::<u32>().unwrap();
2140 let port_path = PathBuf::from(lines.next().unwrap());
2141 assert!(!port_path.parent().unwrap().exists());
2142 assert_eq!(process_state(pid), None, "failed server must be reaped");
2143 }
2144
2145 #[cfg(unix)]
2146 #[test]
2147 fn managed_startup_terminates_a_server_that_never_becomes_ready() {
2148 let script = TestServerScript::new(
2149 "timeout",
2150 "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nexec sleep 30\n",
2151 );
2152 let timeout = Duration::from_millis(100);
2153 let started = Instant::now();
2154
2155 let result = script
2156 .config(timeout)
2157 .build_instance(PocketIcBuilder::new().with_application_subnet());
2158
2159 assert!(
2160 started.elapsed() < Duration::from_secs(2),
2161 "bounded startup should not wait for the sleeping child"
2162 );
2163 let error = result.err().unwrap();
2164 assert!(
2165 matches!(error.failure(), PocketIcStartupFailure::ReadinessTimeout {
2166 server_binary, timeout: actual_timeout,
2167 } if server_binary == &script.path() && *actual_timeout == timeout)
2168 );
2169 assert!(error.server_cleanup().is_none());
2170 }
2171
2172 #[cfg(unix)]
2173 #[test]
2174 fn managed_server_handle_exposes_process_id_url_output_and_raii_ownership() {
2175 let script = TestServerScript::new(
2176 "handle",
2177 "#!/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",
2178 );
2179
2180 let server = script
2181 .config(Duration::from_secs(2))
2182 .start_managed_server()
2183 .expect("start caller-owned managed server");
2184
2185 assert_eq!(server.url(), "http://127.0.0.1:34567/");
2186 assert_eq!(
2187 server.output().stdout(),
2188 format!("managed server ready: {}", server.process_id())
2189 );
2190 assert_eq!(server.output().stderr(), "");
2191 let pid = server.process_id();
2192 assert!(process_state(pid).is_some_and(|state| state != 'Z'));
2193 drop(server);
2194 assert_eq!(process_state(pid), None, "owned server must be reaped");
2195 }
2196
2197 #[cfg(unix)]
2198 #[test]
2199 fn managed_server_cleans_descendants_on_drop_timeout_and_exit() {
2200 for mode in ["drop", "timeout", "exit"] {
2201 let publish = if mode == "drop" {
2202 "printf '34567\\n' > \"$2\"\n"
2203 } else {
2204 ""
2205 };
2206 let finish = if mode == "exit" {
2207 "sleep 0.03\nexit 23\n"
2208 } else {
2209 "exec sleep 30\n"
2210 };
2211 let script = TestServerScript::new(
2212 mode,
2213 &format!("#!/bin/sh\nsleep 30 &\nprintf '%s' \"$!\"\n{publish}{finish}"),
2214 );
2215 let result = script
2216 .config(Duration::from_millis(300))
2217 .start_managed_server();
2218 let output = match result {
2219 Ok(server) => {
2220 let output = server.output().stdout().to_owned();
2221 drop(server);
2222 output
2223 }
2224 Err(error) => {
2225 match error.failure() {
2226 PocketIcStartupFailure::ReadinessTimeout { .. } => {
2227 assert_eq!(mode, "timeout");
2228 assert!(error.server_cleanup().is_none());
2229 }
2230 PocketIcStartupFailure::ServerExited { status, .. } => {
2231 assert_eq!(mode, "exit");
2232 assert_eq!(status.code(), Some(23));
2233 }
2234 _ => panic!("unexpected {mode} startup result: {error}"),
2235 }
2236 error.output().stdout().to_owned()
2237 }
2238 };
2239 let pid = output
2240 .parse::<u32>()
2241 .expect("server published its descendant PID");
2242 let deadline = Instant::now() + Duration::from_secs(2);
2243 while process_state(pid).is_some_and(|state| state != 'Z') {
2244 assert!(
2245 Instant::now() < deadline,
2246 "{mode} left its descendant running"
2247 );
2248 std::thread::sleep(Duration::from_millis(10));
2249 }
2250 }
2251 }
2252
2253 #[cfg(unix)]
2254 #[test]
2255 fn managed_server_passes_an_explicit_hard_ttl() {
2256 let script = TestServerScript::new(
2257 "hard-ttl",
2258 "#!/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",
2259 );
2260
2261 let server = PocketIcStartupConfig::Spawn {
2262 server_binary: script.path(),
2263 timeout: Duration::from_secs(2),
2264 server_hard_ttl: Some(Duration::from_secs(17)),
2265 server_idle_ttl: None,
2266 server_output_files: None,
2267 }
2268 .start_managed_server()
2269 .expect("start managed server with an explicit hard TTL");
2270
2271 assert_eq!(server.url(), "http://127.0.0.1:34567/");
2272 }
2273
2274 #[cfg(unix)]
2275 #[test]
2276 #[ignore = "requires POCKET_IC_BIN=<caller-provided PocketIC server binary>"]
2277 fn caller_provided_server_publishes_port_constructs_instance_and_cleans_up() {
2278 let binary = std::env::var_os("POCKET_IC_BIN")
2279 .map(PathBuf::from)
2280 .expect("set POCKET_IC_BIN to the exact server binary");
2281 let wrapper = TestServerScript::new(
2284 "real-owned-instance",
2285 "#!/bin/sh\nprintf '%s\n%s\n' \"$$\" \"$4\" > \"$0.record\"\nexec \"$POCKET_IC_BIN\" \"$@\"\n",
2286 );
2287 let owned = PocketIcStartupConfig::Spawn {
2288 server_binary: wrapper.path(),
2289 timeout: Duration::from_secs(30),
2290 server_hard_ttl: Some(Duration::from_secs(60)),
2291 server_idle_ttl: None,
2292 server_output_files: None,
2293 }
2294 .build_instance(PocketIcBuilder::new().with_application_subnet())
2295 .expect("owned managed spawn must construct an instance");
2296 let record_file = wrapper.path().with_extension("record");
2297 let record = fs::read_to_string(&record_file).unwrap();
2298 let mut fields = record.lines();
2299 let pid = fields.next().unwrap().parse().unwrap();
2300 let port_path = PathBuf::from(fields.next().unwrap());
2301 let startup_directory = port_path.parent().unwrap();
2302 drop(owned);
2303 assert!(!startup_directory.exists());
2304 assert_eq!(process_state(pid), None);
2305 fs::remove_file(record_file).unwrap();
2306
2307 let server = PocketIcStartupConfig::Spawn {
2308 server_binary: (&binary).into(),
2309 timeout: Duration::from_secs(30),
2310 server_hard_ttl: Some(Duration::from_secs(60)),
2311 server_idle_ttl: None,
2312 server_output_files: None,
2313 }
2314 .start_managed_server()
2315 .expect("caller-provided PocketIC server must publish its port");
2316 let files = &server.server.files;
2317 let startup_directory = files.directory.path().to_path_buf();
2318
2319 assert!(files.port.is_file());
2320 let pocket_ic = PocketIcStartupConfig::Connect {
2321 server_url: (server.url()).into(),
2322 timeout: Duration::from_secs(30),
2323 }
2324 .build_instance(PocketIcBuilder::new().with_application_subnet())
2325 .expect("construct instance through caller-provided server");
2326
2327 drop(pocket_ic);
2328 drop(server);
2329 assert!(!startup_directory.exists());
2330 }
2331
2332 #[cfg(unix)]
2333 struct TestServerScript {
2334 path: PathBuf,
2335 #[expect(
2336 dead_code,
2337 reason = "retains directory ownership until cleanup or drop"
2338 )]
2339 directory: OwnedDirectory,
2340 }
2341
2342 #[cfg(unix)]
2343 impl TestServerScript {
2344 fn new(label: &str, contents: &str) -> Self {
2345 let directory = OwnedDirectory::new(
2346 create_unique_directory(
2347 &std::env::temp_dir(),
2348 &format!("ic-testkit-pocket-ic-{label}"),
2349 0o700,
2350 None,
2351 )
2352 .expect("create test server directory"),
2353 );
2354 let path = directory.path().join("server");
2355 write_executable_script(&path, contents).expect("write executable test script");
2356 Self { path, directory }
2357 }
2358
2359 fn path(&self) -> PathBuf {
2360 self.path.clone()
2361 }
2362
2363 fn config(&self, timeout: Duration) -> PocketIcStartupConfig {
2364 PocketIcStartupConfig::Spawn {
2365 server_binary: self.path(),
2366 timeout,
2367 server_hard_ttl: None,
2368 server_idle_ttl: None,
2369 server_output_files: None,
2370 }
2371 }
2372 }
2373}