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