Skip to main content

port_file/
read.rs

1use camino::{Utf8Path, Utf8PathBuf};
2use std::{
3    fmt, io,
4    process::ExitStatus,
5    str::FromStr,
6    thread,
7    time::{Duration, Instant},
8};
9
10/// The outcome of a [`poll_once`] call.
11///
12/// This has a similar shape to [`std::task::Poll`], but is defined separately
13/// because `Poll` implies waker semantics that don't necessarily apply here.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15#[must_use]
16pub enum Readiness<T> {
17    /// The port file is available and has been parsed successfully.
18    Ready(T),
19
20    /// The port file is not yet available.
21    Pending,
22}
23
24/// A permanent error indicating the port file could not be read, returned by
25/// [`poll_once`].
26///
27/// This is also part of the [`WaitForError::Poll`] variant.
28#[derive(Debug)]
29#[non_exhaustive]
30pub enum PollError {
31    /// The file was present but its contents could not be parsed.
32    ///
33    /// When the file is produced by [`write`] or another atomic writer, there
34    /// is no danger of seeing a partial write, so a parse error is permanent.
35    /// However, a non-atomic writer can momentarily expose an incomplete file,
36    /// which would be reported as malformed here.
37    Malformed {
38        /// The path to the port file.
39        path: Utf8PathBuf,
40
41        /// The raw contents of the port file read from disk.
42        contents: Vec<u8>,
43
44        /// The error describing why the contents could not be parsed.
45        ///
46        /// For a value that was present but unparseable, this is the error
47        /// returned by the [`FromStr`] implementation. It could also be an
48        /// error indicating a missing trailing newline or invalid UTF-8.
49        source: Box<dyn std::error::Error + Send + Sync>,
50    },
51
52    /// The file could not be read for a reason other than "not found yet" or
53    /// an interrupted read.
54    ///
55    /// `Io` is returned for all I/O error kinds other than the following:
56    ///
57    /// * [`io::ErrorKind::NotFound`], since that indicates the port file is
58    ///   not present.
59    /// * [`io::ErrorKind::Interrupted`], which should be retried.
60    /// * On Windows, OS error 32 (`ERROR_SHARING_VIOLATION`), which usually
61    ///   indicates an antivirus or other scanner.
62    Io {
63        /// The path to the port file.
64        path: Utf8PathBuf,
65
66        /// The error that occurred while reading the port file.
67        source: io::Error,
68    },
69
70    /// The child process exited before the port file appeared.
71    ProcessExited {
72        /// The path to the port file.
73        path: Utf8PathBuf,
74
75        /// The exit status of the child process.
76        status: ExitStatus,
77    },
78
79    /// There was an error determining whether the child process was alive.
80    ChildTryWait {
81        /// The path to the port file.
82        path: Utf8PathBuf,
83
84        /// The error that occurred while trying to wait for the child process.
85        source: io::Error,
86    },
87}
88
89impl PollError {
90    /// Returns the path to the port file.
91    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
153/// Polls a port file once.
154///
155/// This is the underlying function used to determine whether a port file is
156/// available, based on the results of a read attempt and the result of
157/// [`Child::try_wait`].
158///
159/// # Notes
160///
161/// Callers must call [`Child::try_wait`] _before_ attempting to read the port
162/// file. Doing so guarantees that any file the writer produces just before
163/// exiting is visible to the reader. The parameter order mirrors this
164/// requirement, so it is fine to build the arguments inline as in
165/// `poll_once(path, child.try_wait(), std::fs::read(path))`.
166///
167/// An [`io::ErrorKind::Interrupted`] error from either `exited` or `read` is
168/// transient by definition and reported as [`Readiness::Pending`], to be
169/// retried on the next poll.
170///
171/// A file already present at `path` is returned as-is. Callers must ensure that
172/// the port file doesn't already exist at the start of the operation, typically
173/// by creating a fresh temporary directory to write the port file to. (The
174/// [`write`] function enforces this requirement.)
175///
176/// The read data must be valid UTF-8, and must end with a single trailing
177/// newline.
178///
179/// [`Child::try_wait`]: std::process::Child::try_wait
180pub 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            // We reject non-UTF-8 files as malformed.
192            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            // Strip the trailing newline that write always adds.
204            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 32 (ERROR_SHARING_VIOLATION) is transient, but Rust treats it as
243    // ErrorKind::Uncategorized
244    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    // Bare file names have Some("") as their parent and root paths have None.
254    // Neither has a meaningful directory to check.
255    let parent = path.parent()?;
256    if parent.as_str().is_empty() { None } else { Some(parent) }
257}
258
259/// A permanent problem with the port file's parent directory, detected by the
260/// wait loops.
261#[derive(Debug)]
262enum ParentDirProblem {
263    /// The parent directory does not exist.
264    Missing(Utf8PathBuf),
265
266    /// The parent path exists but is not a directory.
267    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        // A regular file (or other non-directory) in the parent position means
298        // the port file can never be created there. Examining the parent
299        // directory is important on Windows, where reading the port file
300        // returns "not found" in this situation. (On Unix-like platforms, that
301        // read fails with ENOTDIR, which poll_once already treats as
302        // permanent.)
303        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        // Treat other errors as inconclusive. This check only runs after
308        // reading the port file itself reported "not found", and most
309        // scenarios that make the metadata call fail (such as permissions
310        // issues) make that read also fail with a permanent error.
311        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/// How long [`wait_for`] and [`wait_for_blocking`] sleep between polls of the
327/// port file.
328///
329/// A typical value for the poll interval is 25 milliseconds.
330///
331/// Sleeps are clamped to the time remaining before the [`Timeout`] deadline, so
332/// a long poll interval does not end up in the wait functions waiting past the
333/// deadline.
334///
335/// This is a newtype (rather than a bare [`Duration`]) so it cannot be
336/// confused with [`Timeout`].
337#[derive(Clone, Copy, Debug, PartialEq, Eq)]
338pub struct PollInterval(pub Duration);
339
340/// How long [`wait_for`] and [`wait_for_blocking`] keep polling before giving
341/// up.
342///
343/// A typical value for the timeout is 30 seconds.
344///
345/// Note that the wait functions poll for the port file at least once even with
346/// a zero timeout, and poll one final time once the deadline passes. This means
347/// that a file appearing before the deadline is always found. (A zero timeout
348/// acts as a single non-blocking check.)
349///
350/// This is a newtype (rather than a bare [`Duration`]) so it cannot be
351/// confused with [`PollInterval`].
352#[derive(Clone, Copy, Debug, PartialEq, Eq)]
353pub struct Timeout(pub Duration);
354
355/// A permanent error returned by [`wait_for`] and [`wait_for_blocking`].
356#[derive(Debug)]
357#[non_exhaustive]
358pub enum WaitForError {
359    /// A poll operation returned a permanent error.
360    Poll {
361        /// The error that occurred.
362        error: PollError,
363        /// How long the driver had been waiting when the error occurred.
364        elapsed: Duration,
365    },
366
367    /// The parent directory of the port file does not exist.
368    MissingParentDirectory {
369        /// The path to the port file.
370        path: Utf8PathBuf,
371
372        /// The parent directory that does not exist.
373        parent: Utf8PathBuf,
374
375        /// How long the driver had been waiting when the error occurred.
376        elapsed: Duration,
377    },
378
379    /// The parent path of the port file exists but is not a directory.
380    ///
381    /// This is most likely to occur on Windows. On Unix-like platforms, this
382    /// condition usually surfaces as [`WaitForError::Poll`] with
383    /// [`PollError::Io`] instead, because reading the port file itself fails
384    /// with a "not a directory" error.
385    ParentNotADirectory {
386        /// The path to the port file.
387        path: Utf8PathBuf,
388
389        /// The parent path that is not a directory.
390        parent: Utf8PathBuf,
391
392        /// How long the driver had been waiting when the error occurred.
393        elapsed: Duration,
394    },
395
396    /// The timeout was reached.
397    TimedOut {
398        /// The path of the port file that was being waited for.
399        path: Utf8PathBuf,
400        /// How long the driver had been waiting when the error occurred.
401        elapsed: Duration,
402    },
403}
404
405impl WaitForError {
406    /// Returns the path of the port file that was being waited for.
407    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    /// Returns how long the driver had been waiting when the error occurred.
417    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
470/// Repeatedly polls `path` with [`thread::sleep`] until completion.
471///
472/// `child_try_wait` is typically `|| child.try_wait()` on a
473/// [`std::process::Child`].
474///
475/// Note that unlike [`wait_for`], a blocking wait cannot be cancelled.
476///
477/// # Stale port files
478///
479/// A file already present at `path` is returned as-is. Callers must ensure that
480/// the port file doesn't already exist at the start of the operation, typically
481/// by creating a fresh temporary directory to write the port file to. (The
482/// [`write`] function enforces this requirement.)
483///
484/// # Examples
485///
486/// ```
487/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
488/// use port_file::{PollInterval, Timeout};
489/// use std::{net::SocketAddr, time::Duration};
490///
491/// let dir = camino_tempfile::tempdir()?;
492/// let path = dir.path().join("service.port");
493///
494/// // Ordinarily a child process publishes its address. In this example,
495/// // we write it inline here.
496/// port_file::write(&path, "[::1]:8080".parse::<SocketAddr>()?)?;
497///
498/// let addr: SocketAddr = port_file::wait_for_blocking(
499///     &path,
500///     // With a real child process, pass in `|| child.try_wait()` instead.
501///     || Ok(None),
502///     PollInterval(Duration::from_millis(25)),
503///     Timeout(Duration::from_secs(30)),
504/// )?;
505/// assert_eq!(addr, "[::1]:8080".parse()?);
506/// # Ok(())
507/// # }
508/// ```
509pub 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        // Call child_try_wait before reading the file, as documented by
522        // poll_once.
523        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        // The deadline check runs only after a poll, and the final sleep is
541        // clamped to the deadline. This guarantees that a file appearing before
542        // the deadline is always seen, and that a zero timeout still polls
543        // exactly once.
544        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/// Polls `path` in an asynchronous fashion until completion.
556///
557/// `child_try_wait` is typically `|| child.try_wait()` on a
558/// [`std::process::Child`] or [`tokio::process::Child`].
559///
560/// # Stale port files
561///
562/// A file already present at `path` is returned as-is. Callers must ensure that
563/// the port file doesn't already exist at the start of the operation, typically
564/// by creating a fresh temporary directory to write the port file to. (The
565/// [`write`] function enforces this requirement.)
566///
567/// # Examples
568///
569/// ```
570/// # #[tokio::main(flavor = "current_thread")]
571/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
572/// use port_file::{PollInterval, Timeout};
573/// use std::{net::SocketAddr, time::Duration};
574///
575/// let dir = camino_tempfile::tempdir()?;
576/// let path = dir.path().join("service.port");
577///
578/// // Ordinarily a child process publishes its address. In this example,
579/// // we write it inline here.
580/// port_file::write(&path, "[::1]:8080".parse::<SocketAddr>()?)?;
581///
582/// let addr: SocketAddr = port_file::wait_for(
583///     &path,
584///     // With a real child process, pass in `|| child.try_wait()` instead.
585///     || Ok(None),
586///     PollInterval(Duration::from_millis(25)),
587///     Timeout(Duration::from_secs(30)),
588/// )
589/// .await?;
590/// assert_eq!(addr, "[::1]:8080".parse()?);
591/// # Ok(())
592/// # }
593/// ```
594///
595/// [`tokio::process::Child`]: https://docs.rs/tokio/1/tokio/process/struct.Child.html
596#[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    // We poll explicitly for the child process being alive rather than waiting
610    // for it to exit. We could potentially use a select loop instead, but there
611    // isn't much benefit to that and it prevents abstracting out `poll_once`
612    // into a separate function.
613    loop {
614        // Call child_try_wait before reading the file, as documented by
615        // poll_once.
616        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        // The deadline check runs only after a poll, and the final sleep is
634        // clamped to the deadline. This guarantees that a file appearing before
635        // the deadline is always seen, and that a zero timeout still polls
636        // exactly once.
637        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        // Test that the trailing newline is mandatory.
732        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        // Test that we strip exactly one trailing newline.
745        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        // Test that a "not found" read paired with a dead writer is a permanent
764        // error.
765        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        // Test that an interrupted read is transient, not a permanent Io
851        // error.
852        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        // Test that an interrupted child_try_wait is transient, not a
867        // permanent ChildTryWait error.
868        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        // Test that a regular file where the parent directory should be is a
968        // permanent problem. This matters on Windows, where reading the port
969        // file itself reports "not found" rather than "not a directory".
970        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}