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