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