Skip to main content

epics_libcom_rs/runtime/
fs.rs

1//! Filesystem operations through the runtime seam.
2//!
3//! The counterpart of [`crate::runtime::task`]'s time seam, for the same
4//! reason (decision A2): no site in this crate names `tokio::fs` directly.
5//!
6//! # Why `tokio::fs` is not portable here
7//!
8//! `tokio::fs` is not async file IO — there is no such thing on a POSIX
9//! filesystem. Every call is a blocking `std::fs` call that tokio hands to its
10//! `spawn_blocking` pool, so each one requires an entered tokio runtime and
11//! panics without one:
12//!
13//! ```text
14//! thread 'cbMedium' panicked at tokio-1.51.1/src/fs/mod.rs:317:11:
15//! there is no reactor running, must be called from the context of a
16//! Tokio 1.x runtime
17//! ```
18//!
19//! Under `rtems-exec-model` the background executor runs callbacks on its own
20//! std threads, which are not tokio runtime threads, so every `tokio::fs` call
21//! reached from a callback took the IOC's autosave writer down with it — and
22//! because the RTEMS build unwinds rather than aborts, it took *only* that
23//! thread down, leaving an IOC that looked healthy and had silently stopped
24//! saving.
25//!
26//! These wrappers do the same thing tokio does — `std::fs` on a blocking
27//! worker — but through [`crate::runtime::task::spawn_blocking`], which both
28//! backends implement. On the RTEMS backend that is a callback-pool worker.
29//!
30//! # Composite operations belong in one closure
31//!
32//! Prefer doing a whole sequence inside a single [`blocking`] call over
33//! chaining these wrappers. An open-write-sync-rename sequence built from four
34//! awaits makes four hops through the worker pool and lets unrelated work
35//! interleave between the steps; the same sequence in one closure makes one
36//! hop and keeps the durability ordering where a reader can see it.
37
38use std::io;
39use std::path::{Path, PathBuf};
40
41/// Run `f` on a blocking worker, flattening the join error into `io::Error`.
42///
43/// A join error means the worker panicked or was cancelled. There is no
44/// meaningful filesystem answer in that case, so it surfaces as
45/// [`io::ErrorKind::Other`] rather than being unwrapped into a second panic on
46/// the caller's thread.
47pub async fn blocking<F, T>(f: F) -> io::Result<T>
48where
49    F: FnOnce() -> io::Result<T> + Send + 'static,
50    T: Send + 'static,
51{
52    match crate::runtime::task::spawn_blocking(f).await {
53        Ok(result) => result,
54        Err(e) => Err(io::Error::other(format!(
55            "filesystem worker did not complete: {e}"
56        ))),
57    }
58}
59
60/// [`std::fs::read_to_string`] on a blocking worker.
61pub async fn read_to_string(path: impl AsRef<Path>) -> io::Result<String> {
62    let path = path.as_ref().to_path_buf();
63    blocking(move || std::fs::read_to_string(path)).await
64}
65
66/// [`std::fs::write`] on a blocking worker.
67pub async fn write(path: impl AsRef<Path>, contents: impl Into<Vec<u8>>) -> io::Result<()> {
68    let path = path.as_ref().to_path_buf();
69    let contents = contents.into();
70    blocking(move || std::fs::write(path, contents)).await
71}
72
73/// [`std::fs::copy`] on a blocking worker.
74pub async fn copy(from: impl AsRef<Path>, to: impl AsRef<Path>) -> io::Result<u64> {
75    let from = from.as_ref().to_path_buf();
76    let to = to.as_ref().to_path_buf();
77    blocking(move || std::fs::copy(from, to)).await
78}
79
80/// [`std::fs::rename`] on a blocking worker.
81pub async fn rename(from: impl AsRef<Path>, to: impl AsRef<Path>) -> io::Result<()> {
82    let from = from.as_ref().to_path_buf();
83    let to = to.as_ref().to_path_buf();
84    blocking(move || std::fs::rename(from, to)).await
85}
86
87/// [`std::fs::canonicalize`] on a blocking worker.
88pub async fn canonicalize(path: impl AsRef<Path>) -> io::Result<PathBuf> {
89    let path = path.as_ref().to_path_buf();
90    blocking(move || std::fs::canonicalize(path)).await
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[epics_macros_rs::epics_test]
98    async fn a_round_trip_goes_through_the_seam() {
99        let dir = tempfile::tempdir().unwrap();
100        let path = dir.path().join("seam.txt");
101        write(&path, "hello").await.unwrap();
102        assert_eq!(read_to_string(&path).await.unwrap(), "hello");
103    }
104
105    /// The error must be the filesystem's, not a wrapper's: callers switch on
106    /// `ErrorKind::NotFound` (autosave treats a missing `.sav` as "no saved
107    /// state" and anything else as corruption), so flattening must not lose it.
108    #[epics_macros_rs::epics_test]
109    async fn a_missing_file_keeps_its_error_kind() {
110        let dir = tempfile::tempdir().unwrap();
111        let e = read_to_string(dir.path().join("absent")).await.unwrap_err();
112        assert_eq!(e.kind(), io::ErrorKind::NotFound);
113    }
114
115    #[epics_macros_rs::epics_test]
116    async fn rename_and_copy_move_the_bytes() {
117        let dir = tempfile::tempdir().unwrap();
118        let a = dir.path().join("a");
119        let b = dir.path().join("b");
120        let c = dir.path().join("c");
121        write(&a, "payload").await.unwrap();
122        rename(&a, &b).await.unwrap();
123        assert!(!a.exists());
124        copy(&b, &c).await.unwrap();
125        assert_eq!(read_to_string(&c).await.unwrap(), "payload");
126    }
127
128    #[epics_macros_rs::epics_test]
129    async fn canonicalize_resolves_to_an_absolute_path() {
130        let dir = tempfile::tempdir().unwrap();
131        let path = dir.path().join("real");
132        write(&path, "x").await.unwrap();
133        assert!(canonicalize(&path).await.unwrap().is_absolute());
134    }
135
136    /// A composite sequence is one hop, and its `io::Error` reaches the caller
137    /// unchanged — this is the shape `write_save_file` uses.
138    #[epics_macros_rs::epics_test]
139    async fn a_composite_closure_returns_its_own_error() {
140        let e = blocking(|| std::fs::read_to_string("/definitely/not/here"))
141            .await
142            .unwrap_err();
143        assert_eq!(e.kind(), io::ErrorKind::NotFound);
144    }
145
146    /// No wrapper in this module may name the crate it replaces: that is the
147    /// whole point of it existing, and one that reached for the convenient
148    /// spelling would reintroduce the panic it was written to remove.
149    ///
150    /// Bounded to the production half — from the imports up to this test
151    /// module — for the reason this file exists to document: the header prose
152    /// above and this test's own doc comment both name the forbidden spelling
153    /// in order to explain it, so a whole-file search would match the
154    /// explanation and fail. Measured; it failed exactly that way first time.
155    #[test]
156    fn the_seam_does_not_name_the_crate_it_replaces() {
157        let src = include_str!("fs.rs");
158        let code = src
159            .split_once("use std::io;")
160            .expect("the module still starts its code with the imports")
161            .1;
162        let production = code
163            .split_once(concat!("#[cfg", "(test)]"))
164            .expect("the test module is still the tail of this file")
165            .0;
166        assert!(!production.contains(concat!("tokio", "::fs")));
167    }
168}