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