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