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