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