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