1use std::{
2 fmt::Write as _,
3 fs::{self, File, OpenOptions},
4 io::{self, Read as _},
5 path::{Path, PathBuf},
6 process::{Child, Command, ExitStatus, Stdio},
7 sync::{
8 atomic::{AtomicU64, Ordering},
9 mpsc::{self, RecvTimeoutError},
10 },
11 thread,
12 time::{Duration, Instant},
13};
14
15use pocket_ic::{PocketIc, PocketIcBuilder};
16
17use super::transport;
18
19const STARTUP_POLL_INTERVAL: Duration = Duration::from_millis(20);
20const SERVER_OUTPUT_LIMIT: usize = 16 * 1024;
21
22static STARTUP_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
23
24#[derive(Clone, Debug, Eq, PartialEq)]
26pub struct PocketIcStartupConfig {
27 source: PocketIcStartupSource,
28 timeout: Duration,
29 server_hard_ttl: Option<Duration>,
30}
31
32pub struct PocketIcManagedServer {
43 server: ManagedServer,
44 url: String,
45}
46
47#[derive(Clone, Debug, Default, Eq, PartialEq)]
52pub struct PocketIcManagedServerOutput {
53 stdout: String,
54 stderr: String,
55}
56
57#[derive(Clone, Debug, Eq, PartialEq)]
58enum PocketIcStartupSource {
59 Spawn { server_binary: PathBuf },
60 Connect { server_url: String },
61}
62
63#[non_exhaustive]
65#[derive(Debug)]
66pub enum PocketIcStartupError {
67 InvalidConfiguration { message: String },
69 InvalidServerUrl { server_url: String, message: String },
71 Io {
73 operation: &'static str,
74 path: PathBuf,
75 source: io::Error,
76 },
77 ServerSpawn {
79 server_binary: PathBuf,
80 source: io::Error,
81 },
82 ServerExited {
84 server_binary: PathBuf,
85 status: ExitStatus,
86 elapsed: Duration,
87 stdout: String,
88 stderr: String,
89 },
90 ReadinessTimeout {
92 server_binary: PathBuf,
93 timeout: Duration,
94 stdout: String,
95 stderr: String,
96 termination_error: Option<String>,
97 },
98 InvalidServerPort {
100 server_binary: PathBuf,
101 value: String,
102 stdout: String,
103 stderr: String,
104 },
105 InstanceCreationTimeout {
107 timeout: Duration,
108 stdout: String,
109 stderr: String,
110 termination_error: Option<String>,
111 },
112 BuilderThreadSpawn { source: io::Error },
114 BuilderPanicked { message: String },
116 BuilderDisconnected,
118}
119
120pub trait PocketIcBuilderExt {
126 fn try_build(self, config: PocketIcStartupConfig) -> Result<PocketIc, PocketIcStartupError>;
136}
137
138impl PocketIcStartupConfig {
139 #[must_use]
144 pub fn spawn(server_binary: impl Into<PathBuf>, timeout: Duration) -> Self {
145 Self {
146 source: PocketIcStartupSource::Spawn {
147 server_binary: server_binary.into(),
148 },
149 timeout,
150 server_hard_ttl: None,
151 }
152 }
153
154 #[must_use]
159 pub fn connect(server_url: impl Into<String>, timeout: Duration) -> Self {
160 Self {
161 source: PocketIcStartupSource::Connect {
162 server_url: server_url.into(),
163 },
164 timeout,
165 server_hard_ttl: None,
166 }
167 }
168
169 #[must_use]
171 pub const fn with_server_hard_ttl(mut self, hard_ttl: Duration) -> Self {
172 self.server_hard_ttl = Some(hard_ttl);
173 self
174 }
175
176 #[must_use]
178 pub const fn timeout(&self) -> Duration {
179 self.timeout
180 }
181
182 #[must_use]
184 pub const fn server_hard_ttl(&self) -> Option<Duration> {
185 self.server_hard_ttl
186 }
187
188 #[must_use]
190 pub fn server_binary(&self) -> Option<&Path> {
191 match &self.source {
192 PocketIcStartupSource::Spawn { server_binary } => Some(server_binary),
193 PocketIcStartupSource::Connect { .. } => None,
194 }
195 }
196
197 #[must_use]
199 pub fn server_url(&self) -> Option<&str> {
200 match &self.source {
201 PocketIcStartupSource::Connect { server_url } => Some(server_url),
202 PocketIcStartupSource::Spawn { .. } => None,
203 }
204 }
205
206 pub fn start_managed_server(self) -> Result<PocketIcManagedServer, PocketIcStartupError> {
214 self.validate()?;
215 let PocketIcStartupSource::Spawn { server_binary } = self.source else {
216 return Err(PocketIcStartupError::InvalidConfiguration {
217 message: "starting a managed PocketIC server requires a spawn configuration"
218 .to_owned(),
219 });
220 };
221 let started = Instant::now();
222 let deadline = startup_deadline(started, self.timeout)?;
223 let (server, url) = ManagedServer::start(
224 server_binary,
225 self.server_hard_ttl,
226 deadline,
227 self.timeout,
228 started,
229 )?;
230 Ok(PocketIcManagedServer { server, url })
231 }
232
233 fn validate(&self) -> Result<(), PocketIcStartupError> {
234 if self.timeout.is_zero() {
235 return Err(PocketIcStartupError::InvalidConfiguration {
236 message: "PocketIC startup timeout must be greater than zero".to_owned(),
237 });
238 }
239 if matches!(&self.source, PocketIcStartupSource::Spawn { .. })
240 && self
241 .server_hard_ttl
242 .is_some_and(|hard_ttl| hard_ttl.as_secs() == 0)
243 {
244 return Err(PocketIcStartupError::InvalidConfiguration {
245 message: "PocketIC server hard TTL must be at least one second".to_owned(),
246 });
247 }
248 Ok(())
249 }
250}
251
252impl PocketIcManagedServer {
253 #[must_use]
260 pub fn process_id(&self) -> u32 {
261 self.server
262 .child
263 .as_ref()
264 .expect("managed server handle must own its child")
265 .id()
266 }
267
268 #[must_use]
270 pub fn url(&self) -> &str {
271 &self.url
272 }
273
274 #[must_use]
280 pub fn output(&self) -> PocketIcManagedServerOutput {
281 self.server.capture().into()
282 }
283}
284
285impl PocketIcManagedServerOutput {
286 #[must_use]
288 pub fn stdout(&self) -> &str {
289 &self.stdout
290 }
291
292 #[must_use]
294 pub fn stderr(&self) -> &str {
295 &self.stderr
296 }
297}
298
299impl PocketIcBuilderExt for PocketIcBuilder {
300 fn try_build(self, config: PocketIcStartupConfig) -> Result<PocketIc, PocketIcStartupError> {
301 config.validate()?;
302 let started = Instant::now();
303 let deadline = startup_deadline(started, config.timeout)?;
304 match config.source {
305 PocketIcStartupSource::Connect { server_url } => {
306 build_bounded(self, &server_url, deadline, config.timeout, None)
307 }
308 PocketIcStartupSource::Spawn { server_binary } => {
309 let (server, server_url) = ManagedServer::start(
310 server_binary,
311 config.server_hard_ttl,
312 deadline,
313 config.timeout,
314 started,
315 )?;
316 build_bounded(self, &server_url, deadline, config.timeout, Some(server))
317 }
318 }
319 }
320}
321
322fn startup_deadline(started: Instant, timeout: Duration) -> Result<Instant, PocketIcStartupError> {
323 started
324 .checked_add(timeout)
325 .ok_or_else(|| PocketIcStartupError::InvalidConfiguration {
326 message: "PocketIC startup timeout exceeds the platform clock range".to_owned(),
327 })
328}
329
330fn build_bounded(
331 builder: PocketIcBuilder,
332 server_url: &str,
333 deadline: Instant,
334 timeout: Duration,
335 mut server: Option<ManagedServer>,
336) -> Result<PocketIc, PocketIcStartupError> {
337 let builder = match server_url.parse() {
338 Ok(server_url) => builder.with_server_url(server_url),
339 Err(error) => {
340 return Err(PocketIcStartupError::InvalidServerUrl {
341 server_url: server_url.to_owned(),
342 message: error.to_string(),
343 });
344 }
345 };
346 let (sender, receiver) = mpsc::sync_channel(1);
347 if let Err(source) = thread::Builder::new()
348 .name("ic-testkit-pocket-ic-startup".to_owned())
349 .spawn(move || {
350 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| builder.build()))
351 .map_err(|payload| transport::panic_payload_to_string(payload.as_ref()));
352 let _ = sender.send(result);
353 })
354 {
355 return Err(PocketIcStartupError::BuilderThreadSpawn { source });
356 }
357
358 loop {
359 let now = Instant::now();
360 if now >= deadline {
361 let captured = server.take().map_or_else(
362 CapturedServer::default,
363 ManagedServer::terminate_and_capture,
364 );
365 return Err(PocketIcStartupError::InstanceCreationTimeout {
366 timeout,
367 stdout: captured.stdout,
368 stderr: captured.stderr,
369 termination_error: captured.termination_error,
370 });
371 }
372 let remaining = deadline.saturating_duration_since(now);
373 let wait = if server.is_some() {
374 remaining.min(STARTUP_POLL_INTERVAL)
375 } else {
376 remaining
377 };
378 match receiver.recv_timeout(wait) {
379 Ok(Ok(pocket_ic)) => {
380 if let Some(mut managed) = server.take() {
381 if let Some(status) = managed.try_wait()? {
382 return Err(managed.exited_error(status));
383 }
384 managed.reap_in_background();
385 }
386 return Ok(pocket_ic);
387 }
388 Ok(Err(message)) => {
389 if let Some(server) = server.take() {
390 let _ = server.terminate_and_capture();
391 }
392 return Err(PocketIcStartupError::BuilderPanicked { message });
393 }
394 Err(RecvTimeoutError::Disconnected) => {
395 if let Some(server) = server.take() {
396 let _ = server.terminate_and_capture();
397 }
398 return Err(PocketIcStartupError::BuilderDisconnected);
399 }
400 Err(RecvTimeoutError::Timeout) => {
401 if let Some(managed) = &mut server
402 && let Some(status) = managed.try_wait()?
403 {
404 return Err(server
405 .take()
406 .expect("managed server must remain present")
407 .exited_error(status));
408 }
409 }
410 }
411 }
412}
413
414struct ManagedServer {
415 child: Option<Child>,
416 binary: PathBuf,
417 files: StartupFiles,
418 started: Instant,
419}
420
421enum PortFileState {
422 Pending,
423 Ready(u16),
424 Invalid(String),
425}
426
427impl ManagedServer {
428 fn start(
429 binary: PathBuf,
430 hard_ttl: Option<Duration>,
431 deadline: Instant,
432 timeout: Duration,
433 started: Instant,
434 ) -> Result<(Self, String), PocketIcStartupError> {
435 let (files, stdout, stderr) = StartupFiles::create()?;
436 let mut command = Command::new(&binary);
437 if let Some(hard_ttl) = hard_ttl {
438 command
439 .arg("--hard-ttl")
440 .arg(hard_ttl.as_secs().to_string());
441 }
442 command
443 .arg("--port-file")
444 .arg(&files.port)
445 .stdout(Stdio::from(stdout))
446 .stderr(Stdio::from(stderr));
447 #[cfg(unix)]
448 {
449 use std::os::unix::process::CommandExt as _;
450 command.process_group(0);
451 }
452 let child = command
453 .spawn()
454 .map_err(|source| PocketIcStartupError::ServerSpawn {
455 server_binary: binary.clone(),
456 source,
457 })?;
458 let mut server = Self {
459 child: Some(child),
460 binary,
461 files,
462 started,
463 };
464
465 loop {
466 if let Some(status) = server.try_wait()? {
467 return Err(server.exited_error(status));
468 }
469 let now = Instant::now();
470 if now >= deadline {
471 let binary = server.binary.clone();
472 let captured = server.terminate_and_capture();
473 return Err(PocketIcStartupError::ReadinessTimeout {
474 server_binary: binary,
475 timeout,
476 stdout: captured.stdout,
477 stderr: captured.stderr,
478 termination_error: captured.termination_error,
479 });
480 }
481 match server.read_port()? {
482 PortFileState::Pending => {}
483 PortFileState::Ready(port) => {
484 return Ok((server, format!("http://127.0.0.1:{port}/")));
485 }
486 PortFileState::Invalid(value) => {
487 let binary = server.binary.clone();
488 let captured = server.terminate_and_capture();
489 return Err(PocketIcStartupError::InvalidServerPort {
490 server_binary: binary,
491 value,
492 stdout: captured.stdout,
493 stderr: captured.stderr,
494 });
495 }
496 }
497 thread::sleep(
498 deadline
499 .saturating_duration_since(now)
500 .min(STARTUP_POLL_INTERVAL),
501 );
502 }
503 }
504
505 fn try_wait(&mut self) -> Result<Option<ExitStatus>, PocketIcStartupError> {
506 let child = self
507 .child
508 .as_mut()
509 .expect("managed server child must remain present");
510 poll_child(child).map_err(|source| PocketIcStartupError::Io {
511 operation: "inspect PocketIC server child",
512 path: self.binary.clone(),
513 source,
514 })
515 }
516
517 fn read_port(&self) -> Result<PortFileState, PocketIcStartupError> {
518 let port_path = &self.files.port;
519 let contents = match fs::read_to_string(port_path) {
520 Ok(contents) => contents,
521 Err(error) if error.kind() == io::ErrorKind::NotFound => {
522 return Ok(PortFileState::Pending);
523 }
524 Err(source) => {
525 return Err(PocketIcStartupError::Io {
526 operation: "read PocketIC server port file",
527 path: port_path.clone(),
528 source,
529 });
530 }
531 };
532 if !contents.contains('\n') {
533 return Ok(PortFileState::Pending);
534 }
535 let value = contents.trim().to_owned();
536 match value.parse::<u16>() {
537 Ok(port) if port != 0 => Ok(PortFileState::Ready(port)),
538 _ => Ok(PortFileState::Invalid(value)),
539 }
540 }
541
542 fn exited_error(mut self, status: ExitStatus) -> PocketIcStartupError {
543 let elapsed = self.started.elapsed();
544 let binary = self.binary.clone();
545 self.child.take();
546 let captured = self.capture();
547 PocketIcStartupError::ServerExited {
548 server_binary: binary,
549 status,
550 elapsed,
551 stdout: captured.stdout,
552 stderr: captured.stderr,
553 }
554 }
555
556 fn terminate_and_capture(mut self) -> CapturedServer {
557 let termination_error = match self.child.take() {
558 Some(mut child) => terminate_child(&mut child),
559 None => None,
560 };
561 let mut captured = self.capture();
562 captured.termination_error = termination_error;
563 captured
564 }
565
566 fn capture(&self) -> CapturedServer {
567 let files = &self.files;
568 CapturedServer {
569 stdout: read_bounded_lossy(&files.stdout),
570 stderr: read_bounded_lossy(&files.stderr),
571 termination_error: None,
572 }
573 }
574
575 fn reap_in_background(self) {
576 let _ = thread::Builder::new()
577 .name("ic-testkit-pocket-ic-server-reaper".to_owned())
578 .spawn(move || {
579 let mut server = self;
582 if let Some(child) = server.child.as_mut() {
583 #[cfg(unix)]
584 let _ = wait_for_child_exit(child, false);
585 #[cfg(not(unix))]
586 let _ = child.wait();
587 }
588 });
589 }
590}
591
592impl Drop for ManagedServer {
593 fn drop(&mut self) {
594 if let Some(mut child) = self.child.take() {
595 let _ = terminate_child(&mut child);
596 }
597 }
598}
599
600#[cfg(unix)]
601fn wait_for_child_exit(child: &Child, nonblocking: bool) -> io::Result<bool> {
602 let flags = libc::WEXITED | libc::WNOWAIT | if nonblocking { libc::WNOHANG } else { 0 };
604 loop {
605 let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
607 let result = unsafe { libc::waitid(libc::P_PID, child.id(), &raw mut info, flags) };
609 if result == 0 {
610 return Ok(info.si_signo != 0);
611 }
612 let error = io::Error::last_os_error();
613 if error.kind() != io::ErrorKind::Interrupted {
614 return Err(error);
615 }
616 }
617}
618
619fn poll_child(child: &mut Child) -> io::Result<Option<ExitStatus>> {
620 #[cfg(unix)]
621 {
622 if !wait_for_child_exit(child, true)? {
623 return Ok(None);
624 }
625 terminate_process_group(child)?;
627 child.wait().map(Some)
628 }
629 #[cfg(not(unix))]
630 child.try_wait()
631}
632
633#[cfg(unix)]
634fn terminate_process_group(child: &Child) -> io::Result<()> {
635 let group = libc::pid_t::try_from(child.id())
636 .map_err(|_| io::Error::other("managed child PID exceeds the OS process ID range"))?;
637 loop {
638 let result = unsafe { libc::killpg(group, libc::SIGKILL) };
641 if result == 0 {
642 return Ok(());
643 }
644 let error = io::Error::last_os_error();
645 if error.raw_os_error() == Some(libc::ESRCH) {
646 return Ok(());
647 }
648 if error.kind() != io::ErrorKind::Interrupted {
649 return Err(error);
650 }
651 }
652}
653
654#[cfg(unix)]
655fn terminate_child(child: &mut Child) -> Option<String> {
656 let termination = terminate_process_group(child);
657 if termination.is_err() {
658 let _ = child.kill();
659 }
660 let wait = child.wait();
661 termination
662 .and_then(|()| wait.map(|_| ()))
663 .err()
664 .map(|error| error.to_string())
665}
666
667#[cfg(not(unix))]
668fn terminate_child(child: &mut Child) -> Option<String> {
669 match child.try_wait() {
670 Ok(Some(_)) => None,
671 Ok(None) => child
672 .kill()
673 .and_then(|()| child.wait().map(|_| ()))
674 .err()
675 .map(|error| error.to_string()),
676 Err(inspect_error) => {
677 let termination_error = child.kill().and_then(|()| child.wait().map(|_| ())).err();
678 termination_error.map(|termination_error| {
679 format!(
680 "failed to inspect child before termination: {inspect_error}; termination also failed: {termination_error}"
681 )
682 })
683 }
684 }
685}
686
687#[derive(Default)]
688struct CapturedServer {
689 stdout: String,
690 stderr: String,
691 termination_error: Option<String>,
692}
693
694impl From<CapturedServer> for PocketIcManagedServerOutput {
695 fn from(captured: CapturedServer) -> Self {
696 Self {
697 stdout: captured.stdout,
698 stderr: captured.stderr,
699 }
700 }
701}
702
703struct StartupFiles {
704 directory: PathBuf,
705 port: PathBuf,
706 stdout: PathBuf,
707 stderr: PathBuf,
708}
709
710impl StartupFiles {
711 fn create() -> Result<(Self, File, File), PocketIcStartupError> {
712 loop {
713 let sequence = STARTUP_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
714 let base = std::env::temp_dir().join(format!(
715 "ic-testkit-pocket-ic-startup-{}-{sequence}",
716 std::process::id()
717 ));
718 let mut directory = fs::DirBuilder::new();
719 #[cfg(unix)]
720 {
721 use std::os::unix::fs::DirBuilderExt as _;
722 directory.mode(0o700);
723 }
724 match directory.create(&base) {
725 Ok(()) => {}
726 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
727 Err(source) => return Err(startup_file_error("create", &base, source)),
728 }
729 let files = Self {
730 port: base.join("port"),
731 stdout: base.join("stdout"),
732 stderr: base.join("stderr"),
733 directory: base,
734 };
735 let stdout = create_new_file(&files.stdout)
736 .map_err(|source| startup_file_error("create", &files.stdout, source))?;
737 let stderr = create_new_file(&files.stderr)
738 .map_err(|source| startup_file_error("create", &files.stderr, source))?;
739 return Ok((files, stdout, stderr));
740 }
741 }
742}
743
744impl Drop for StartupFiles {
745 fn drop(&mut self) {
746 let _ = fs::remove_dir_all(&self.directory);
747 }
748}
749
750fn create_new_file(path: &Path) -> io::Result<File> {
751 OpenOptions::new().write(true).create_new(true).open(path)
752}
753
754fn startup_file_error(
755 operation: &'static str,
756 path: &Path,
757 source: io::Error,
758) -> PocketIcStartupError {
759 PocketIcStartupError::Io {
760 operation,
761 path: path.to_owned(),
762 source,
763 }
764}
765
766fn read_bounded_lossy(path: &Path) -> String {
767 let Ok(file) = File::open(path) else {
768 return String::new();
769 };
770 let length = file.metadata().map_or(0, |metadata| metadata.len());
771 let mut bytes = Vec::with_capacity(SERVER_OUTPUT_LIMIT);
772 if file
773 .take(SERVER_OUTPUT_LIMIT as u64)
774 .read_to_end(&mut bytes)
775 .is_err()
776 {
777 return String::new();
778 }
779 let mut output = String::from_utf8_lossy(&bytes).into_owned();
780 let omitted = length.saturating_sub(bytes.len() as u64);
781 if omitted > 0 {
782 let _ = write!(output, "\n<truncated {omitted} bytes>");
783 }
784 output
785}
786
787impl std::fmt::Display for PocketIcStartupError {
788 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
789 match self {
790 Self::InvalidConfiguration { message } => formatter.write_str(message),
791 Self::InvalidServerUrl {
792 server_url,
793 message,
794 } => write!(
795 formatter,
796 "invalid PocketIC server URL {server_url:?}: {message}"
797 ),
798 Self::Io {
799 operation,
800 path,
801 source,
802 } => write!(
803 formatter,
804 "failed to {operation} at {}: {source}",
805 path.display()
806 ),
807 Self::ServerSpawn {
808 server_binary,
809 source,
810 } => write!(
811 formatter,
812 "failed to spawn PocketIC server {}: {source}",
813 server_binary.display()
814 ),
815 Self::ServerExited {
816 server_binary,
817 status,
818 elapsed,
819 stderr,
820 ..
821 } => write!(
822 formatter,
823 "PocketIC server {} exited with {status} after {elapsed:?}: {stderr}",
824 server_binary.display()
825 ),
826 Self::ReadinessTimeout {
827 server_binary,
828 timeout,
829 ..
830 } => write!(
831 formatter,
832 "PocketIC server {} was not ready within {timeout:?}",
833 server_binary.display()
834 ),
835 Self::InvalidServerPort {
836 server_binary,
837 value,
838 ..
839 } => write!(
840 formatter,
841 "PocketIC server {} published invalid port {value:?}",
842 server_binary.display()
843 ),
844 Self::InstanceCreationTimeout { timeout, .. } => {
845 write!(formatter, "PocketIC instance creation exceeded {timeout:?}")
846 }
847 Self::BuilderThreadSpawn { source } => {
848 write!(
849 formatter,
850 "failed to spawn PocketIC builder worker: {source}"
851 )
852 }
853 Self::BuilderPanicked { message } => {
854 write!(formatter, "PocketIC startup panicked: {message}")
855 }
856 Self::BuilderDisconnected => {
857 formatter.write_str("PocketIC builder worker disconnected without a result")
858 }
859 }
860 }
861}
862
863impl std::error::Error for PocketIcStartupError {
864 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
865 match self {
866 Self::Io { source, .. }
867 | Self::ServerSpawn { source, .. }
868 | Self::BuilderThreadSpawn { source } => Some(source),
869 _ => None,
870 }
871 }
872}
873
874#[cfg(test)]
875mod tests {
876 use std::{
877 fs,
878 path::PathBuf,
879 time::{Duration, Instant},
880 };
881
882 use super::{
883 PocketIcBuilderExt as _, PocketIcStartupConfig, PocketIcStartupError, StartupFiles,
884 };
885 use pocket_ic::PocketIcBuilder;
886
887 #[cfg(unix)]
888 use std::os::unix::fs::PermissionsExt as _;
889
890 #[cfg(unix)]
891 #[test]
892 fn reading_large_sparse_server_output_is_bounded() {
893 use std::io::Write as _;
894 let (files, _, _) = StartupFiles::create().expect("allocate startup files");
895 let mut file = fs::File::create(&files.stdout).expect("create sparse log");
896 file.write_all(b"server started\n").expect("write prefix");
897 let size = 8_u64 * 1024 * 1024 * 1024;
898 file.set_len(size).expect("extend sparse log");
899 let output = super::read_bounded_lossy(&files.stdout);
900 assert!(output.starts_with("server started\n"));
901 assert!(output.ends_with(&format!(
902 "<truncated {} bytes>",
903 size - super::SERVER_OUTPUT_LIMIT as u64
904 )));
905 assert!(output.len() < super::SERVER_OUTPUT_LIMIT + 100);
906 }
907
908 #[test]
909 fn startup_config_requires_positive_bounds() {
910 let error = PocketIcStartupConfig::connect("http://127.0.0.1:1/", Duration::ZERO)
911 .validate()
912 .expect_err("zero startup timeout must fail");
913 assert!(matches!(
914 error,
915 PocketIcStartupError::InvalidConfiguration { .. }
916 ));
917
918 let error = PocketIcStartupConfig::spawn("pocket-ic", Duration::from_secs(1))
919 .with_server_hard_ttl(Duration::from_millis(1))
920 .validate()
921 .expect_err("subsecond server hard TTL must fail");
922 assert!(matches!(
923 error,
924 PocketIcStartupError::InvalidConfiguration { .. }
925 ));
926 }
927
928 #[test]
929 fn managed_server_hard_ttl_is_opt_in() {
930 let default = PocketIcStartupConfig::spawn("pocket-ic", Duration::from_secs(1));
931 assert_eq!(default.server_hard_ttl(), None);
932
933 let explicit = default.with_server_hard_ttl(Duration::from_secs(17));
934 assert_eq!(explicit.server_hard_ttl(), Some(Duration::from_secs(17)));
935 }
936
937 #[test]
938 fn startup_files_leave_the_server_owned_port_path_absent() {
939 let (files, stdout, stderr) = StartupFiles::create().expect("allocate startup files");
940 let directory = files.directory.clone();
941
942 assert!(directory.is_dir());
943 assert!(!files.port.exists());
944 assert!(files.stdout.is_file());
945 assert!(files.stderr.is_file());
946 #[cfg(unix)]
947 {
948 use std::os::unix::fs::PermissionsExt as _;
949
950 let mode = fs::metadata(&directory)
951 .expect("inspect private startup directory")
952 .permissions()
953 .mode();
954 assert_eq!(mode & 0o077, 0);
955 }
956
957 drop(stdout);
958 drop(stderr);
959 drop(files);
960 assert!(!directory.exists());
961 }
962
963 #[cfg(unix)]
964 #[test]
965 fn managed_startup_reports_an_exited_server_with_bounded_output() {
966 let script = TestServerScript::new(
967 "exit",
968 "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nprintf 'synthetic server stdout'\nprintf 'synthetic bind failure' >&2\nexit 23\n",
969 );
970
971 let result = PocketIcBuilder::new().with_application_subnet().try_build(
972 PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2)),
973 );
974
975 let Err(PocketIcStartupError::ServerExited {
976 server_binary,
977 status,
978 stdout,
979 stderr,
980 ..
981 }) = result
982 else {
983 panic!("an exited managed server must return a structured exit error");
984 };
985 assert_eq!(server_binary, script.path());
986 assert_eq!(status.code(), Some(23));
987 assert_eq!(stdout, "synthetic server stdout");
988 assert_eq!(stderr, "synthetic bind failure");
989 }
990
991 #[cfg(unix)]
992 #[test]
993 fn managed_startup_terminates_a_server_that_never_becomes_ready() {
994 let script = TestServerScript::new(
995 "timeout",
996 "#!/bin/sh\nif [ \"$1\" != \"--port-file\" ] || [ -e \"$2\" ]; then exit 97; fi\nexec sleep 30\n",
997 );
998 let timeout = Duration::from_millis(100);
999 let started = Instant::now();
1000
1001 let result = PocketIcBuilder::new()
1002 .with_application_subnet()
1003 .try_build(PocketIcStartupConfig::spawn(script.path(), timeout));
1004
1005 assert!(
1006 started.elapsed() < Duration::from_secs(2),
1007 "bounded startup should not wait for the sleeping child"
1008 );
1009 assert!(matches!(
1010 result,
1011 Err(PocketIcStartupError::ReadinessTimeout {
1012 server_binary,
1013 timeout: actual_timeout,
1014 termination_error: None,
1015 ..
1016 }) if server_binary == script.path() && actual_timeout == timeout
1017 ));
1018 }
1019
1020 #[cfg(unix)]
1021 #[test]
1022 fn managed_server_handle_exposes_process_id_url_output_and_raii_ownership() {
1023 let script = TestServerScript::new(
1024 "handle",
1025 "#!/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",
1026 );
1027
1028 let server = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
1029 .start_managed_server()
1030 .expect("start caller-owned managed server");
1031
1032 assert_eq!(server.url(), "http://127.0.0.1:34567/");
1033 assert_eq!(
1034 server.output().stdout(),
1035 format!("managed server ready: {}", server.process_id())
1036 );
1037 assert_eq!(server.output().stderr(), "");
1038 #[cfg(target_os = "linux")]
1039 let child_process = PathBuf::from(format!("/proc/{}", server.process_id()));
1040 #[cfg(target_os = "linux")]
1041 assert!(child_process.exists());
1042 drop(server);
1043 #[cfg(target_os = "linux")]
1044 assert!(!child_process.exists());
1045 }
1046
1047 #[cfg(target_os = "linux")]
1048 #[test]
1049 fn managed_server_cleans_descendants_on_drop_timeout_exit_and_background_reap() {
1050 for mode in ["drop", "timeout", "exit", "background"] {
1051 let publish = if matches!(mode, "drop" | "background") {
1052 "printf '34567\\n' > \"$2\"\n"
1053 } else {
1054 ""
1055 };
1056 let finish = if matches!(mode, "exit" | "background") {
1057 "sleep 0.03\nexit 23\n"
1058 } else {
1059 "exec sleep 30\n"
1060 };
1061 let script = TestServerScript::new(
1062 mode,
1063 &format!("#!/bin/sh\nsleep 3 &\nprintf '%s' \"$!\"\n{publish}{finish}"),
1064 );
1065 let result = PocketIcStartupConfig::spawn(script.path(), Duration::from_millis(300))
1066 .start_managed_server();
1067 let output = match result {
1068 Ok(server) => {
1069 let output = server.output().stdout().to_owned();
1070 if mode == "background" {
1071 server.server.reap_in_background();
1072 } else {
1073 drop(server);
1074 }
1075 output
1076 }
1077 Err(PocketIcStartupError::ReadinessTimeout {
1078 stdout,
1079 termination_error,
1080 ..
1081 }) => {
1082 assert_eq!(mode, "timeout");
1083 assert_eq!(termination_error, None);
1084 stdout
1085 }
1086 Err(PocketIcStartupError::ServerExited { stdout, status, .. }) => {
1087 assert_eq!(mode, "exit");
1088 assert_eq!(status.code(), Some(23));
1089 stdout
1090 }
1091 other => panic!(
1092 "unexpected {mode} startup result: {}",
1093 match other {
1094 Err(error) => error.to_string(),
1095 Ok(_) => unreachable!(),
1096 }
1097 ),
1098 };
1099 let pid = output
1100 .parse::<u32>()
1101 .expect("server published its descendant PID");
1102 let state_file = PathBuf::from(format!("/proc/{pid}/stat"));
1103 let deadline = Instant::now() + Duration::from_secs(1);
1104 loop {
1105 match fs::read_to_string(&state_file) {
1106 Err(error) if error.kind() == std::io::ErrorKind::NotFound => break,
1107 Ok(state) if state.rsplit_once(") ").unwrap().1.starts_with('Z') => break,
1108 _ => {}
1109 }
1110 assert!(
1111 Instant::now() < deadline,
1112 "{mode} left its descendant running"
1113 );
1114 std::thread::sleep(Duration::from_millis(10));
1115 }
1116 }
1117 }
1118
1119 #[cfg(unix)]
1120 #[test]
1121 fn managed_server_passes_an_explicit_hard_ttl() {
1122 let script = TestServerScript::new(
1123 "hard-ttl",
1124 "#!/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",
1125 );
1126
1127 let server = PocketIcStartupConfig::spawn(script.path(), Duration::from_secs(2))
1128 .with_server_hard_ttl(Duration::from_secs(17))
1129 .start_managed_server()
1130 .expect("start managed server with an explicit hard TTL");
1131
1132 assert_eq!(server.url(), "http://127.0.0.1:34567/");
1133 }
1134
1135 #[test]
1136 #[ignore = "requires IC_TESTKIT_POCKET_IC_SERVER=<caller-provided PocketIC server binary>"]
1137 fn caller_provided_server_publishes_port_constructs_instance_and_cleans_up() {
1138 let binary = std::env::var_os("IC_TESTKIT_POCKET_IC_SERVER")
1139 .map(PathBuf::from)
1140 .expect("set IC_TESTKIT_POCKET_IC_SERVER to the exact server binary");
1141 let one_shot_sequence = super::STARTUP_FILE_SEQUENCE.load(super::Ordering::Relaxed);
1142 let one_shot_directory = std::env::temp_dir().join(format!(
1143 "ic-testkit-pocket-ic-startup-{}-{one_shot_sequence}",
1144 std::process::id()
1145 ));
1146 let one_shot = PocketIcBuilder::new()
1147 .with_application_subnet()
1148 .try_build(
1149 PocketIcStartupConfig::spawn(&binary, Duration::from_secs(30))
1150 .with_server_hard_ttl(Duration::from_secs(1)),
1151 )
1152 .expect("one-shot managed spawn must construct an instance");
1153 assert!(one_shot_directory.is_dir());
1154 drop(one_shot);
1155 let cleanup_deadline = Instant::now() + Duration::from_secs(3);
1156 while one_shot_directory.exists() && Instant::now() < cleanup_deadline {
1157 std::thread::sleep(Duration::from_millis(20));
1158 }
1159 assert!(!one_shot_directory.exists());
1160
1161 let server = PocketIcStartupConfig::spawn(&binary, Duration::from_secs(30))
1162 .with_server_hard_ttl(Duration::from_secs(60))
1163 .start_managed_server()
1164 .expect("caller-provided PocketIC server must publish its port");
1165 let files = &server.server.files;
1166 let startup_directory = files.directory.clone();
1167
1168 assert!(files.port.is_file());
1169 let pocket_ic = PocketIcBuilder::new()
1170 .with_application_subnet()
1171 .try_build(PocketIcStartupConfig::connect(
1172 server.url(),
1173 Duration::from_secs(30),
1174 ))
1175 .expect("construct instance through caller-provided server");
1176
1177 drop(pocket_ic);
1178 drop(server);
1179 assert!(!startup_directory.exists());
1180 }
1181
1182 #[cfg(unix)]
1183 struct TestServerScript {
1184 path: PathBuf,
1185 }
1186
1187 #[cfg(unix)]
1188 impl TestServerScript {
1189 fn new(label: &str, contents: &str) -> Self {
1190 let path = std::env::temp_dir().join(format!(
1191 "ic-testkit-pocket-ic-{label}-{}-{}",
1192 std::process::id(),
1193 super::STARTUP_FILE_SEQUENCE.fetch_add(1, super::Ordering::Relaxed),
1194 ));
1195 fs::write(&path, contents).expect("write synthetic PocketIC server script");
1196 let mut permissions = fs::metadata(&path)
1197 .expect("read synthetic server script metadata")
1198 .permissions();
1199 permissions.set_mode(0o755);
1200 fs::set_permissions(&path, permissions)
1201 .expect("make synthetic server script executable");
1202 Self { path }
1203 }
1204
1205 fn path(&self) -> PathBuf {
1206 self.path.clone()
1207 }
1208 }
1209
1210 #[cfg(unix)]
1211 impl Drop for TestServerScript {
1212 fn drop(&mut self) {
1213 let _ = fs::remove_file(&self.path);
1214 }
1215 }
1216}