1use camino::{Utf8Path, Utf8PathBuf};
2use std::{
3 fmt, io,
4 process::ExitStatus,
5 str::FromStr,
6 thread,
7 time::{Duration, Instant},
8};
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15#[must_use]
16pub enum Readiness<T> {
17 Ready(T),
19
20 Pending,
22}
23
24#[derive(Debug)]
29#[non_exhaustive]
30pub enum PollError {
31 Malformed {
38 path: Utf8PathBuf,
40
41 contents: Vec<u8>,
43
44 source: Box<dyn std::error::Error + Send + Sync>,
50 },
51
52 Io {
63 path: Utf8PathBuf,
65
66 source: io::Error,
68 },
69
70 ProcessExited {
72 path: Utf8PathBuf,
74
75 status: ExitStatus,
77 },
78
79 ChildTryWait {
81 path: Utf8PathBuf,
83
84 source: io::Error,
86 },
87}
88
89impl PollError {
90 pub fn path(&self) -> &Utf8Path {
92 match self {
93 PollError::Malformed { path, .. }
94 | PollError::Io { path, .. }
95 | PollError::ProcessExited { path, .. }
96 | PollError::ChildTryWait { path, .. } => path,
97 }
98 }
99}
100
101impl fmt::Display for PollError {
102 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 match self {
104 PollError::Malformed { path, contents, .. } => {
105 write!(
106 f,
107 "malformed port file {path}: {:?}",
108 String::from_utf8_lossy(contents)
109 )
110 }
111 PollError::Io { path, .. } => {
112 write!(f, "failed to read port file {path}")
113 }
114 PollError::ProcessExited { path, status } => {
115 write!(
116 f,
117 "process exited before writing port file {path} \
118 (status: {status})"
119 )
120 }
121 PollError::ChildTryWait { path, .. } => {
122 write!(
123 f,
124 "polling whether process was alive for port file {path}"
125 )
126 }
127 }
128 }
129}
130
131impl std::error::Error for PollError {
132 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
133 match self {
134 PollError::Malformed { source, .. } => Some(source.as_ref()),
135 PollError::Io { source, .. } => Some(source),
136 PollError::ProcessExited { .. } => None,
137 PollError::ChildTryWait { source, .. } => Some(source),
138 }
139 }
140}
141
142#[derive(Debug)]
143struct MissingTrailingNewline;
144
145impl fmt::Display for MissingTrailingNewline {
146 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147 f.write_str("file does not end with a newline")
148 }
149}
150
151impl std::error::Error for MissingTrailingNewline {}
152
153pub fn poll_once<T>(
181 path: &Utf8Path,
182 exited: io::Result<Option<ExitStatus>>,
183 read: io::Result<Vec<u8>>,
184) -> Result<Readiness<T>, PollError>
185where
186 T: FromStr,
187 T::Err: std::error::Error + Send + Sync + 'static,
188{
189 match read {
190 Ok(bytes) => {
191 let contents = match String::from_utf8(bytes) {
193 Ok(contents) => contents,
194 Err(error) => {
195 let source = error.utf8_error();
196 return Err(PollError::Malformed {
197 path: path.to_owned(),
198 contents: error.into_bytes(),
199 source: Box::new(source),
200 });
201 }
202 };
203 let Some(payload) = contents.strip_suffix('\n') else {
205 return Err(PollError::Malformed {
206 path: path.to_owned(),
207 contents: contents.into_bytes(),
208 source: Box::new(MissingTrailingNewline),
209 });
210 };
211 match payload.parse::<T>() {
212 Ok(value) => Ok(Readiness::Ready(value)),
213 Err(source) => Err(PollError::Malformed {
214 path: path.to_owned(),
215 contents: contents.into_bytes(),
216 source: Box::new(source),
217 }),
218 }
219 }
220 Err(e) if e.kind() == io::ErrorKind::NotFound => match exited {
221 Ok(Some(status)) => {
222 Err(PollError::ProcessExited { path: path.to_owned(), status })
223 }
224 Ok(None) => Ok(Readiness::Pending),
225 Err(e) if e.kind() == io::ErrorKind::Interrupted => {
226 Ok(Readiness::Pending)
227 }
228 Err(source) => {
229 Err(PollError::ChildTryWait { path: path.to_owned(), source })
230 }
231 },
232 Err(e) if e.kind() == io::ErrorKind::Interrupted => {
233 Ok(Readiness::Pending)
234 }
235 Err(e) if is_sharing_violation(&e) => Ok(Readiness::Pending),
236 Err(source) => Err(PollError::Io { path: path.to_owned(), source }),
237 }
238}
239
240#[cfg(windows)]
241fn is_sharing_violation(error: &io::Error) -> bool {
242 error.raw_os_error() == Some(32)
245}
246
247#[cfg(not(windows))]
248fn is_sharing_violation(_error: &io::Error) -> bool {
249 false
250}
251
252fn parent_dir_to_check(path: &Utf8Path) -> Option<&Utf8Path> {
253 let parent = path.parent()?;
256 if parent.as_str().is_empty() { None } else { Some(parent) }
257}
258
259#[derive(Debug)]
262enum ParentDirProblem {
263 Missing(Utf8PathBuf),
265
266 NotADirectory(Utf8PathBuf),
268}
269
270impl ParentDirProblem {
271 fn into_error(self, path: &Utf8Path, elapsed: Duration) -> WaitForError {
272 match self {
273 ParentDirProblem::Missing(parent) => {
274 WaitForError::MissingParentDirectory {
275 path: path.to_owned(),
276 parent,
277 elapsed,
278 }
279 }
280 ParentDirProblem::NotADirectory(parent) => {
281 WaitForError::ParentNotADirectory {
282 path: path.to_owned(),
283 parent,
284 elapsed,
285 }
286 }
287 }
288 }
289}
290
291fn classify_parent_dir(
292 parent: &Utf8Path,
293 metadata: io::Result<std::fs::Metadata>,
294) -> Option<ParentDirProblem> {
295 match metadata {
296 Ok(metadata) if metadata.is_dir() => None,
297 Ok(_) => Some(ParentDirProblem::NotADirectory(parent.to_owned())),
304 Err(error) if error.kind() == io::ErrorKind::NotFound => {
305 Some(ParentDirProblem::Missing(parent.to_owned()))
306 }
307 Err(_) => None,
312 }
313}
314
315fn check_parent_dir(path: &Utf8Path) -> Option<ParentDirProblem> {
316 let parent = parent_dir_to_check(path)?;
317 classify_parent_dir(parent, std::fs::metadata(parent))
318}
319
320#[cfg(feature = "tokio")]
321async fn check_parent_dir_async(path: &Utf8Path) -> Option<ParentDirProblem> {
322 let parent = parent_dir_to_check(path)?;
323 classify_parent_dir(parent, tokio::fs::metadata(parent).await)
324}
325
326#[derive(Clone, Copy, Debug, PartialEq, Eq)]
338pub struct PollInterval(pub Duration);
339
340#[derive(Clone, Copy, Debug, PartialEq, Eq)]
353pub struct Timeout(pub Duration);
354
355#[derive(Debug)]
357#[non_exhaustive]
358pub enum WaitForError {
359 Poll {
361 error: PollError,
363 elapsed: Duration,
365 },
366
367 MissingParentDirectory {
369 path: Utf8PathBuf,
371
372 parent: Utf8PathBuf,
374
375 elapsed: Duration,
377 },
378
379 ParentNotADirectory {
386 path: Utf8PathBuf,
388
389 parent: Utf8PathBuf,
391
392 elapsed: Duration,
394 },
395
396 TimedOut {
398 path: Utf8PathBuf,
400 elapsed: Duration,
402 },
403}
404
405impl WaitForError {
406 pub fn path(&self) -> &Utf8Path {
408 match self {
409 WaitForError::Poll { error, .. } => error.path(),
410 WaitForError::MissingParentDirectory { path, .. }
411 | WaitForError::ParentNotADirectory { path, .. }
412 | WaitForError::TimedOut { path, .. } => path,
413 }
414 }
415
416 pub fn elapsed(&self) -> Duration {
418 match self {
419 WaitForError::Poll { elapsed, .. }
420 | WaitForError::MissingParentDirectory { elapsed, .. }
421 | WaitForError::ParentNotADirectory { elapsed, .. }
422 | WaitForError::TimedOut { elapsed, .. } => *elapsed,
423 }
424 }
425}
426
427impl fmt::Display for WaitForError {
428 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
429 match self {
430 WaitForError::Poll { error, elapsed } => {
431 write!(f, "{error} (after {elapsed:?})")
432 }
433 WaitForError::MissingParentDirectory { path, parent, elapsed } => {
434 write!(
435 f,
436 "parent directory {parent} does not exist, so port file \
437 {path} can never appear (after {elapsed:?})"
438 )
439 }
440 WaitForError::ParentNotADirectory { path, parent, elapsed } => {
441 write!(
442 f,
443 "parent path {parent} is not a directory, so port file \
444 {path} can never appear (after {elapsed:?})"
445 )
446 }
447 WaitForError::TimedOut { path, elapsed } => {
448 write!(
449 f,
450 "timed out after {elapsed:?} waiting for port file {path}"
451 )
452 }
453 }
454 }
455}
456
457impl std::error::Error for WaitForError {
458 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
459 match self {
460 WaitForError::Poll { error, .. } => {
461 std::error::Error::source(error)
462 }
463 WaitForError::MissingParentDirectory { .. }
464 | WaitForError::ParentNotADirectory { .. }
465 | WaitForError::TimedOut { .. } => None,
466 }
467 }
468}
469
470pub fn wait_for_blocking<T>(
510 path: &Utf8Path,
511 mut child_try_wait: impl FnMut() -> io::Result<Option<ExitStatus>>,
512 poll_interval: PollInterval,
513 timeout: Timeout,
514) -> Result<T, WaitForError>
515where
516 T: FromStr,
517 T::Err: std::error::Error + Send + Sync + 'static,
518{
519 let start = Instant::now();
520 loop {
521 let exited = child_try_wait();
524 let read = std::fs::read(path);
525 match poll_once::<T>(path, exited, read) {
526 Ok(Readiness::Ready(value)) => return Ok(value),
527 Ok(Readiness::Pending) => {
528 if let Some(problem) = check_parent_dir(path) {
529 return Err(problem.into_error(path, start.elapsed()));
530 }
531 }
532 Err(error) => {
533 return Err(WaitForError::Poll {
534 error,
535 elapsed: start.elapsed(),
536 });
537 }
538 }
539
540 let elapsed = start.elapsed();
545 if elapsed >= timeout.0 {
546 return Err(WaitForError::TimedOut {
547 path: path.to_owned(),
548 elapsed,
549 });
550 }
551 thread::sleep(poll_interval.0.min(timeout.0 - elapsed));
552 }
553}
554
555#[cfg(feature = "tokio")]
597#[cfg_attr(doc_cfg, doc(cfg(feature = "tokio")))]
598pub async fn wait_for<T>(
599 path: &Utf8Path,
600 mut child_try_wait: impl FnMut() -> io::Result<Option<ExitStatus>>,
601 poll_interval: PollInterval,
602 timeout: Timeout,
603) -> Result<T, WaitForError>
604where
605 T: FromStr,
606 T::Err: std::error::Error + Send + Sync + 'static,
607{
608 let start = tokio::time::Instant::now();
609 loop {
614 let exited = child_try_wait();
617 let read = tokio::fs::read(path).await;
618 match poll_once::<T>(path, exited, read) {
619 Ok(Readiness::Ready(value)) => return Ok(value),
620 Ok(Readiness::Pending) => {
621 if let Some(problem) = check_parent_dir_async(path).await {
622 return Err(problem.into_error(path, start.elapsed()));
623 }
624 }
625 Err(error) => {
626 return Err(WaitForError::Poll {
627 error,
628 elapsed: start.elapsed(),
629 });
630 }
631 }
632
633 let elapsed = start.elapsed();
638 if elapsed >= timeout.0 {
639 return Err(WaitForError::TimedOut {
640 path: path.to_owned(),
641 elapsed,
642 });
643 }
644 tokio::time::sleep(poll_interval.0.min(timeout.0 - elapsed)).await;
645 }
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651 use std::net::SocketAddr;
652
653 fn missing() -> io::Result<Vec<u8>> {
654 Err(io::Error::from(io::ErrorKind::NotFound))
655 }
656
657 fn bytes(contents: &str) -> io::Result<Vec<u8>> {
658 Ok(contents.as_bytes().to_vec())
659 }
660
661 #[cfg(unix)]
662 fn exited_status() -> ExitStatus {
663 use std::os::unix::process::ExitStatusExt;
664 ExitStatus::from_raw(1 << 8)
665 }
666
667 #[cfg(windows)]
668 fn exited_status() -> ExitStatus {
669 use std::os::windows::process::ExitStatusExt;
670 ExitStatus::from_raw(1)
671 }
672
673 #[test]
674 fn parses_bound_socket_addr() {
675 let path = Utf8Path::new("port");
676 let addr: SocketAddr = "[::1]:4676".parse().unwrap();
677 assert_eq!(
678 poll_once::<SocketAddr>(path, Ok(None), bytes("[::1]:4676\n"))
679 .unwrap(),
680 Readiness::Ready(addr)
681 );
682 }
683
684 #[test]
685 fn empty_file_is_malformed() {
686 let path = Utf8Path::new("port");
687 let err = poll_once::<SocketAddr>(path, Ok(None), Ok(Vec::new()))
688 .unwrap_err();
689 let PollError::Malformed { path: p, contents, source } = err else {
690 panic!("expected Malformed, got {err:?}");
691 };
692 assert_eq!(p, path);
693 assert_eq!(contents, b"");
694 assert!(
695 source.downcast_ref::<MissingTrailingNewline>().is_some(),
696 "empty file is missing its trailing newline: {source}"
697 );
698 }
699
700 #[test]
701 fn whitespace_only_file_is_malformed() {
702 let path = Utf8Path::new("port");
703 let err = poll_once::<SocketAddr>(path, Ok(None), bytes(" \n"))
704 .unwrap_err();
705 let PollError::Malformed { path: p, contents, .. } = err else {
706 panic!("expected Malformed, got {err:?}");
707 };
708 assert_eq!(p, path);
709 assert_eq!(contents, b" \n");
710 }
711
712 #[test]
713 fn non_utf8_file_is_malformed() {
714 let path = Utf8Path::new("port");
715 let err =
716 poll_once::<SocketAddr>(path, Ok(None), Ok(vec![0xff, b'\n']))
717 .unwrap_err();
718 let PollError::Malformed { path: p, contents, source } = err else {
719 panic!("expected Malformed, got {err:?}");
720 };
721 assert_eq!(p, path);
722 assert_eq!(contents, b"\xff\n");
723 assert!(
724 source.downcast_ref::<std::str::Utf8Error>().is_some(),
725 "source should be a Utf8Error: {source}"
726 );
727 }
728
729 #[test]
730 fn missing_trailing_newline_is_malformed() {
731 let path = Utf8Path::new("port");
733 let err = poll_once::<SocketAddr>(path, Ok(None), bytes("[::1]:4676"))
734 .unwrap_err();
735 let PollError::Malformed { path: p, contents, .. } = err else {
736 panic!("expected Malformed, got {err:?}");
737 };
738 assert_eq!(p, path);
739 assert_eq!(contents, b"[::1]:4676");
740 }
741
742 #[test]
743 fn only_trailing_newline_is_stripped() {
744 let path = Utf8Path::new("port");
746 assert_eq!(
747 poll_once::<String>(path, Ok(None), bytes(" spaced \n")).unwrap(),
748 Readiness::Ready(" spaced ".to_owned())
749 );
750 }
751
752 #[test]
753 fn missing_file_is_pending_while_alive() {
754 let path = Utf8Path::new("port");
755 assert_eq!(
756 poll_once::<SocketAddr>(path, Ok(None), missing()).unwrap(),
757 Readiness::Pending
758 );
759 }
760
761 #[test]
762 fn missing_file_after_exit_is_permanent() {
763 let path = Utf8Path::new("port");
766 let err =
767 poll_once::<SocketAddr>(path, Ok(Some(exited_status())), missing())
768 .unwrap_err();
769 let PollError::ProcessExited { path: p, .. } = err else {
770 panic!("expected ProcessExited, got {err:?}");
771 };
772 assert_eq!(p, path);
773 }
774
775 #[test]
776 fn malformed_file_is_permanent() {
777 let path = Utf8Path::new("port");
778 let err =
779 poll_once::<SocketAddr>(path, Ok(None), bytes("not-an-addr\n"))
780 .unwrap_err();
781 let PollError::Malformed { path: p, contents, .. } = err else {
782 panic!("expected Malformed, got {err:?}");
783 };
784 assert_eq!(p, path);
785 assert_eq!(contents, b"not-an-addr\n");
786 }
787
788 #[test]
789 fn malformed_file_wins_over_exit() {
790 let path = Utf8Path::new("port");
791 let err = poll_once::<SocketAddr>(
792 path,
793 Ok(Some(exited_status())),
794 bytes("garbage\n"),
795 )
796 .unwrap_err();
797 let PollError::Malformed { .. } = err else {
798 panic!("unexpected error: {err:?}");
799 };
800 }
801
802 #[test]
803 fn valid_file_wins_over_exit() {
804 let path = Utf8Path::new("port");
805 let addr: SocketAddr = "[::1]:4676".parse().unwrap();
806 assert_eq!(
807 poll_once::<SocketAddr>(
808 path,
809 Ok(Some(exited_status())),
810 bytes("[::1]:4676\n")
811 )
812 .unwrap(),
813 Readiness::Ready(addr)
814 );
815 }
816
817 #[test]
818 fn io_error_is_permanent() {
819 let path = Utf8Path::new("port");
820 let err = poll_once::<SocketAddr>(
821 path,
822 Ok(None),
823 Err(io::Error::from(io::ErrorKind::PermissionDenied)),
824 )
825 .unwrap_err();
826 let PollError::Io { path: p, source } = err else {
827 panic!("expected Io, got {err:?}");
828 };
829 assert_eq!(p, path);
830 assert_eq!(source.kind(), io::ErrorKind::PermissionDenied);
831 }
832
833 #[test]
834 fn child_try_wait_error_is_permanent() {
835 let path = Utf8Path::new("port");
836 let err = poll_once::<SocketAddr>(
837 path,
838 Err(io::Error::from(io::ErrorKind::Other)),
839 missing(),
840 )
841 .unwrap_err();
842 let PollError::ChildTryWait { path: p, .. } = err else {
843 panic!("expected ChildTryWait, got {err:?}");
844 };
845 assert_eq!(p, path);
846 }
847
848 #[test]
849 fn interrupted_read_is_pending() {
850 let path = Utf8Path::new("port");
853 assert_eq!(
854 poll_once::<SocketAddr>(
855 path,
856 Ok(None),
857 Err(io::Error::from(io::ErrorKind::Interrupted)),
858 )
859 .unwrap(),
860 Readiness::Pending
861 );
862 }
863
864 #[test]
865 fn interrupted_child_try_wait_is_pending() {
866 let path = Utf8Path::new("port");
869 assert_eq!(
870 poll_once::<SocketAddr>(
871 path,
872 Err(io::Error::from(io::ErrorKind::Interrupted)),
873 missing(),
874 )
875 .unwrap(),
876 Readiness::Pending
877 );
878 }
879
880 #[test]
881 #[cfg(windows)]
882 fn sharing_violation_is_pending() {
883 let path = Utf8Path::new("port");
884 assert_eq!(
885 poll_once::<SocketAddr>(
886 path,
887 Ok(None),
888 Err(io::Error::from_raw_os_error(32)),
889 )
890 .unwrap(),
891 Readiness::Pending
892 );
893 }
894
895 #[test]
896 fn wait_for_error_poll_is_transparent() {
897 let inner = PollError::Io {
898 path: Utf8PathBuf::from("port"),
899 source: io::Error::from(io::ErrorKind::PermissionDenied),
900 };
901 let inner_message = inner.to_string();
902
903 let wrapped =
904 WaitForError::Poll { error: inner, elapsed: Duration::ZERO };
905 assert_eq!(wrapped.to_string(), format!("{inner_message} (after 0ns)"));
906
907 let source = std::error::Error::source(&wrapped)
908 .expect("Poll delegates to the PollError's source");
909 assert!(
910 source.downcast_ref::<io::Error>().is_some(),
911 "source is the underlying io::Error, not the PollError: {source}"
912 );
913 }
914
915 #[test]
916 fn parent_dir_to_check_skips_bare_and_root_paths() {
917 assert_eq!(
918 parent_dir_to_check(Utf8Path::new("port")),
919 None,
920 "a bare file name has no directory to check"
921 );
922 assert_eq!(
923 parent_dir_to_check(Utf8Path::new("/")),
924 None,
925 "the root has no parent"
926 );
927 assert_eq!(
928 parent_dir_to_check(Utf8Path::new("dir/port")),
929 Some(Utf8Path::new("dir"))
930 );
931 assert_eq!(
932 parent_dir_to_check(Utf8Path::new("/port")),
933 Some(Utf8Path::new("/"))
934 );
935 }
936
937 #[test]
938 fn check_parent_dir_reports_missing_directory() {
939 let dir = camino_tempfile::tempdir().unwrap();
940 let present = dir.path().join("port");
941 assert!(
942 check_parent_dir(&present).is_none(),
943 "an existing parent directory is not an error"
944 );
945
946 let missing = dir.path().join("missing_dir").join("port");
947 let problem = check_parent_dir(&missing)
948 .expect("a missing parent directory is a permanent problem");
949 let ParentDirProblem::Missing(parent) = &problem else {
950 panic!("expected Missing, got {problem:?}");
951 };
952 assert_eq!(parent, &dir.path().join("missing_dir"));
953
954 let error = problem.into_error(&missing, Duration::from_secs(1));
955 let WaitForError::MissingParentDirectory { path, parent, elapsed } =
956 &error
957 else {
958 panic!("expected MissingParentDirectory, got {error:?}");
959 };
960 assert_eq!(path, &missing);
961 assert_eq!(parent, &dir.path().join("missing_dir"));
962 assert_eq!(*elapsed, Duration::from_secs(1));
963 }
964
965 #[test]
966 fn check_parent_dir_reports_file_in_parent_position() {
967 let dir = camino_tempfile::tempdir().unwrap();
971 let occupied = dir.path().join("occupied");
972 std::fs::write(&occupied, "not a directory").unwrap();
973
974 let path = occupied.join("port");
975 let problem = check_parent_dir(&path)
976 .expect("a file in the parent position is a permanent problem");
977 let ParentDirProblem::NotADirectory(parent) = &problem else {
978 panic!("expected NotADirectory, got {problem:?}");
979 };
980 assert_eq!(parent, &occupied);
981
982 let error = problem.into_error(&path, Duration::from_secs(1));
983 let WaitForError::ParentNotADirectory { path: p, parent, elapsed } =
984 &error
985 else {
986 panic!("expected ParentNotADirectory, got {error:?}");
987 };
988 assert_eq!(p, &path);
989 assert_eq!(parent, &occupied);
990 assert_eq!(*elapsed, Duration::from_secs(1));
991 }
992}