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