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