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