pub struct FileDevice { /* private fields */ }Expand description
A file opened as a block device.
§Readers share the lock; writers take it alone
A read was once seek then read under a plain mutex, which made
the file’s cursor shared state: two threads reading different offsets
had to take turns, not because the device could not serve them at
once but because one would have moved the other’s cursor.
On Unix the cursor is not involved at all — pread takes the offset
as an argument — so readers hold the lock shared and genuinely
overlap. On Windows the equivalent (seek_read) does move the file
pointer, so readers there take it exclusively and only that platform
pays for the cursor.
Writers take it exclusively on both, because write_at is seek
plus write_all and neither another writer’s seek nor a reader may
land in the middle of it.
§THE LOCK IS NOT AN OPTIMISATION, IT IS THE READ/WRITE CONTRACT
Reads briefly took no lock at all, which read as a natural
consequence of positioned reads needing no cursor. It was not: it
silently dropped the exclusion between readers and writers that the
single mutex had provided, so a read overlapping a write_at could
observe part of it. write_all is permitted to become several
write calls, and a read is a loop of positioned reads — either
split is a window, and the second one does not need the first.
So a reader holds the lock for the whole of FileDevice::read_at,
not for each positioned read inside it. Per-read guards would leave
exactly the same hole one level down, and a rarer tear is worse than
a common one because nobody can reproduce it.
§A known limit, stated rather than fixed
std::sync::RwLock does not promise writer preference on every
platform, and the read path here is the hot one. A device under
sustained parallel reads can therefore make a writer wait longer than
a fair queue would. That is a throughput property, not a correctness
one, and it is left alone rather than solved with a hand-rolled queue
nobody would be able to audit.
Implementations§
Trait Implementations§
Source§impl BlockDevice for FileDevice
impl BlockDevice for FileDevice
Source§fn write_at(&self, offset: u64, buf: &[u8]) -> Result<()>
fn write_at(&self, offset: u64, buf: &[u8]) -> Result<()>
A write past the end is refused, not an extension.
write_all at a seeked offset EXTENDS a file, and this method
had no bound of its own, so a write straddling the end grew the
backing store while size_bytes went on reporting the length
taken at construction – measured on a 4096-byte file:
write_at(4094, 8 bytes) returned Ok, the file became 4102
bytes, size_bytes() stayed 4096, and read_at(4096, 6) then
handed those bytes back. The two halves of one device disagreed
about where it ended, and a caller bounding its reads by
size_bytes – which is what crate::CachingDevice does,
clamping every block it fetches – could never reach them.
RwBytes in this crate’s own test devices already refuses the
same operation, commenting “a device is not a Vec”, and the
slice adapters in crate::slice clamp their window
specifically because this method did not:
slice_rw_length_is_clamped_and_a_write_past_it_does_not_grow_the_image
names the file’s length on disk as its oracle. This is the same
rule one layer down, where it was missing.
The alternative – letting the size move and reopening – is
what BlockRead::size_bytes’s contract forbids. See
rust-fs-core#70.
Source§fn set_len(&self, new_len: u64) -> Result<()>
fn set_len(&self, new_len: u64) -> Result<()>
Set the file’s length, and the length this device reports, as one operation.
§THE POINT IS THAT THE TWO MOVE TOGETHER
#75 refused a write past the end because write_all at a seeked
offset grew the FILE while size_bytes went on reporting the
length taken at construction, so the two halves of one device
disagreed about where it ended and a caller bounding its reads by
size_bytes could never reach what it had written
(rust-fs-core#70). That bound stays. This method is the other
half: growth that says so, and moves the number with it.
An implementation that called File::set_len and left self.size
alone would be #70 with a nicer name on it.
§THE NARROWER LENGTH IS PUBLISHED FIRST, IN BOTH DIRECTIONS
ftruncate and the store are two steps, and one of the two
orderings has a window in it. Take a shrink done store-then-
publish: between the truncate and the store, this device declares
8192 bytes over a 4096-byte file, so a concurrent read inside the
declared device falls off the end of the real one and comes back
ShortRead. The other direction is harmless — a device that
briefly declares 4096 bytes over an 8192-byte file is only
under-reporting, which is the state every FileDevice is in
whenever something else appends to its file.
So: a GROW truncates and then stores, and a SHRINK stores and then truncates. The invariant is one sentence — THE DECLARED SIZE NEVER EXCEEDS THE FILE’S REAL LENGTH — and it holds at every instant rather than only at the ends.
§io_lock EXCLUSIVELY, LIKE A WRITE
For the same reason write_at and flush take it: this changes
the file underneath every reader, and a read must not observe
half of it. size_bytes deliberately does NOT take the lock —
see the field — so the ordering above is what keeps a reader that
asked the size mid-call from being misled, not the lock.
§WHAT IT REFUSES
Error::ReadOnly on a handle opened with FileDevice::open,
before touching the file. Error::Custom on a handle that is
writable but not a regular file — a block device node, whose
length belongs to the kernel — naming that reason rather than
letting ftruncate’s EINVAL stand in for it. See
FileDevice::can_grow, which is the question to ask instead of
discovering either of these.
Source§fn is_writable(&self) -> bool
fn is_writable(&self) -> bool
write_at is likely to succeed. Mount paths use this to
decide whether to attempt journal replay or stay strict-read-only.