Skip to main content

gwseq_io/source/
local.rs

1//! A local file as a [`ByteSource`].
2//!
3//! One `std::fs::File`, read positionally — `pread` on unix, `seek_read` on
4//! Windows — so `&self` is enough and N threads share one handle, and there is
5//! no pool of handles to manage or hand back.
6//!
7//! No `mmap`. It would be faster on a warm page cache and it would turn a
8//! truncated or concurrently modified file into `SIGBUS`, which is not a
9//! failure a `Result` can carry or a Python caller can catch. Every other
10//! failure here comes back as an error; this one would not.
11
12use std::fs::File;
13use std::path::Path;
14
15use bytes::Bytes;
16use parking_lot::RwLock;
17
18use crate::error::{Error, Result};
19use crate::source::ByteSource;
20
21#[derive(Debug)]
22pub struct LocalSource {
23    /// `None` once closed. `RwLock` rather than `Mutex`: reads take it shared,
24    /// so the positioned reads stay concurrent, and only `close` takes it
25    /// exclusively.
26    file: RwLock<Option<File>>,
27    path: String,
28    /// Snapshotted at open. These are files being read, not tailed, and a
29    /// length that cannot change is what lets `read_at` size its buffer to what
30    /// is actually there rather than to what was asked for.
31    len: u64,
32}
33
34impl LocalSource {
35    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
36        let path = path.as_ref();
37        let display = path.to_string_lossy().into_owned();
38        let file = File::open(path).map_err(|e| Error::io(&display, e))?;
39        let len = file.metadata().map_err(|e| Error::io(&display, e))?.len();
40        Ok(Self {
41            file: RwLock::new(Some(file)),
42            path: display,
43            len,
44        })
45    }
46}
47
48impl ByteSource for LocalSource {
49    fn path(&self) -> &str {
50        &self.path
51    }
52
53    fn len(&self) -> Result<u64> {
54        Ok(self.len)
55    }
56
57    fn read_at(&self, offset: u64, len: usize) -> Result<Bytes> {
58        if len == 0 || offset >= self.len {
59            return Ok(Bytes::new());
60        }
61        // Clamp to what the file holds, so a caller asking for the rest of a
62        // 2 GB file does not get a 2 GB allocation for the 4 KB that are left.
63        let len = len.min((self.len - offset) as usize);
64
65        let guard = self.file.read();
66        let file = guard.as_ref().ok_or_else(|| Error::Closed {
67            path: self.path.clone(),
68        })?;
69
70        let mut buf = vec![0u8; len];
71        let read = pread(file, offset, &mut buf).map_err(|e| Error::io(&self.path, e))?;
72        buf.truncate(read);
73        Ok(Bytes::from(buf))
74    }
75
76    fn close(&self) {
77        *self.file.write() = None;
78    }
79}
80
81/// Positioned read, looping until the buffer is full or the file ends.
82///
83/// One `read_at`/`seek_read` may return short for reasons that are not EOF — a
84/// signal, a large request — so the loop is not optional. `Ok(0)` is the only
85/// end-of-file signal.
86#[cfg(unix)]
87fn pread(file: &File, offset: u64, buf: &mut [u8]) -> std::io::Result<usize> {
88    use std::os::unix::fs::FileExt;
89    let mut filled = 0;
90    while filled < buf.len() {
91        match file.read_at(&mut buf[filled..], offset + filled as u64) {
92            Ok(0) => break,
93            Ok(n) => filled += n,
94            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
95            Err(e) => return Err(e),
96        }
97    }
98    Ok(filled)
99}
100
101#[cfg(windows)]
102fn pread(file: &File, offset: u64, buf: &mut [u8]) -> std::io::Result<usize> {
103    use std::os::windows::fs::FileExt;
104    let mut filled = 0;
105    while filled < buf.len() {
106        match file.seek_read(&mut buf[filled..], offset + filled as u64) {
107            Ok(0) => break,
108            Ok(n) => filled += n,
109            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
110            Err(e) => return Err(e),
111        }
112    }
113    Ok(filled)
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use std::io::Write;
120
121    fn fixture(bytes: &[u8]) -> (tempdir::TempPath, LocalSource) {
122        let path = tempdir::TempPath::new("local-source");
123        let mut f = File::create(&path.0).unwrap();
124        f.write_all(bytes).unwrap();
125        f.sync_all().unwrap();
126        let source = LocalSource::open(&path.0).unwrap();
127        (path, source)
128    }
129
130    /// A temp file that removes itself. Not worth a dependency for one test
131    /// module.
132    pub mod tempdir {
133        pub struct TempPath(pub std::path::PathBuf);
134
135        impl TempPath {
136            pub fn new(tag: &str) -> Self {
137                let nanos = std::time::SystemTime::now()
138                    .duration_since(std::time::UNIX_EPOCH)
139                    .unwrap()
140                    .as_nanos();
141                let mut p = std::env::temp_dir();
142                p.push(format!(
143                    "gwseq-{tag}-{nanos}-{:?}",
144                    std::thread::current().id()
145                ));
146                TempPath(p)
147            }
148        }
149
150        impl Drop for TempPath {
151            fn drop(&mut self) {
152                let _ = std::fs::remove_file(&self.0);
153            }
154        }
155    }
156
157    #[test]
158    fn reads_every_range_of_a_known_file() {
159        let data: Vec<u8> = (0..=255u8).collect();
160        let (_p, source) = fixture(&data);
161        assert_eq!(source.len().unwrap(), 256);
162        for offset in [0u64, 1, 127, 255] {
163            for len in [1usize, 2, 64, 300] {
164                let got = source.read_at(offset, len).unwrap();
165                let end = (offset as usize + len).min(data.len());
166                assert_eq!(&got[..], &data[offset as usize..end], "at {offset}+{len}");
167            }
168        }
169    }
170
171    #[test]
172    fn a_read_past_the_end_is_short_not_an_error() {
173        let (_p, source) = fixture(b"abcdef");
174        assert_eq!(&source.read_at(4, 100).unwrap()[..], b"ef");
175        assert!(source.read_at(6, 10).unwrap().is_empty());
176        assert!(source.read_at(99, 10).unwrap().is_empty());
177        assert!(source.read_at(0, 0).unwrap().is_empty());
178    }
179
180    #[test]
181    fn read_exact_at_refuses_a_short_read() {
182        let (_p, source) = fixture(b"abcdef");
183        assert_eq!(&source.read_exact_at(0, 6).unwrap()[..], b"abcdef");
184        let err = source.read_exact_at(4, 100).unwrap_err().to_string();
185        assert!(err.contains("wanted 100 bytes, got 2"), "{err}");
186    }
187
188    #[test]
189    fn read_to_end_takes_the_rest() {
190        let (_p, source) = fixture(b"abcdef");
191        assert_eq!(&source.read_to_end(2).unwrap()[..], b"cdef");
192        assert!(source.read_to_end(6).unwrap().is_empty());
193    }
194
195    #[test]
196    fn a_closed_source_refuses_and_names_the_file() {
197        let (path, source) = fixture(b"abcdef");
198        source.close();
199        source.close(); // idempotent
200        let err = source.read_at(0, 1).unwrap_err().to_string();
201        assert!(err.contains("is closed"), "{err}");
202        // The point of naming it: a reader closed by mistake in a program
203        // holding several says which one.
204        assert!(err.contains(path.0.to_str().unwrap()), "{err}");
205    }
206
207    #[test]
208    fn concurrent_readers_share_one_handle() {
209        let data: Vec<u8> = (0..64u8).cycle().take(1 << 16).collect();
210        let (_p, source) = fixture(&data);
211        let source = std::sync::Arc::new(source);
212        let threads: Vec<_> = (0..8)
213            .map(|t| {
214                let source = source.clone();
215                let data = data.clone();
216                std::thread::spawn(move || {
217                    for i in 0..500u64 {
218                        let offset = (t * 977 + i * 13) % 60_000;
219                        let got = source.read_at(offset, 128).unwrap();
220                        let end = (offset as usize + 128).min(data.len());
221                        assert_eq!(&got[..], &data[offset as usize..end]);
222                    }
223                })
224            })
225            .collect();
226        for t in threads {
227            t.join().unwrap();
228        }
229    }
230
231    #[test]
232    fn opening_a_missing_file_names_it() {
233        let err = LocalSource::open("/definitely/not/here.bigwig").unwrap_err();
234        assert!(matches!(err, Error::Io { .. }));
235        assert!(err.to_string().contains("here.bigwig"));
236    }
237}