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