1use std::{
2 fmt::Write as _,
3 fs::{self, File, OpenOptions},
4 io::{self, Read as _},
5 path::{Path, PathBuf},
6 process::{Command, ExitStatus, Stdio},
7 sync::{
8 atomic::{AtomicU64, Ordering},
9 mpsc::{self, RecvTimeoutError},
10 },
11 thread,
12 time::{Duration, Instant},
13};
14
15#[cfg(unix)]
16use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _};
17
18use ic_host_process::child::OwnedChild;
19use pocket_ic::{PocketIc, PocketIcBuilder};
20
21use super::transport;
22
23const STARTUP_POLL_INTERVAL: Duration = Duration::from_millis(20);
24const SERVER_OUTPUT_LIMIT: usize = 16 * 1024;
25const SERVER_PORT_FILE_LIMIT: usize = 64;
28
29static STARTUP_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
30
31#[derive(Clone, Debug, Eq, PartialEq)]
33pub struct PocketIcStartupConfig {
34 source: PocketIcStartupSource,
35 timeout: Duration,
36 server_hard_ttl: Option<Duration>,
37}
38
39pub struct PocketIcManagedServer {
50 server: ManagedServer,
51 url: String,
52}
53
54#[derive(Clone, Debug, Default, Eq, PartialEq)]
60pub struct PocketIcManagedServerOutput {
61 stdout: String,
62 stderr: String,
63}
64
65#[derive(Clone, Debug, Eq, PartialEq)]
66enum PocketIcStartupSource {
67 Spawn { server_binary: PathBuf },
68 Connect { server_url: String },
69}
70
71#[non_exhaustive]
73#[derive(Debug)]
74pub enum PocketIcStartupError {
75 NotConfigured,
77 InvalidEnvironment { variable: &'static str },
79 ServerVersionProbe {
81 source: ic_host_process::tool::ToolError,
82 },
83 ServerVersionMismatch { expected: String, observed: Vec<u8> },
85 CommandRun {
87 program: PathBuf,
88 source: io::Error,
89 termination_error: Option<String>,
90 },
91 InvalidConfiguration { message: String },
93 InvalidServerUrl { server_url: String, message: String },
95 Io {
97 operation: &'static str,
98 path: PathBuf,
99 source: io::Error,
100 },
101 ServerSpawn {
103 server_binary: PathBuf,
104 source: io::Error,
105 },
106 ServerExited {
108 server_binary: PathBuf,
109 status: ExitStatus,
110 elapsed: Duration,
111 stdout: String,
112 stderr: String,
113 },
114 ReadinessTimeout {
116 server_binary: PathBuf,
117 timeout: Duration,
118 stdout: String,
119 stderr: String,
120 termination_error: Option<String>,
121 },
122 InvalidServerPort {
124 server_binary: PathBuf,
125 value: String,
126 stdout: String,
127 stderr: String,
128 },
129 InstanceCreationTimeout {
131 timeout: Duration,
132 stdout: String,
133 stderr: String,
134 termination_error: Option<String>,
135 },
136 BuilderThreadSpawn { source: io::Error },
138 BuilderPanicked { message: String },
140 BuilderDisconnected,
142}
143
144pub trait PocketIcBuilderExt {
150 fn try_build(self, config: PocketIcStartupConfig) -> Result<PocketIc, PocketIcStartupError>;
160}
161
162impl PocketIcStartupConfig {
163 pub fn from_env(timeout: Duration) -> Result<Self, PocketIcStartupError> {
174 Self::from_environment(timeout, |name| std::env::var_os(name))
175 }
176
177 fn from_environment(
178 timeout: Duration,
179 mut variable: impl FnMut(&str) -> Option<std::ffi::OsString>,
180 ) -> Result<Self, PocketIcStartupError> {
181 if let Some(value) = variable("IC_TESTKIT_POCKET_IC_URL") {
182 let server_url = value
183 .into_string()
184 .ok()
185 .filter(|value| !value.is_empty())
186 .ok_or(PocketIcStartupError::InvalidEnvironment {
187 variable: "IC_TESTKIT_POCKET_IC_URL",
188 })?;
189 let config = Self::connect(&server_url, timeout);
190 config.validate()?;
191 let parsed =
192 server_url
193 .parse()
194 .map_err(|error| PocketIcStartupError::InvalidServerUrl {
195 server_url: server_url.clone(),
196 message: format!("{error}"),
197 })?;
198 let _ = PocketIcBuilder::new().with_server_url(parsed);
199 return Ok(config);
200 }
201 let value = variable("POCKET_IC_BIN").ok_or(PocketIcStartupError::NotConfigured)?;
202 if value.is_empty() {
203 return Err(PocketIcStartupError::InvalidEnvironment {
204 variable: "POCKET_IC_BIN",
205 });
206 }
207 let path = PathBuf::from(value);
208 let binary = fs::canonicalize(&path).map_err(|source| PocketIcStartupError::Io {
209 operation: "resolve configured PocketIC executable",
210 path,
211 source,
212 })?;
213 let config = Self::spawn(&binary, timeout);
214 config.validate()?;
215 let evidence = ic_host_process::tool::capture_command(
216 Command::new(&binary).arg("--version"),
217 ic_host_process::tool::OutputLimits {
218 stdout_bytes: SERVER_OUTPUT_LIMIT,
219 stderr_bytes: SERVER_OUTPUT_LIMIT,
220 timeout,
221 },
222 )
223 .map_err(|source| PocketIcStartupError::ServerVersionProbe { source })?;
224 let expected = format!("pocket-ic-server {}", pocket_ic::LATEST_SERVER_VERSION);
225 if std::str::from_utf8(&evidence.stdout).map(str::trim) != Ok(expected.as_str()) {
226 return Err(PocketIcStartupError::ServerVersionMismatch {
227 expected,
228 observed: evidence.stdout,
229 });
230 }
231 Ok(config)
232 }
233
234 #[must_use]
239 pub fn spawn(server_binary: impl Into<PathBuf>, timeout: Duration) -> Self {
240 Self {
241 source: PocketIcStartupSource::Spawn {
242 server_binary: server_binary.into(),
243 },
244 timeout,
245 server_hard_ttl: None,
246 }
247 }
248
249 #[must_use]
254 pub fn connect(server_url: impl Into<String>, timeout: Duration) -> Self {
255 Self {
256 source: PocketIcStartupSource::Connect {
257 server_url: server_url.into(),
258 },
259 timeout,
260 server_hard_ttl: None,
261 }
262 }
263
264 #[must_use]
266 pub const fn with_server_hard_ttl(mut self, hard_ttl: Duration) -> Self {
267 self.server_hard_ttl = Some(hard_ttl);
268 self
269 }
270
271 #[must_use]
273 pub const fn timeout(&self) -> Duration {
274 self.timeout
275 }
276
277 #[must_use]
279 pub const fn server_hard_ttl(&self) -> Option<Duration> {
280 self.server_hard_ttl
281 }
282
283 #[must_use]
285 pub fn server_binary(&self) -> Option<&Path> {
286 match &self.source {
287 PocketIcStartupSource::Spawn { server_binary } => Some(server_binary),
288 PocketIcStartupSource::Connect { .. } => None,
289 }
290 }
291
292 #[must_use]
294 pub fn server_url(&self) -> Option<&str> {
295 match &self.source {
296 PocketIcStartupSource::Connect { server_url } => Some(server_url),
297 PocketIcStartupSource::Spawn { .. } => None,
298 }
299 }
300
301 pub fn start_managed_server(self) -> Result<PocketIcManagedServer, PocketIcStartupError> {
313 self.validate()?;
314 let PocketIcStartupSource::Spawn { server_binary } = self.source else {
315 return Err(PocketIcStartupError::InvalidConfiguration {
316 message: "starting a managed PocketIC server requires a spawn configuration"
317 .to_owned(),
318 });
319 };
320 let started = Instant::now();
321 let deadline = startup_deadline(started, self.timeout)?;
322 let (server, url) = ManagedServer::start(
323 server_binary,
324 self.server_hard_ttl,
325 deadline,
326 self.timeout,
327 started,
328 )?;
329 Ok(PocketIcManagedServer { server, url })
330 }
331
332 pub fn run_command(
349 self,
350 command: &mut Command,
351 mut cancelled: impl FnMut() -> bool,
352 ) -> Result<ExitStatus, PocketIcStartupError> {
353 self.validate()?;
354 if cancelled() {
355 return Err(PocketIcStartupError::Io {
356 operation: "run command with PocketIC server",
357 path: PathBuf::from(command.get_program()),
358 source: io::Error::from(io::ErrorKind::Interrupted),
359 });
360 }
361 let (mut server, url) = if let Some(url) = self.server_url() {
362 (None, url.to_owned())
363 } else {
364 let server = self.start_managed_server()?;
365 let url = server.url().to_owned();
366 (Some(server), url)
367 };
368 let command_error = |source| PocketIcStartupError::Io {
369 operation: "run command with PocketIC server",
370 path: PathBuf::from(command.get_program()),
371 source,
372 };
373 if cancelled() {
374 return Err(command_error(io::Error::from(io::ErrorKind::Interrupted)));
375 }
376 command.env("IC_TESTKIT_POCKET_IC_URL", url);
377 let mut owned_child =
378 OwnedChild::spawn(command).map_err(|source| PocketIcStartupError::Io {
379 operation: "spawn command with PocketIC server",
380 path: PathBuf::from(command.get_program()),
381 source,
382 })?;
383 let result = loop {
384 if cancelled() {
385 break Err(io::Error::from(io::ErrorKind::Interrupted));
386 }
387 match owned_child.try_wait() {
388 Ok(Some(status)) => break Ok(status),
389 Ok(None) => {
390 if let Some(managed) = server.as_mut()
391 && let Some(status) = managed.server.try_wait()?
392 {
393 return Err(server
394 .take()
395 .expect("managed server remains owned")
396 .server
397 .exited_error(status));
398 }
399 thread::sleep(STARTUP_POLL_INTERVAL);
400 }
401 Err(source) => break Err(source),
402 }
403 };
404 let termination_error = result
405 .is_err()
406 .then(|| owned_child.terminate().err().map(|error| error.to_string()))
407 .flatten();
408 drop(server);
409 result.map_err(|source| PocketIcStartupError::CommandRun {
410 program: PathBuf::from(command.get_program()),
411 source,
412 termination_error,
413 })
414 }
415
416 fn validate(&self) -> Result<(), PocketIcStartupError> {
417 if self.timeout.is_zero() {
418 return Err(PocketIcStartupError::InvalidConfiguration {
419 message: "PocketIC startup timeout must be greater than zero".to_owned(),
420 });
421 }
422 if matches!(&self.source, PocketIcStartupSource::Spawn { .. })
423 && self
424 .server_hard_ttl
425 .is_some_and(|hard_ttl| hard_ttl.as_secs() == 0)
426 {
427 return Err(PocketIcStartupError::InvalidConfiguration {
428 message: "PocketIC server hard TTL must be at least one second".to_owned(),
429 });
430 }
431 Ok(())
432 }
433}
434
435impl PocketIcManagedServer {
436 #[must_use]
443 pub fn process_id(&self) -> u32 {
444 self.server
445 .child
446 .as_ref()
447 .expect("managed server handle must own its child")
448 .id()
449 }
450
451 #[must_use]
453 pub fn url(&self) -> &str {
454 &self.url
455 }
456
457 #[must_use]
463 pub fn output(&self) -> PocketIcManagedServerOutput {
464 self.server.capture().into()
465 }
466}
467
468impl PocketIcManagedServerOutput {
469 #[must_use]
471 pub fn stdout(&self) -> &str {
472 &self.stdout
473 }
474
475 #[must_use]
477 pub fn stderr(&self) -> &str {
478 &self.stderr
479 }
480}
481
482impl PocketIcBuilderExt for PocketIcBuilder {
483 fn try_build(self, config: PocketIcStartupConfig) -> Result<PocketIc, PocketIcStartupError> {
484 config.validate()?;
485 let started = Instant::now();
486 let deadline = startup_deadline(started, config.timeout)?;
487 match config.source {
488 PocketIcStartupSource::Connect { server_url } => {
489 build_bounded(self, &server_url, deadline, config.timeout, None)
490 }
491 PocketIcStartupSource::Spawn { server_binary } => {
492 let (server, server_url) = ManagedServer::start(
493 server_binary,
494 config.server_hard_ttl,
495 deadline,
496 config.timeout,
497 started,
498 )?;
499 build_bounded(self, &server_url, deadline, config.timeout, Some(server))
500 }
501 }
502 }
503}
504
505fn startup_deadline(started: Instant, timeout: Duration) -> Result<Instant, PocketIcStartupError> {
506 started
507 .checked_add(timeout)
508 .ok_or_else(|| PocketIcStartupError::InvalidConfiguration {
509 message: "PocketIC startup timeout exceeds the platform clock range".to_owned(),
510 })
511}
512
513fn build_bounded(
514 builder: PocketIcBuilder,
515 server_url: &str,
516 deadline: Instant,
517 timeout: Duration,
518 mut server: Option<ManagedServer>,
519) -> Result<PocketIc, PocketIcStartupError> {
520 let builder = match server_url.parse() {
521 Ok(server_url) => builder.with_server_url(server_url),
522 Err(error) => {
523 return Err(PocketIcStartupError::InvalidServerUrl {
524 server_url: server_url.to_owned(),
525 message: error.to_string(),
526 });
527 }
528 };
529 let (sender, receiver) = mpsc::sync_channel(1);
530 if let Err(source) = thread::Builder::new()
531 .name("ic-testkit-pocket-ic-startup".to_owned())
532 .spawn(move || {
533 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| builder.build()))
534 .map_err(|payload| transport::panic_payload_to_string(payload.as_ref()));
535 let _ = sender.send(result);
536 })
537 {
538 return Err(PocketIcStartupError::BuilderThreadSpawn { source });
539 }
540
541 loop {
542 let now = Instant::now();
543 if now >= deadline {
544 let captured = server.take().map_or_else(
545 CapturedServer::default,
546 ManagedServer::terminate_and_capture,
547 );
548 return Err(PocketIcStartupError::InstanceCreationTimeout {
549 timeout,
550 stdout: captured.stdout,
551 stderr: captured.stderr,
552 termination_error: captured.termination_error,
553 });
554 }
555 let remaining = deadline.saturating_duration_since(now);
556 let wait = if server.is_some() {
557 remaining.min(STARTUP_POLL_INTERVAL)
558 } else {
559 remaining
560 };
561 match receiver.recv_timeout(wait) {
562 Ok(Ok(pocket_ic)) => {
563 if let Some(mut managed) = server.take() {
564 if let Some(status) = managed.try_wait()? {
565 return Err(managed.exited_error(status));
566 }
567 managed.reap_in_background();
568 }
569 return Ok(pocket_ic);
570 }
571 Ok(Err(message)) => {
572 if let Some(server) = server.take() {
573 let _ = server.terminate_and_capture();
574 }
575 return Err(PocketIcStartupError::BuilderPanicked { message });
576 }
577 Err(RecvTimeoutError::Disconnected) => {
578 if let Some(server) = server.take() {
579 let _ = server.terminate_and_capture();
580 }
581 return Err(PocketIcStartupError::BuilderDisconnected);
582 }
583 Err(RecvTimeoutError::Timeout) => {
584 if let Some(managed) = &mut server
585 && let Some(status) = managed.try_wait()?
586 {
587 return Err(server
588 .take()
589 .expect("managed server must remain present")
590 .exited_error(status));
591 }
592 }
593 }
594 }
595}
596
597struct ManagedServer {
598 child: Option<OwnedChild>,
599 binary: PathBuf,
600 files: StartupFiles,
601 started: Instant,
602}
603
604enum PortFileState {
605 Pending,
606 Ready(u16),
607 Invalid(String),
608}
609
610impl ManagedServer {
611 fn start(
612 binary: PathBuf,
613 hard_ttl: Option<Duration>,
614 deadline: Instant,
615 timeout: Duration,
616 started: Instant,
617 ) -> Result<(Self, String), PocketIcStartupError> {
618 let (files, stdout, stderr) = StartupFiles::create()?;
619 let mut command = Command::new(&binary);
620 if let Some(hard_ttl) = hard_ttl {
621 command
622 .arg("--hard-ttl")
623 .arg(hard_ttl.as_secs().to_string());
624 }
625 command
626 .arg("--port-file")
627 .arg(&files.port)
628 .stdout(Stdio::from(stdout))
629 .stderr(Stdio::from(stderr));
630 let child = OwnedChild::spawn(&mut command).map_err(|source| {
631 PocketIcStartupError::ServerSpawn {
632 server_binary: binary.clone(),
633 source,
634 }
635 })?;
636 let mut server = Self {
637 child: Some(child),
638 binary,
639 files,
640 started,
641 };
642
643 loop {
644 if let Some(status) = server.try_wait()? {
645 return Err(server.exited_error(status));
646 }
647 let now = Instant::now();
648 if now >= deadline {
649 let binary = server.binary.clone();
650 let captured = server.terminate_and_capture();
651 return Err(PocketIcStartupError::ReadinessTimeout {
652 server_binary: binary,
653 timeout,
654 stdout: captured.stdout,
655 stderr: captured.stderr,
656 termination_error: captured.termination_error,
657 });
658 }
659 match server.read_port()? {
660 PortFileState::Pending => {}
661 PortFileState::Ready(port) => {
662 return Ok((server, format!("http://127.0.0.1:{port}/")));
663 }
664 PortFileState::Invalid(value) => {
665 let binary = server.binary.clone();
666 let captured = server.terminate_and_capture();
667 return Err(PocketIcStartupError::InvalidServerPort {
668 server_binary: binary,
669 value,
670 stdout: captured.stdout,
671 stderr: captured.stderr,
672 });
673 }
674 }
675 thread::sleep(
676 deadline
677 .saturating_duration_since(now)
678 .min(STARTUP_POLL_INTERVAL),
679 );
680 }
681 }
682
683 fn try_wait(&mut self) -> Result<Option<ExitStatus>, PocketIcStartupError> {
684 let child = self
685 .child
686 .as_mut()
687 .expect("managed server child must remain present");
688 child.try_wait().map_err(|source| PocketIcStartupError::Io {
689 operation: "inspect PocketIC server child",
690 path: self.binary.clone(),
691 source,
692 })
693 }
694
695 fn read_port(&self) -> Result<PortFileState, PocketIcStartupError> {
696 let port_path = &self.files.port;
697 let mut contents = String::new();
698 match open_regular_startup_file(port_path).and_then(|file| {
699 file.take((SERVER_PORT_FILE_LIMIT + 1) as u64)
700 .read_to_string(&mut contents)
701 }) {
702 Ok(_) => {}
703 Err(error) if error.kind() == io::ErrorKind::NotFound => {
704 return Ok(PortFileState::Pending);
705 }
706 Err(source) => {
707 return Err(PocketIcStartupError::Io {
708 operation: "read PocketIC server port file",
709 path: port_path.clone(),
710 source,
711 });
712 }
713 }
714 if contents.len() > SERVER_PORT_FILE_LIMIT {
715 return Ok(PortFileState::Invalid(format!(
716 "{} (port file exceeds {SERVER_PORT_FILE_LIMIT} bytes)",
717 contents.trim()
718 )));
719 }
720 if !contents.contains('\n') {
721 return Ok(PortFileState::Pending);
722 }
723 let value = contents.trim().to_owned();
724 match value.parse::<u16>() {
725 Ok(port) if port != 0 => Ok(PortFileState::Ready(port)),
726 _ => Ok(PortFileState::Invalid(value)),
727 }
728 }
729
730 fn exited_error(mut self, status: ExitStatus) -> PocketIcStartupError {
731 let elapsed = self.started.elapsed();
732 let binary = self.binary.clone();
733 self.child.take();
734 let captured = self.capture();
735 PocketIcStartupError::ServerExited {
736 server_binary: binary,
737 status,
738 elapsed,
739 stdout: captured.stdout,
740 stderr: captured.stderr,
741 }
742 }
743
744 fn terminate_and_capture(mut self) -> CapturedServer {
745 let termination_error = match self.child.take() {
746 Some(mut child) => child.terminate().err().map(|error| error.to_string()),
747 None => None,
748 };
749 let mut captured = self.capture();
750 captured.termination_error = termination_error;
751 captured
752 }
753
754 fn capture(&self) -> CapturedServer {
755 let files = &self.files;
756 CapturedServer {
757 stdout: read_bounded_lossy(&files.stdout),
758 stderr: read_bounded_lossy(&files.stderr),
759 termination_error: None,
760 }
761 }
762
763 fn reap_in_background(self) {
764 let _ = thread::Builder::new()
765 .name("ic-testkit-pocket-ic-server-reaper".to_owned())
766 .spawn(move || {
767 let mut server = self;
770 if let Some(child) = server.child.as_mut() {
771 let _ = child.wait();
772 }
773 });
774 }
775}
776
777#[derive(Default)]
778struct CapturedServer {
779 stdout: String,
780 stderr: String,
781 termination_error: Option<String>,
782}
783
784impl From<CapturedServer> for PocketIcManagedServerOutput {
785 fn from(captured: CapturedServer) -> Self {
786 Self {
787 stdout: captured.stdout,
788 stderr: captured.stderr,
789 }
790 }
791}
792
793struct StartupFiles {
794 directory: PathBuf,
795 port: PathBuf,
796 stdout: PathBuf,
797 stderr: PathBuf,
798}
799
800impl StartupFiles {
801 fn create() -> Result<(Self, File, File), PocketIcStartupError> {
802 loop {
803 let sequence = STARTUP_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
804 let base = std::env::temp_dir().join(format!(
805 "ic-testkit-pocket-ic-startup-{}-{sequence}",
806 std::process::id()
807 ));
808 let mut directory = fs::DirBuilder::new();
809 #[cfg(unix)]
810 {
811 directory.mode(0o700);
812 }
813 match directory.create(&base) {
814 Ok(()) => {}
815 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
816 Err(source) => return Err(startup_file_error("create", &base, source)),
817 }
818 let files = Self {
819 port: base.join("port"),
820 stdout: base.join("stdout"),
821 stderr: base.join("stderr"),
822 directory: base,
823 };
824 let stdout = create_new_file(&files.stdout)
825 .map_err(|source| startup_file_error("create", &files.stdout, source))?;
826 let stderr = create_new_file(&files.stderr)
827 .map_err(|source| startup_file_error("create", &files.stderr, source))?;
828 return Ok((files, stdout, stderr));
829 }
830 }
831}
832
833impl Drop for StartupFiles {
834 fn drop(&mut self) {
835 let _ = fs::remove_dir_all(&self.directory);
836 }
837}
838
839fn create_new_file(path: &Path) -> io::Result<File> {
840 OpenOptions::new().write(true).create_new(true).open(path)
841}
842
843fn startup_file_error(
844 operation: &'static str,
845 path: &Path,
846 source: io::Error,
847) -> PocketIcStartupError {
848 PocketIcStartupError::Io {
849 operation,
850 path: path.to_owned(),
851 source,
852 }
853}
854
855fn read_bounded_lossy(path: &Path) -> String {
856 let Ok(file) = open_regular_startup_file(path) else {
857 return String::new();
858 };
859 let length = file.metadata().map_or(0, |metadata| metadata.len());
860 let Ok(bytes) = ic_host_artifacts::artifact::read_reader(
861 file.take(SERVER_OUTPUT_LIMIT as u64),
862 SERVER_OUTPUT_LIMIT,
863 ) else {
864 return String::new();
865 };
866 let mut output = String::from_utf8_lossy(&bytes).into_owned();
867 let omitted = length.saturating_sub(bytes.len() as u64);
868 if omitted > 0 {
869 let _ = write!(output, "\n<truncated {omitted} bytes>");
870 }
871 output
872}
873
874fn open_regular_startup_file(path: &Path) -> io::Result<File> {
875 let mut options = OpenOptions::new();
876 options.read(true);
877 #[cfg(unix)]
880 options.custom_flags(libc::O_NONBLOCK);
881 let file = options.open(path)?;
882 if !file.metadata()?.is_file() {
883 return Err(io::Error::new(
884 io::ErrorKind::InvalidData,
885 "PocketIC startup reader requires a regular file",
886 ));
887 }
888 Ok(file)
889}
890
891impl std::fmt::Display for PocketIcStartupError {
892 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
893 match self {
894 Self::NotConfigured => formatter.write_str("configure IC_TESTKIT_POCKET_IC_URL or POCKET_IC_BIN; prepare a verified binary with make install-tools"),
895 Self::InvalidEnvironment { variable } => write!(formatter, "invalid selected environment value: {variable}"),
896 Self::ServerVersionProbe { source } => write!(formatter, "PocketIC version probe failed: {source}"),
897 Self::ServerVersionMismatch { expected, observed } => write!(formatter, "PocketIC version mismatch: expected {expected:?}, observed {:?}", String::from_utf8_lossy(observed)),
898 Self::CommandRun { program, source, termination_error } => {
899 write!(formatter, "command {} failed: {source}", program.display())?;
900 if let Some(error) = termination_error { write!(formatter, "; cleanup also failed: {error}")?; }
901 Ok(())
902 }
903 Self::InvalidConfiguration { message } => formatter.write_str(message),
904 Self::InvalidServerUrl {
905 server_url,
906 message,
907 } => write!(
908 formatter,
909 "invalid PocketIC server URL {server_url:?}: {message}"
910 ),
911 Self::Io {
912 operation,
913 path,
914 source,
915 } => write!(
916 formatter,
917 "failed to {operation} at {}: {source}",
918 path.display()
919 ),
920 Self::ServerSpawn {
921 server_binary,
922 source,
923 } => write!(
924 formatter,
925 "failed to spawn PocketIC server {}: {source}",
926 server_binary.display()
927 ),
928 Self::ServerExited {
929 server_binary,
930 status,
931 elapsed,
932 stderr,
933 ..
934 } => write!(
935 formatter,
936 "PocketIC server {} exited with {status} after {elapsed:?}: {stderr}",
937 server_binary.display()
938 ),
939 Self::ReadinessTimeout {
940 server_binary,
941 timeout,
942 ..
943 } => write!(
944 formatter,
945 "PocketIC server {} was not ready within {timeout:?}",
946 server_binary.display()
947 ),
948 Self::InvalidServerPort {
949 server_binary,
950 value,
951 ..
952 } => write!(
953 formatter,
954 "PocketIC server {} published invalid port {value:?}",
955 server_binary.display()
956 ),
957 Self::InstanceCreationTimeout { timeout, .. } => {
958 write!(formatter, "PocketIC instance creation exceeded {timeout:?}")
959 }
960 Self::BuilderThreadSpawn { source } => {
961 write!(
962 formatter,
963 "failed to spawn PocketIC builder worker: {source}"
964 )
965 }
966 Self::BuilderPanicked { message } => {
967 write!(formatter, "PocketIC startup panicked: {message}")
968 }
969 Self::BuilderDisconnected => {
970 formatter.write_str("PocketIC builder worker disconnected without a result")
971 }
972 }
973 }
974}
975
976impl std::error::Error for PocketIcStartupError {
977 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
978 match self {
979 Self::ServerVersionProbe { source } => Some(source),
980 Self::Io { source, .. }
981 | Self::CommandRun { source, .. }
982 | Self::ServerSpawn { source, .. }
983 | Self::BuilderThreadSpawn { source } => Some(source),
984 _ => None,
985 }
986 }
987}
988
989#[cfg(test)]
990mod tests {
991 use std::{
992 fs,
993 path::PathBuf,
994 time::{Duration, Instant},
995 };
996
997 use super::{
998 PocketIcBuilderExt as _, PocketIcStartupConfig, PocketIcStartupError, StartupFiles,
999 };
1000 use pocket_ic::PocketIcBuilder;
1001
1002 #[cfg(unix)]
1003 use crate::test_executable::write_executable_script;
1004 #[cfg(unix)]
1005 use std::{
1006 io::Write as _,
1007 os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _},
1008 process::Command,
1009 sync::mpsc,
1010 };
1011
1012 #[cfg(unix)]
1013 #[test]
1014 fn command_cancellation_before_startup_has_no_spawn_effects() {
1015 let error = PocketIcStartupConfig::spawn("/missing/server", Duration::from_secs(1))
1016 .run_command(&mut Command::new("/missing/command"), || true)
1017 .unwrap_err();
1018 assert!(
1019 matches!(error, PocketIcStartupError::Io { source, .. } if source.kind() == std::io::ErrorKind::Interrupted)
1020 );
1021 }
1022
1023 #[cfg(unix)]
1024 #[test]
1025 fn cancellation_callback_panic_still_reaps_the_owned_command() {
1026 let script = TestServerScript::new(
1027 "cancel-panic",
1028 "#!/bin/sh\nprintf '%s' \"$$\" > \"$1\"\nexec sleep 30\n",
1029 );
1030 let pid_file = script.path().with_extension("pid");
1031 let result = std::panic::catch_unwind(|| {
1032 PocketIcStartupConfig::connect("http://127.0.0.1:12345/", Duration::from_secs(1))
1033 .run_command(Command::new(script.path()).arg(&pid_file), || {
1034 assert!(
1035 !fs::read_to_string(&pid_file).is_ok_and(|value| !value.is_empty()),
1036 "caller cancellation failed"
1037 );
1038 false
1039 })
1040 });
1041 assert!(result.is_err());
1042 let pid = fs::read_to_string(&pid_file).unwrap().parse().unwrap();
1043 assert!(process_state(pid).is_none_or(|state| state == 'Z'));
1044 fs::remove_file(pid_file).unwrap();
1045 }
1046
1047 #[cfg(unix)]
1048 #[test]
1049 fn environment_selection_prefers_urls_and_fails_closed() {
1050 use std::os::unix::ffi::OsStringExt as _;
1051
1052 let timeout = Duration::from_secs(1);
1053 let config = PocketIcStartupConfig::from_environment(timeout, |name| {
1054 Some(
1055 if name == "IC_TESTKIT_POCKET_IC_URL" {
1056 "http://127.0.0.1:12345/"
1057 } else {
1058 "/missing/server"
1059 }
1060 .into(),
1061 )
1062 })
1063 .unwrap();
1064 assert_eq!(config.server_url(), Some("http://127.0.0.1:12345/"));
1065 assert!(config.server_binary().is_none());
1066 assert!(matches!(
1067 PocketIcStartupConfig::from_environment(timeout, |_| None),
1068 Err(PocketIcStartupError::NotConfigured)
1069 ));
1070 assert!(matches!(
1071 PocketIcStartupConfig::from_environment(timeout, |_| Some("".into())),
1072 Err(PocketIcStartupError::InvalidEnvironment {
1073 variable: "IC_TESTKIT_POCKET_IC_URL"
1074 })
1075 ));
1076 assert!(matches!(
1077 PocketIcStartupConfig::from_environment(timeout, |_| Some("bad URL".into())),
1078 Err(PocketIcStartupError::InvalidServerUrl { .. })
1079 ));
1080 assert!(matches!(
1081 PocketIcStartupConfig::from_environment(timeout, |_| Some(
1082 std::ffi::OsString::from_vec(vec![0xff])
1083 )),
1084 Err(PocketIcStartupError::InvalidEnvironment { .. })
1085 ));
1086 }
1087
1088 #[cfg(unix)]
1089 #[test]
1090 fn environment_binary_selection_uses_bounded_shared_version_capture() {
1091 for (label, body, expected) in [
1092 ("qualified", "printf 'pocket-ic-server 16.0.0\\n'", 0),
1093 ("wrong-version", "printf 'pocket-ic-server 15.0.0\\n'", 1),
1094 (
1095 "failed-version",
1096 "printf 'pocket-ic-server 16.0.0\\n'; exit 23",
1097 2,
1098 ),
1099 ("invalid-utf8", "printf '\\377'", 1),
1100 ("version-timeout", "exec sleep 30", 2),
1101 ] {
1102 let script = TestServerScript::new(
1103 label,
1104 &format!("#!/bin/sh\n[ \"$1\" = --version ] || exit 99\n{body}\n"),
1105 );
1106 let result =
1107 PocketIcStartupConfig::from_environment(Duration::from_millis(200), |name| {
1108 (name == "POCKET_IC_BIN").then(|| script.path().into_os_string())
1109 });
1110 match (expected, result) {
1111 (0, Ok(config)) => {
1112 let binary = script.path().canonicalize().unwrap();
1113 assert_eq!(config.server_binary(), Some(binary.as_path()));
1114 }
1115 (1, Err(PocketIcStartupError::ServerVersionMismatch { .. }))
1116 | (2, Err(PocketIcStartupError::ServerVersionProbe { .. })) => {}
1117 (_, result) => panic!("unexpected {label} result: {result:?}"),
1118 }
1119 }
1120 }
1121
1122 #[cfg(unix)]
1123 fn process_state(pid: u32) -> Option<char> {
1124 let output = Command::new("/bin/ps")
1127 .args(["-p", &pid.to_string(), "-o", "stat="])
1128 .output()
1129 .expect("inspect managed test process state");
1130 assert!(
1131 output.status.success()
1132 || (output.status.code() == Some(1)
1133 && output.stdout.is_empty()
1134 && output.stderr.is_empty()),
1135 "process-state inspection failed: {}: {}",
1136 output.status,
1137 String::from_utf8_lossy(&output.stderr),
1138 );
1139 String::from_utf8(output.stdout)
1140 .expect("process state is ASCII")
1141 .trim()
1142 .chars()
1143 .next()
1144 }
1145
1146 #[cfg(unix)]
1147 #[test]
1148 fn reading_large_sparse_server_output_is_bounded() {
1149 let (files, _, _) = StartupFiles::create().expect("allocate startup files");
1150 let mut file = fs::File::create(&files.stdout).expect("create sparse log");
1151 file.write_all(b"server started\n").expect("write prefix");
1152 let size = 8_u64 * 1024 * 1024 * 1024;
1153 file.set_len(size).expect("extend sparse log");
1154 let output = super::read_bounded_lossy(&files.stdout);
1155 assert!(output.starts_with("server started\n"));
1156 assert!(output.ends_with(&format!(
1157 "<truncated {} bytes>",
1158 size - super::SERVER_OUTPUT_LIMIT as u64
1159 )));
1160 assert!(output.len() < super::SERVER_OUTPUT_LIMIT + 100);
1161 }
1162
1163 #[cfg(unix)]
1164 #[test]
1165 fn startup_readers_reject_fifos_without_waiting_for_a_writer() {
1166 let (files, stdout, stderr) = StartupFiles::create().expect("allocate startup files");
1167 drop((stdout, stderr));
1168 fs::remove_file(&files.stdout).unwrap();
1169 fs::remove_file(&files.stderr).unwrap();
1170 assert!(
1171 Command::new("mkfifo")
1172 .args([&files.port, &files.stdout, &files.stderr])
1173 .status()
1174 .expect("create FIFO startup files")
1175 .success()
1176 );
1177 let server = super::ManagedServer {
1178 child: None,
1179 binary: PathBuf::from("unused-server"),
1180 files,
1181 started: Instant::now(),
1182 };
1183 for path in [
1184 &server.files.port,
1185 &server.files.stdout,
1186 &server.files.stderr,
1187 ] {
1188 let fifo = path.clone();
1192 let (stop_writer, stopped) = mpsc::channel();
1193 let writer = std::thread::spawn(move || {
1194 loop {
1195 match stopped.recv_timeout(Duration::from_millis(200)) {
1196 Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return false,
1197 Err(mpsc::RecvTimeoutError::Timeout) => {}
1198 }
1199 if fs::OpenOptions::new()
1200 .write(true)
1201 .custom_flags(libc::O_NONBLOCK)
1202 .open(&fifo)
1203 .is_ok()
1204 {
1205 return true;
1206 }
1207 }
1208 });
1209 let result = if path == &server.files.port {
1210 Some(server.read_port())
1211 } else {
1212 assert_eq!(super::read_bounded_lossy(path), "");
1213 None
1214 };
1215 let _ = stop_writer.send(());
1216 assert!(
1217 !writer.join().expect("join delayed FIFO writer"),
1218 "startup reader waited for a writer: {}",
1219 path.display(),
1220 );
1221 if let Some(result) = result {
1222 assert!(matches!(
1223 result,
1224 Err(PocketIcStartupError::Io { source, .. })
1225 if source.kind() == std::io::ErrorKind::InvalidData
1226 ));
1227 }
1228 }
1229 }
1230
1231 #[test]
1232 fn port_file_readiness_preserves_partial_writes_and_rejects_oversized_contents() {
1233 let (files, _, _) = StartupFiles::create().expect("allocate startup files");
1234 let server = super::ManagedServer {
1235 child: None,
1236 binary: PathBuf::from("unused-server"),
1237 files,
1238 started: Instant::now(),
1239 };
1240 assert!(matches!(
1241 server.read_port().unwrap(),
1242 super::PortFileState::Pending
1243 ));
1244 for contents in ["", "34567"] {
1245 fs::write(&server.files.port, contents).unwrap();
1246 assert!(matches!(
1247 server.read_port().unwrap(),
1248 super::PortFileState::Pending
1249 ));
1250 }
1251 for (contents, expected) in [("1\n", 1), ("65535\n", 65535), (" 34567\r\n", 34567)] {
1252 fs::write(&server.files.port, contents).unwrap();
1253 assert!(matches!(
1254 server.read_port().unwrap(),
1255 super::PortFileState::Ready(port) if port == expected
1256 ));
1257 }
1258 for contents in ["0\n", "65536\n", "invalid\n", "1\n2\n"] {
1259 fs::write(&server.files.port, contents).unwrap();
1260 assert!(matches!(
1261 server.read_port().unwrap(),
1262 super::PortFileState::Invalid(_)
1263 ));
1264 }
1265 fs::write(&server.files.port, [0xff, b'\n']).unwrap();
1266 assert!(matches!(
1267 server.read_port(),
1268 Err(PocketIcStartupError::Io { source, .. })
1269 if source.kind() == std::io::ErrorKind::InvalidData
1270 ));
1271 for contents in ["1\n".to_owned() + &" ".repeat(128), "0".repeat(128)] {
1272 fs::write(&server.files.port, contents).unwrap();
1273 assert!(
1274 matches!(
1275 server.read_port().unwrap(),
1276 super::PortFileState::Invalid(_)
1277 ),
1278 "oversized port contents must fail even without a newline",
1279 );
1280 }
1281 fs::File::options()
1283 .write(true)
1284 .open(&server.files.port)
1285 .unwrap()
1286 .set_len(1024 * 1024)
1287 .unwrap();
1288 assert!(matches!(
1289 server.read_port().unwrap(),
1290 super::PortFileState::Invalid(value) if value.len() < 256
1291 ));
1292 }
1293
1294 #[test]
1295 fn startup_config_requires_positive_bounds() {
1296 let error = PocketIcStartupConfig::connect("http://127.0.0.1:1/", Duration::ZERO)
1297 .validate()
1298 .expect_err("zero startup timeout must fail");
1299 assert!(matches!(
1300 error,
1301 PocketIcStartupError::InvalidConfiguration { .. }
1302 ));
1303
1304 let error = PocketIcStartupConfig::spawn("pocket-ic", Duration::from_secs(1))
1305 .with_server_hard_ttl(Duration::from_millis(1))
1306 .validate()
1307 .expect_err("subsecond server hard TTL must fail");
1308 assert!(matches!(
1309 error,
1310 PocketIcStartupError::InvalidConfiguration { .. }
1311 ));
1312 }
1313
1314 #[test]
1315 fn managed_server_hard_ttl_is_opt_in() {
1316 let default = PocketIcStartupConfig::spawn("pocket-ic", Duration::from_secs(1));
1317 assert_eq!(default.server_hard_ttl(), None);
1318
1319 let explicit = default.with_server_hard_ttl(Duration::from_secs(17));
1320 assert_eq!(explicit.server_hard_ttl(), Some(Duration::from_secs(17)));
1321 }
1322
1323 #[test]
1324 fn startup_files_leave_the_server_owned_port_path_absent() {
1325 let (files, stdout, stderr) = StartupFiles::create().expect("allocate startup files");
1326 let directory = files.directory.clone();
1327
1328 assert!(directory.is_dir());
1329 assert!(!files.port.exists());
1330 assert!(files.stdout.is_file());
1331 assert!(files.stderr.is_file());
1332 #[cfg(unix)]
1333 {
1334 let mode = fs::metadata(&directory)
1335 .expect("inspect private startup directory")
1336 .permissions()
1337 .mode();
1338 assert_eq!(mode & 0o077, 0);
1339 }
1340
1341 drop(stdout);
1342 drop(stderr);
1343 drop(files);
1344 assert!(!directory.exists());
1345 }
1346
1347 #[cfg(unix)]
1348 #[test]
1349 fn managed_startup_reports_an_exited_server_with_bounded_output() {
1350 let script = TestServerScript::new(
1351 "exit",
1352 "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nprintf 'synthetic server stdout'\nprintf 'synthetic bind failure' >&2\nexit 23\n",
1353 );
1354
1355 let result = PocketIcBuilder::new().with_application_subnet().try_build(
1356 PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2)),
1357 );
1358
1359 let Err(PocketIcStartupError::ServerExited {
1360 server_binary,
1361 status,
1362 stdout,
1363 stderr,
1364 ..
1365 }) = result
1366 else {
1367 panic!(
1368 "an exited managed server must return a structured exit error; got {:?}",
1369 result.err(),
1370 );
1371 };
1372 assert_eq!(server_binary, script.path());
1373 assert_eq!(status.code(), Some(23));
1374 assert_eq!(stdout, "synthetic server stdout");
1375 assert_eq!(stderr, "synthetic bind failure");
1376 }
1377
1378 #[cfg(unix)]
1379 #[test]
1380 fn managed_startup_rejects_oversized_port_files_and_cleans_up() {
1381 let script = TestServerScript::new(
1382 "oversized-port",
1383 "#!/bin/sh\nprintf '%s\\n%s\\n' \"$$\" \"$2\"\nprintf '34567\\n%064s' '' > \"$2.pending\"\nmv \"$2.pending\" \"$2\"\nexec sleep 30\n",
1384 );
1385 let result = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
1386 .start_managed_server();
1387 let Err(PocketIcStartupError::InvalidServerPort { value, stdout, .. }) = result else {
1388 panic!("oversized port publication must fail readiness");
1389 };
1390 assert!(value.contains("port file exceeds"));
1391 assert!(value.len() < 256);
1392 let mut lines = stdout.lines();
1393 let pid = lines.next().unwrap().parse::<u32>().unwrap();
1394 let port_path = PathBuf::from(lines.next().unwrap());
1395 assert!(!port_path.parent().unwrap().exists());
1396 assert_eq!(process_state(pid), None, "failed server must be reaped");
1397 }
1398
1399 #[cfg(unix)]
1400 #[test]
1401 fn managed_startup_terminates_a_server_that_never_becomes_ready() {
1402 let script = TestServerScript::new(
1403 "timeout",
1404 "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nexec sleep 30\n",
1405 );
1406 let timeout = Duration::from_millis(100);
1407 let started = Instant::now();
1408
1409 let result = PocketIcBuilder::new()
1410 .with_application_subnet()
1411 .try_build(PocketIcStartupConfig::spawn(script.path(), timeout));
1412
1413 assert!(
1414 started.elapsed() < Duration::from_secs(2),
1415 "bounded startup should not wait for the sleeping child"
1416 );
1417 assert!(matches!(
1418 result,
1419 Err(PocketIcStartupError::ReadinessTimeout {
1420 server_binary,
1421 timeout: actual_timeout,
1422 termination_error: None,
1423 ..
1424 }) if server_binary == script.path() && actual_timeout == timeout
1425 ));
1426 }
1427
1428 #[cfg(unix)]
1429 #[test]
1430 fn managed_server_handle_exposes_process_id_url_output_and_raii_ownership() {
1431 let script = TestServerScript::new(
1432 "handle",
1433 "#!/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",
1434 );
1435
1436 let server = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
1437 .start_managed_server()
1438 .expect("start caller-owned managed server");
1439
1440 assert_eq!(server.url(), "http://127.0.0.1:34567/");
1441 assert_eq!(
1442 server.output().stdout(),
1443 format!("managed server ready: {}", server.process_id())
1444 );
1445 assert_eq!(server.output().stderr(), "");
1446 let pid = server.process_id();
1447 assert!(process_state(pid).is_some_and(|state| state != 'Z'));
1448 drop(server);
1449 assert_eq!(process_state(pid), None, "owned server must be reaped");
1450 }
1451
1452 #[cfg(unix)]
1453 #[test]
1454 fn managed_server_cleans_descendants_on_drop_timeout_exit_and_background_reap() {
1455 for mode in ["drop", "timeout", "exit", "background"] {
1456 let publish = if matches!(mode, "drop" | "background") {
1457 "printf '34567\\n' > \"$2\"\n"
1458 } else {
1459 ""
1460 };
1461 let finish = if mode == "background" {
1462 "while [ -e \"$2\" ]; do sleep 0.01; done\nexit 23\n"
1465 } else if mode == "exit" {
1466 "sleep 0.03\nexit 23\n"
1467 } else {
1468 "exec sleep 30\n"
1469 };
1470 let script = TestServerScript::new(
1471 mode,
1472 &format!("#!/bin/sh\nsleep 30 &\nprintf '%s' \"$!\"\n{publish}{finish}"),
1473 );
1474 let result = PocketIcStartupConfig::spawn(script.path(), Duration::from_millis(300))
1475 .start_managed_server();
1476 let output = match result {
1477 Ok(server) => {
1478 let output = server.output().stdout().to_owned();
1479 if mode == "background" {
1480 let port = server.server.files.port.clone();
1481 server.server.reap_in_background();
1482 fs::remove_file(port).expect("release the background server after handoff");
1483 } else {
1484 drop(server);
1485 }
1486 output
1487 }
1488 Err(PocketIcStartupError::ReadinessTimeout {
1489 stdout,
1490 termination_error,
1491 ..
1492 }) => {
1493 assert_eq!(mode, "timeout");
1494 assert_eq!(termination_error, None);
1495 stdout
1496 }
1497 Err(PocketIcStartupError::ServerExited { stdout, status, .. }) => {
1498 assert_eq!(mode, "exit");
1499 assert_eq!(status.code(), Some(23));
1500 stdout
1501 }
1502 other => panic!(
1503 "unexpected {mode} startup result: {}",
1504 match other {
1505 Err(error) => error.to_string(),
1506 Ok(_) => unreachable!(),
1507 }
1508 ),
1509 };
1510 let pid = output
1511 .parse::<u32>()
1512 .expect("server published its descendant PID");
1513 let deadline = Instant::now() + Duration::from_secs(2);
1514 while process_state(pid).is_some_and(|state| state != 'Z') {
1515 assert!(
1516 Instant::now() < deadline,
1517 "{mode} left its descendant running"
1518 );
1519 std::thread::sleep(Duration::from_millis(10));
1520 }
1521 }
1522 }
1523
1524 #[cfg(unix)]
1525 #[test]
1526 fn managed_server_passes_an_explicit_hard_ttl() {
1527 let script = TestServerScript::new(
1528 "hard-ttl",
1529 "#!/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",
1530 );
1531
1532 let server = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
1533 .with_server_hard_ttl(Duration::from_secs(17))
1534 .start_managed_server()
1535 .expect("start managed server with an explicit hard TTL");
1536
1537 assert_eq!(server.url(), "http://127.0.0.1:34567/");
1538 }
1539
1540 #[test]
1541 #[ignore = "requires POCKET_IC_BIN=<caller-provided PocketIC server binary>"]
1542 fn caller_provided_server_publishes_port_constructs_instance_and_cleans_up() {
1543 let binary = std::env::var_os("POCKET_IC_BIN")
1544 .map(PathBuf::from)
1545 .expect("set POCKET_IC_BIN to the exact server binary");
1546 let one_shot_sequence = super::STARTUP_FILE_SEQUENCE.load(super::Ordering::Relaxed);
1547 let one_shot_directory = std::env::temp_dir().join(format!(
1548 "ic-testkit-pocket-ic-startup-{}-{one_shot_sequence}",
1549 std::process::id()
1550 ));
1551 let one_shot = PocketIcBuilder::new()
1552 .with_application_subnet()
1553 .try_build(
1554 PocketIcStartupConfig::spawn(&binary, Duration::from_secs(30))
1555 .with_server_hard_ttl(Duration::from_secs(1)),
1556 )
1557 .expect("one-shot managed spawn must construct an instance");
1558 assert!(one_shot_directory.is_dir());
1559 drop(one_shot);
1560 let cleanup_deadline = Instant::now() + Duration::from_secs(3);
1561 while one_shot_directory.exists() && Instant::now() < cleanup_deadline {
1562 std::thread::sleep(Duration::from_millis(20));
1563 }
1564 assert!(!one_shot_directory.exists());
1565
1566 let server = PocketIcStartupConfig::spawn(&binary, Duration::from_secs(30))
1567 .with_server_hard_ttl(Duration::from_secs(60))
1568 .start_managed_server()
1569 .expect("caller-provided PocketIC server must publish its port");
1570 let files = &server.server.files;
1571 let startup_directory = files.directory.clone();
1572
1573 assert!(files.port.is_file());
1574 let pocket_ic = PocketIcBuilder::new()
1575 .with_application_subnet()
1576 .try_build(PocketIcStartupConfig::connect(
1577 server.url(),
1578 Duration::from_secs(30),
1579 ))
1580 .expect("construct instance through caller-provided server");
1581
1582 drop(pocket_ic);
1583 drop(server);
1584 assert!(!startup_directory.exists());
1585 }
1586
1587 #[cfg(unix)]
1588 struct TestServerScript {
1589 path: PathBuf,
1590 }
1591
1592 #[cfg(unix)]
1593 impl TestServerScript {
1594 fn new(label: &str, contents: &str) -> Self {
1595 let path = std::env::temp_dir().join(format!(
1596 "ic-testkit-pocket-ic-{label}-{}-{}",
1597 std::process::id(),
1598 super::STARTUP_FILE_SEQUENCE.fetch_add(1, super::Ordering::Relaxed),
1599 ));
1600 write_executable_script(&path, contents);
1601 Self { path }
1602 }
1603
1604 fn path(&self) -> PathBuf {
1605 self.path.clone()
1606 }
1607 }
1608
1609 #[cfg(unix)]
1610 impl Drop for TestServerScript {
1611 fn drop(&mut self) {
1612 let _ = fs::remove_file(&self.path);
1613 }
1614 }
1615}