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}