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