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