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