718 lines
30 KiB
Rust
718 lines
30 KiB
Rust
//! The single funnel for every durability-relevant syscall.
|
|
//!
|
|
//! Lead-owned (scope 2.3). Nothing else in this crate may call
|
|
//! `File::sync_all`, `File::sync_data`, `std::fs::rename`, `remove_file`, or
|
|
//! open a directory descriptor. Two reasons:
|
|
//!
|
|
//! 1. `DurabilityCounters` turns "exactly one fence per group" and "no
|
|
//! per-object fsync" into assertions against observed counts rather than
|
|
//! claims about the code (Phase 1 exit criteria).
|
|
//! 2. Fault injection (short write, `ENOSPC`, `EIO` on fence, `EIO` on read)
|
|
//! has exactly one place to live, so the crash matrix runs unprivileged in
|
|
//! CI without `dm-flakey`.
|
|
//!
|
|
//! The `check-phase1.sh` gate greps the crate for direct calls that bypass
|
|
//! this module.
|
|
|
|
use std::fs::File;
|
|
use std::io::{self, IoSlice, Seek, SeekFrom, Write};
|
|
use std::path::Path;
|
|
use std::sync::atomic::Ordering::Relaxed;
|
|
|
|
use crate::types::DurabilityCounters;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fault injection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A fault the harness can arm on the next matching call.
|
|
///
|
|
/// Deliberately not a general-purpose filesystem simulator: these are exactly
|
|
/// the physical failures plan §10's matrix requires ("short writes, fsync
|
|
/// errors, ENOSPC, corrupt index, corrupt segment, torn tail").
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
|
pub enum Fault {
|
|
/// Write only `prefix_bytes` of the requested vector and return `Ok`. A
|
|
/// short write is not an error; it leaves a durable prefix, which is why
|
|
/// `write_vectored_all` loops and why frame completeness — not a return
|
|
/// value — decides an outcome (scope 3.7).
|
|
ShortWrite { prefix_bytes: usize },
|
|
/// Fail the write with `ENOSPC`.
|
|
NoSpace,
|
|
/// Fail the fence with `EIO`. On Linux this may be reported exactly once,
|
|
/// after which the kernel has already dropped the dirty pages, so a retry
|
|
/// can return success while the data is permanently gone. The shard must
|
|
/// poison and must never retry (scope 3.7).
|
|
FenceEio,
|
|
/// Fail a positioned read with `EIO`, modelling a device that lost a write
|
|
/// it acknowledged. Live on the frozen profile because
|
|
/// `power_loss_protection = false` (scope 3.8).
|
|
///
|
|
/// Unscoped: consumed by whichever positioned read runs first. That is
|
|
/// almost never the one a test means, because reopening a shard reads
|
|
/// `FORMAT`, the manifest, and the journal header before it reaches the
|
|
/// tail — and an `EIO` on those is legitimately fatal. Use
|
|
/// [`Fault::ReadEioAtOrAfter`] to target the tail.
|
|
ReadEio,
|
|
/// Fail a positioned read with `EIO`, but only at or beyond `offset`.
|
|
///
|
|
/// This exists because the unscoped variant cannot express scope 3.8 step
|
|
/// 5's central claim — that an `EIO` *in the tail region* ends the tail
|
|
/// rather than failing the open. A3 found this by writing that test and
|
|
/// having it fail for the wrong reason: the first `pread` of a reopen ate
|
|
/// the fault. Scoping by offset is the smallest thing that makes the
|
|
/// intended state reachable, and it keeps the distinction the rule turns
|
|
/// on — early read failures stay fatal, tail read failures do not.
|
|
ReadEioAtOrAfter { offset: u64 },
|
|
/// Fail a directory fsync.
|
|
DirSyncEio,
|
|
/// Leave the write cursor somewhere other than where the caller expects,
|
|
/// without failing the write.
|
|
///
|
|
/// Plan §5.2 names "unexpected file position" as a poisoning condition and
|
|
/// scope 4-A3 requires it be injectable, but no other fault produces it:
|
|
/// `ShortWrite` returns early with a durable prefix and a *consistent*
|
|
/// cursor, which is the correct modelling of a short write and therefore
|
|
/// cannot also model this. Seeks the descriptor after a successful write so
|
|
/// the post-append position check fails.
|
|
CursorSkew { delta: i64 },
|
|
}
|
|
|
|
#[cfg(feature = "failpoints")]
|
|
mod fault_state {
|
|
use super::Fault;
|
|
use std::sync::Mutex;
|
|
|
|
static ARMED: Mutex<Option<Fault>> = Mutex::new(None);
|
|
static SERIAL: Mutex<()> = Mutex::new(());
|
|
|
|
/// Proof that the holder has exclusive use of the fault registry.
|
|
///
|
|
/// The registry is one process-global slot, so two tests running
|
|
/// concurrently in the same binary contend for it — and the contention is
|
|
/// invisible, because the loser does not fail, it merely has someone
|
|
/// else's fault delivered or its own consumed by an unrelated read. That
|
|
/// produced a 1-in-5 failure rate in `recovery_eio.rs` that read as
|
|
/// flakiness for as long as it went unexplained.
|
|
///
|
|
/// Requiring this token in [`arm`] is what makes the invariant hold. A
|
|
/// lock a test *may* take is a lock a new test will not take: the file
|
|
/// where this was first diagnosed carried a header saying it was
|
|
/// deliberately the only test in it, and a second test had been added
|
|
/// under that comment anyway. A comment cannot fail a build; a parameter
|
|
/// can.
|
|
///
|
|
/// Hold it for the **whole test body**, not just across `arm`. The
|
|
/// original defect was a clean `scan_journal` performed *before* arming:
|
|
/// that scan is itself a funnel read, so it consumed the fault another
|
|
/// test had armed. Any funnel traffic outside the lock can do this.
|
|
pub struct FaultSerial(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
|
|
|
|
/// Take exclusive use of the fault registry, clearing anything left armed.
|
|
///
|
|
/// Recovers from poisoning rather than propagating it: a test that panics
|
|
/// while holding this has already reported its own failure, and turning
|
|
/// that into a cascade of poison errors in every later test would bury the
|
|
/// real one. Clearing on acquire is the other half — a panicking test
|
|
/// cannot run its own cleanup, so without this its armed fault would be
|
|
/// delivered to whichever test ran next.
|
|
pub fn serial() -> FaultSerial {
|
|
let guard = SERIAL
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
*ARMED
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
|
|
FaultSerial(guard)
|
|
}
|
|
|
|
pub fn arm(_serial: &FaultSerial, fault: Fault) {
|
|
*ARMED.lock().expect("fault mutex") = Some(fault);
|
|
}
|
|
|
|
pub fn disarm(_serial: &FaultSerial) {
|
|
*ARMED.lock().expect("fault mutex") = None;
|
|
}
|
|
|
|
/// Consume the armed fault only if this call site is the one it targets.
|
|
///
|
|
/// One-shot, but *selective*. An unconditional take was a real bug: the
|
|
/// first `write_vectored_all` of a group consumed a fault armed for the
|
|
/// fence, so `Fault::FenceEio`, `ReadEio`, and `DirSyncEio` could never be
|
|
/// delivered at all — the whole `EIO`-on-fdatasync half of the fault
|
|
/// matrix was unreachable through this seam. Every site now takes only its
|
|
/// own faults, so arming is genuinely positional.
|
|
pub fn take_if(matches: impl Fn(&Fault) -> bool) -> Option<Fault> {
|
|
let mut armed = ARMED.lock().expect("fault mutex");
|
|
match armed.as_ref() {
|
|
Some(fault) if matches(fault) => armed.take(),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reached by A3 through `drive::faults` rather than by making `sys` public;
|
|
// the module stays private so the funnel invariant holds.
|
|
#[cfg(feature = "failpoints")]
|
|
pub use fault_state::{arm, disarm, serial, FaultSerial};
|
|
|
|
#[cfg(feature = "failpoints")]
|
|
#[inline]
|
|
fn take_fault_if(matches: impl Fn(&Fault) -> bool) -> Option<Fault> {
|
|
fault_state::take_if(matches)
|
|
}
|
|
|
|
#[cfg(not(feature = "failpoints"))]
|
|
#[inline(always)]
|
|
fn take_fault_if(_matches: impl Fn(&Fault) -> bool) -> Option<Fault> {
|
|
None
|
|
}
|
|
|
|
fn eio(what: &'static str) -> io::Error {
|
|
io::Error::new(io::ErrorKind::Other, format!("injected EIO during {what}"))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Write path
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Append a frame group at the file's current position, looping over short
|
|
/// writes, and verify the resulting position.
|
|
///
|
|
/// The caller owns the descriptor exclusively (one shard thread per shard),
|
|
/// so the file position is the write cursor. Plan §5.2 names "unexpected file
|
|
/// position" as a poisoning condition; that check lives here.
|
|
pub(crate) fn write_vectored_all(
|
|
file: &mut File,
|
|
bufs: &[IoSlice<'_>],
|
|
counters: &DurabilityCounters,
|
|
) -> io::Result<u64> {
|
|
let total: usize = bufs.iter().map(|b| b.len()).sum();
|
|
let start = file.stream_position()?;
|
|
|
|
// Flatten once. `write_vectored` may consume a partial slice, and
|
|
// re-slicing `IoSlice`s across a partial consume is where short-write
|
|
// handling usually goes wrong; a single owned buffer makes the loop
|
|
// obviously correct at the cost of one copy. A1 may replace this with
|
|
// proper `IoSlice` advancing once the golden vectors pin the bytes.
|
|
let mut flat = Vec::with_capacity(total);
|
|
for buf in bufs {
|
|
flat.extend_from_slice(buf);
|
|
}
|
|
|
|
// Only write-path faults. A fault armed for the fence, a read, or a
|
|
// directory sync must survive this call untouched.
|
|
let mut fault = take_fault_if(|f| {
|
|
matches!(
|
|
f,
|
|
Fault::ShortWrite { .. } | Fault::NoSpace | Fault::CursorSkew { .. }
|
|
)
|
|
});
|
|
let mut written = 0usize;
|
|
while written < flat.len() {
|
|
match fault.take() {
|
|
Some(Fault::NoSpace) => {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::StorageFull,
|
|
"injected ENOSPC during journal append",
|
|
))
|
|
}
|
|
Some(Fault::ShortWrite { prefix_bytes }) => {
|
|
let n = prefix_bytes.min(flat.len() - written);
|
|
let n = file.write(&flat[written..written + n])?;
|
|
written += n;
|
|
counters.write_vectored.fetch_add(1, Relaxed);
|
|
counters.short_writes.fetch_add(1, Relaxed);
|
|
counters.bytes_written.fetch_add(n as u64, Relaxed);
|
|
// The injected short write stands: return with a durable
|
|
// prefix and no error, exactly as the device may behave.
|
|
return Ok(start + written as u64);
|
|
}
|
|
Some(Fault::CursorSkew { delta }) => {
|
|
// The write succeeds; only the resulting position is wrong, so
|
|
// the post-append check below is what must catch it.
|
|
let n = file.write(&flat[written..])?;
|
|
counters.write_vectored.fetch_add(1, Relaxed);
|
|
counters.bytes_written.fetch_add(n as u64, Relaxed);
|
|
written += n;
|
|
let here = file.stream_position()?;
|
|
let moved = here as i64 + delta;
|
|
file.seek(SeekFrom::Start(moved.max(0) as u64))?;
|
|
}
|
|
other => {
|
|
fault = other;
|
|
let n = file.write(&flat[written..])?;
|
|
counters.write_vectored.fetch_add(1, Relaxed);
|
|
counters.bytes_written.fetch_add(n as u64, Relaxed);
|
|
if n == 0 {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::WriteZero,
|
|
"journal append made no progress",
|
|
));
|
|
}
|
|
if n < flat.len() - written {
|
|
counters.short_writes.fetch_add(1, Relaxed);
|
|
}
|
|
written += n;
|
|
}
|
|
}
|
|
}
|
|
|
|
let end = file.stream_position()?;
|
|
let expected = start + total as u64;
|
|
if end != expected {
|
|
return Err(io::Error::other(format!(
|
|
"unexpected file position after append: expected {expected}, observed {end}"
|
|
)));
|
|
}
|
|
Ok(end)
|
|
}
|
|
|
|
/// The durability fence. Exactly one per group.
|
|
///
|
|
/// `File::sync_data` is `fdatasync(2)` on Linux. A failure here is terminal
|
|
/// for the shard and must never be retried; see `Fault::FenceEio`.
|
|
pub(crate) fn fdatasync(file: &File, counters: &DurabilityCounters) -> io::Result<()> {
|
|
if take_fault_if(|f| matches!(f, Fault::FenceEio)).is_some() {
|
|
counters.fdatasync.fetch_add(1, Relaxed);
|
|
return Err(eio("fdatasync"));
|
|
}
|
|
counters.fdatasync.fetch_add(1, Relaxed);
|
|
file.sync_data()
|
|
}
|
|
|
|
/// Make a directory entry durable. Required after every create, rename, and
|
|
/// unlink that recovery depends on.
|
|
pub(crate) fn fsync_dir(path: &Path, counters: &DurabilityCounters) -> io::Result<()> {
|
|
if take_fault_if(|f| matches!(f, Fault::DirSyncEio)).is_some() {
|
|
counters.fsync_dir.fetch_add(1, Relaxed);
|
|
return Err(eio("directory fsync"));
|
|
}
|
|
counters.fsync_dir.fetch_add(1, Relaxed);
|
|
File::open(path)?.sync_all()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Naming
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// `renameat2(RENAME_NOREPLACE)`. For every name that must never overwrite:
|
|
/// new manifests, segments, checkpoints, index runs, and migration/restore
|
|
/// siblings. Plan §9 forbids a copy fallback.
|
|
pub(crate) fn rename_noreplace(from: &Path, to: &Path) -> io::Result<()> {
|
|
rustix::fs::renameat_with(
|
|
rustix::fs::CWD,
|
|
from,
|
|
rustix::fs::CWD,
|
|
to,
|
|
rustix::fs::RenameFlags::NOREPLACE,
|
|
)
|
|
.map_err(|e| io::Error::from_raw_os_error(e.raw_os_error()))
|
|
}
|
|
|
|
/// Plain atomic replacing rename. Exactly one legitimate call site: the
|
|
/// `CURRENT.tmp` -> `CURRENT` pointer install of scope 3.5, which by
|
|
/// definition replaces. Every other site uses `rename_noreplace`.
|
|
pub(crate) fn rename_replace(from: &Path, to: &Path) -> io::Result<()> {
|
|
std::fs::rename(from, to)
|
|
}
|
|
|
|
/// Hard-link an existing file under a second name, refusing to overwrite.
|
|
///
|
|
/// Sealing uses this instead of a rename (scope 3.4). A rename would make the
|
|
/// journal reachable under exactly one name at every instant, so a crash after
|
|
/// the rename and before the manifest install would leave a fenced,
|
|
/// acknowledged prefix in a segment that no manifest references and with no
|
|
/// active journal to scan — losing acknowledged data. Linking keeps the file
|
|
/// reachable under *both* names across the window; the active name is unlinked
|
|
/// only after the manifest that references the segment name is durable.
|
|
pub(crate) fn link_noreplace(existing: &Path, new_name: &Path) -> io::Result<()> {
|
|
// `link(2)` never replaces an existing destination, so the no-replace
|
|
// property is inherent rather than a flag.
|
|
std::fs::hard_link(existing, new_name)
|
|
}
|
|
|
|
pub(crate) fn unlink(path: &Path) -> io::Result<()> {
|
|
std::fs::remove_file(path)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Opening a name this process does not yet own
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// These exist for the paths where the store has to look at, and then write
|
|
// into, a directory it has not established is its own — startup state 1, and
|
|
// `lock_root` on every path. Plain `File::open` and
|
|
// `File::options().create(true)` both traverse a symlink at the final
|
|
// component, so either one at a name an operator can occupy is a write to a
|
|
// path outside the root. `open`/`stat` are therefore not available on those
|
|
// paths at all; these are.
|
|
|
|
/// Open `path` read-only for *verification*, refusing to traverse a symlink at
|
|
/// the final component and refusing anything that is not a regular file.
|
|
///
|
|
/// `Ok(None)` means "there is no regular file at exactly this path": absent, a
|
|
/// symlink, a directory, a fifo, a socket, a device. Every caller is asking
|
|
/// whether the bytes at a name are its own, and for all of those the answer is
|
|
/// no — so they collapse into one variant rather than being distinguished by a
|
|
/// caller that would treat them identically.
|
|
///
|
|
/// `O_NOFOLLOW` is what makes the symlink case an error rather than a read of
|
|
/// somebody else's file, and the `fstat` is what makes it a *regular file*
|
|
/// rather than a name that merely opened: `O_NOFOLLOW` says nothing about
|
|
/// directories or fifos. The type is read from the descriptor already opened,
|
|
/// not from a second path lookup, so the answer is about the object this call
|
|
/// holds and cannot be changed underneath it.
|
|
///
|
|
/// `O_NONBLOCK` is load-bearing rather than incidental. Opening a fifo for
|
|
/// reading blocks until a writer arrives, so without it a fifo left in a
|
|
/// configured root would hang startup indefinitely — a denial of service
|
|
/// reached by `mkfifo`, and one this function exists to be immune to because
|
|
/// its whole job is to look at names it does not trust.
|
|
pub(crate) fn open_regular_nofollow(path: &Path) -> io::Result<Option<File>> {
|
|
use rustix::fs::{FileType, Mode, OFlags};
|
|
let fd = match rustix::fs::open(
|
|
path,
|
|
OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK,
|
|
Mode::empty(),
|
|
) {
|
|
Ok(fd) => fd,
|
|
Err(rustix::io::Errno::NOENT) | Err(rustix::io::Errno::NOTDIR) => return Ok(None),
|
|
// `O_NOFOLLOW` on a symlink is `ELOOP` on Linux and `EMLINK` on some
|
|
// BSDs. Both mean "the final component is a symlink", which is exactly
|
|
// the answer this function is being asked for.
|
|
Err(rustix::io::Errno::LOOP) | Err(rustix::io::Errno::MLINK) => return Ok(None),
|
|
Err(e) => return Err(io::Error::from_raw_os_error(e.raw_os_error())),
|
|
};
|
|
let file = File::from(fd);
|
|
let stat =
|
|
rustix::fs::fstat(&file).map_err(|e| io::Error::from_raw_os_error(e.raw_os_error()))?;
|
|
if FileType::from_raw_mode(stat.st_mode) == FileType::RegularFile {
|
|
Ok(Some(file))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
/// Create `path` as a **new** regular file, or report that the name is taken.
|
|
///
|
|
/// `Ok(None)` means the name already exists — as anything at all, including a
|
|
/// symlink. `O_CREAT | O_EXCL` is specified to fail with `EEXIST` on a symlink
|
|
/// whether or not the link resolves, so this can never create or truncate a
|
|
/// file outside the directory it names; `O_NOFOLLOW` is passed as well so the
|
|
/// intent survives someone later relaxing the `O_EXCL`.
|
|
///
|
|
/// This is the only way anything in this crate may bring a new name into a
|
|
/// directory the store has not yet established is its own. `create(true)` plus
|
|
/// `truncate(true)` is the operation it replaces, and the difference is that
|
|
/// this one cannot destroy a byte it did not write.
|
|
pub(crate) fn create_new_nofollow(path: &Path) -> io::Result<Option<File>> {
|
|
use rustix::fs::{Mode, OFlags};
|
|
match rustix::fs::open(
|
|
path,
|
|
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
|
Mode::from_raw_mode(0o644),
|
|
) {
|
|
Ok(fd) => Ok(Some(File::from(fd))),
|
|
Err(rustix::io::Errno::EXIST) => Ok(None),
|
|
Err(e) => Err(io::Error::from_raw_os_error(e.raw_os_error())),
|
|
}
|
|
}
|
|
|
|
/// Open `path` read-write, creating it if absent, refusing to traverse a
|
|
/// symlink at the final component and refusing anything that is not a regular
|
|
/// file. Never truncates.
|
|
///
|
|
/// `Ok(None)` means "the name is occupied by something that is not a regular
|
|
/// file": a symlink, a directory, a fifo, a socket, a device. Absent is not one
|
|
/// of them — absent is the create case, and it returns the new file.
|
|
///
|
|
/// This is the shape [`open_regular_nofollow`] and [`create_new_nofollow`] do
|
|
/// not cover: a name the store must *own and keep*, where an existing file is
|
|
/// adopted rather than replaced and an absent one is brought into being. The
|
|
/// only caller is `segment::lock_root`, which needs exactly this because
|
|
/// `<root>/LOCK` is created as a side effect of asking whether a root is busy.
|
|
///
|
|
/// # Why the type check is not cosmetic here
|
|
///
|
|
/// `flock` locks the *inode*, not the name. A symlink at `LOCK` that a
|
|
/// follow-through open traverses therefore takes the root lock on a foreign
|
|
/// inode, and the root's own lock file is left unlocked — so a second process
|
|
/// arriving at the same root takes it too, and both hold what each believes is
|
|
/// exclusive ownership. That defeats the exclusion scope 3.1 is built on, which
|
|
/// no amount of care at the `flock` call itself can restore. `O_NOFOLLOW` plus
|
|
/// this `fstat` is what makes the locked inode provably the one the caller
|
|
/// named.
|
|
///
|
|
/// One `O_CREAT` open, not an `O_EXCL` create followed by a plain open on
|
|
/// `EEXIST`: the single call has no window between deciding the name is taken
|
|
/// and opening what is there, so there is nothing for an adversary replacing
|
|
/// the name to land in.
|
|
///
|
|
/// # What this still permits
|
|
///
|
|
/// A device node at the name receives one `open(2)` before the `fstat` refuses
|
|
/// it. `O_NONBLOCK` is passed so that open cannot hang — the fifo case is a
|
|
/// startup denial of service otherwise — but a driver that acts on being opened
|
|
/// has still been opened. Closing that would need `O_PATH` for classification
|
|
/// and a re-open of the same inode, which is more machinery than the hazard
|
|
/// warrants: reaching it requires the privilege to `mknod` inside a configured
|
|
/// store root. Recorded rather than silently accepted.
|
|
pub(crate) fn open_or_create_regular_nofollow(path: &Path) -> io::Result<Option<File>> {
|
|
use rustix::fs::{FileType, Mode, OFlags};
|
|
let fd = match rustix::fs::open(
|
|
path,
|
|
OFlags::RDWR | OFlags::CREATE | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK,
|
|
Mode::from_raw_mode(0o644),
|
|
) {
|
|
Ok(fd) => fd,
|
|
// A symlink at the final component: `ELOOP` on Linux, `EMLINK` on some
|
|
// BSDs. `O_CREAT` through a *dangling* link is the same refusal, which
|
|
// is what stops this call from creating a file outside the root.
|
|
Err(rustix::io::Errno::LOOP) | Err(rustix::io::Errno::MLINK) => return Ok(None),
|
|
// A directory refuses `O_RDWR` before any type check runs.
|
|
Err(rustix::io::Errno::ISDIR) => return Ok(None),
|
|
Err(e) => return Err(io::Error::from_raw_os_error(e.raw_os_error())),
|
|
};
|
|
let file = File::from(fd);
|
|
let stat =
|
|
rustix::fs::fstat(&file).map_err(|e| io::Error::from_raw_os_error(e.raw_os_error()))?;
|
|
if FileType::from_raw_mode(stat.st_mode) == FileType::RegularFile {
|
|
Ok(Some(file))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
/// Truncate a file to `len`.
|
|
///
|
|
/// In the funnel because it changes what is durable. `journal.rs` was calling
|
|
/// `File::set_len` directly, which the original guard did not scan for — a
|
|
/// truncation is a durability-relevant mutation as surely as a write, and one
|
|
/// that recovery reasons about directly.
|
|
pub(crate) fn truncate(file: &File, len: u64, counters: &DurabilityCounters) -> io::Result<()> {
|
|
if take_fault_if(|f| matches!(f, Fault::NoSpace)).is_some() {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::StorageFull,
|
|
"injected ENOSPC during truncate",
|
|
));
|
|
}
|
|
counters.write_vectored.fetch_add(1, Relaxed);
|
|
file.set_len(len)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Read path and allocation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Whether an armed read fault applies to a read at `offset`.
|
|
///
|
|
/// An unscoped `ReadEio` fires anywhere; a scoped one fires only at or beyond
|
|
/// its offset and is left armed until a read actually reaches that far, so a
|
|
/// reopen's early metadata reads cannot consume a fault aimed at the tail.
|
|
fn read_fault_fires(offset: u64) -> bool {
|
|
take_fault_if(|f| match f {
|
|
Fault::ReadEio => true,
|
|
Fault::ReadEioAtOrAfter { offset: at } => offset >= *at,
|
|
Fault::ShortWrite { .. }
|
|
| Fault::NoSpace
|
|
| Fault::FenceEio
|
|
| Fault::DirSyncEio
|
|
| Fault::CursorSkew { .. } => false,
|
|
})
|
|
.is_some()
|
|
}
|
|
|
|
/// Bounded positioned read. Segment and journal readers use only this.
|
|
pub(crate) fn pread_exact(file: &File, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
|
if read_fault_fires(offset) {
|
|
return Err(eio("positioned read"));
|
|
}
|
|
use std::os::unix::fs::FileExt;
|
|
file.read_exact_at(buf, offset)
|
|
}
|
|
|
|
/// Positioned read that reports a short read rather than failing, so a tail
|
|
/// scanner can distinguish "the file ends here" from an I/O error.
|
|
pub(crate) fn pread(file: &File, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
|
|
if read_fault_fires(offset) {
|
|
return Err(eio("positioned read"));
|
|
}
|
|
use std::os::unix::fs::FileExt;
|
|
file.read_at(buf, offset)
|
|
}
|
|
|
|
/// Preallocate without `FALLOC_FL_KEEP_SIZE`, so the file has its full size
|
|
/// with unwritten extents and appends become overwrites within an already
|
|
/// sized file (scope 3.2).
|
|
pub(crate) fn preallocate(file: &File, len: u64) -> io::Result<()> {
|
|
rustix::fs::fallocate(file, rustix::fs::FallocateFlags::empty(), 0, len)
|
|
.map_err(|e| io::Error::from_raw_os_error(e.raw_os_error()))
|
|
}
|
|
|
|
/// Non-blocking exclusive lock for `<root>/LOCK`. Failure is refusal, never a
|
|
/// wait (scope 3.1).
|
|
pub(crate) fn try_lock_exclusive(file: &File) -> io::Result<bool> {
|
|
match rustix::fs::flock(file, rustix::fs::FlockOperation::NonBlockingLockExclusive) {
|
|
Ok(()) => Ok(true),
|
|
Err(rustix::io::Errno::WOULDBLOCK) => Ok(false),
|
|
Err(e) => Err(io::Error::from_raw_os_error(e.raw_os_error())),
|
|
}
|
|
}
|
|
|
|
/// Release a `flock` held on `file`'s **open file description**.
|
|
///
|
|
/// This is not redundant with closing the descriptor. `flock` is a property of
|
|
/// the open file description, not of the descriptor: any descriptor that
|
|
/// shares the description keeps the lock alive, and `fork` hands the child
|
|
/// exactly such a descriptor. `FD_CLOEXEC` closes it at `exec`, not at `fork`,
|
|
/// so between a concurrent fork and that child's `exec` — or for as long as a
|
|
/// forked child runs without exec'ing — the parent closing its own descriptor
|
|
/// releases nothing. Releasing must therefore be an explicit act.
|
|
///
|
|
/// Locking already lives in this module; unlocking stays beside it so the
|
|
/// durability funnel remains the only place that issues the syscall.
|
|
pub(crate) fn unlock(file: &File) -> io::Result<()> {
|
|
rustix::fs::flock(file, rustix::fs::FlockOperation::Unlock)
|
|
.map_err(|e| io::Error::from_raw_os_error(e.raw_os_error()))
|
|
}
|
|
|
|
/// Read the file position without moving it.
|
|
pub(crate) fn cursor(file: &mut File) -> io::Result<u64> {
|
|
file.stream_position()
|
|
}
|
|
|
|
pub(crate) fn seek_to(file: &mut File, offset: u64) -> io::Result<()> {
|
|
file.seek(SeekFrom::Start(offset)).map(|_| ())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::io::Read;
|
|
|
|
fn temp() -> (tempfile::TempDir, std::path::PathBuf) {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let path = dir.path().join("j");
|
|
(dir, path)
|
|
}
|
|
|
|
#[test]
|
|
fn write_vectored_all_counts_bytes_and_verifies_position() {
|
|
let (_dir, path) = temp();
|
|
let mut file = File::options()
|
|
.create(true)
|
|
.read(true)
|
|
.write(true)
|
|
.truncate(true)
|
|
.open(&path)
|
|
.expect("open");
|
|
let counters = DurabilityCounters::default();
|
|
|
|
let a = [1u8; 100];
|
|
let b = [2u8; 156];
|
|
let end = write_vectored_all(&mut file, &[IoSlice::new(&a), IoSlice::new(&b)], &counters)
|
|
.expect("append");
|
|
|
|
assert_eq!(end, 256);
|
|
assert_eq!(counters.snapshot().bytes_written, 256);
|
|
assert_eq!(counters.snapshot().fdatasync, 0, "append must not fence");
|
|
}
|
|
|
|
#[test]
|
|
fn one_fence_per_group_is_observable() {
|
|
let (_dir, path) = temp();
|
|
let file = File::options()
|
|
.create(true)
|
|
.write(true)
|
|
.truncate(true)
|
|
.open(&path)
|
|
.expect("open");
|
|
let counters = DurabilityCounters::default();
|
|
|
|
for _ in 0..7 {
|
|
fdatasync(&file, &counters).expect("fence");
|
|
}
|
|
assert_eq!(counters.snapshot().fdatasync, 7);
|
|
}
|
|
|
|
#[test]
|
|
fn preallocated_region_reads_back_as_zeros() {
|
|
let (_dir, path) = temp();
|
|
let file = File::options()
|
|
.create(true)
|
|
.read(true)
|
|
.write(true)
|
|
.truncate(true)
|
|
.open(&path)
|
|
.expect("open");
|
|
preallocate(&file, 8192).expect("fallocate");
|
|
|
|
assert_eq!(file.metadata().expect("stat").len(), 8192);
|
|
let mut buf = [0xAAu8; 64];
|
|
pread_exact(&file, 4096, &mut buf).expect("read");
|
|
assert!(
|
|
buf.iter().all(|b| *b == 0),
|
|
"the region past the write cursor must read as zeros, \
|
|
which is what stops a tail scan cleanly"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rename_noreplace_refuses_an_occupied_destination() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let from = dir.path().join("a");
|
|
let to = dir.path().join("b");
|
|
std::fs::write(&from, b"new").expect("write");
|
|
std::fs::write(&to, b"existing").expect("write");
|
|
|
|
let err = rename_noreplace(&from, &to).expect_err("must refuse");
|
|
assert_eq!(err.raw_os_error(), Some(libc_eexist()));
|
|
|
|
let mut kept = String::new();
|
|
File::open(&to)
|
|
.expect("open")
|
|
.read_to_string(&mut kept)
|
|
.expect("read");
|
|
assert_eq!(kept, "existing", "the destination must be untouched");
|
|
}
|
|
|
|
#[test]
|
|
fn rename_replace_installs_over_an_existing_pointer() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let from = dir.path().join("CURRENT.tmp");
|
|
let to = dir.path().join("CURRENT");
|
|
std::fs::write(&from, b"new").expect("write");
|
|
std::fs::write(&to, b"old").expect("write");
|
|
|
|
rename_replace(&from, &to).expect("install");
|
|
assert_eq!(std::fs::read(&to).expect("read"), b"new");
|
|
}
|
|
|
|
#[test]
|
|
fn exclusive_lock_refuses_a_second_holder_without_waiting() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let path = dir.path().join("LOCK");
|
|
let a = File::options()
|
|
.create(true)
|
|
.write(true)
|
|
.open(&path)
|
|
.expect("open a");
|
|
let b = File::options()
|
|
.create(true)
|
|
.write(true)
|
|
.open(&path)
|
|
.expect("open b");
|
|
|
|
assert!(try_lock_exclusive(&a).expect("lock a"));
|
|
assert!(
|
|
!try_lock_exclusive(&b).expect("lock b"),
|
|
"a second holder must be refused, not blocked"
|
|
);
|
|
}
|
|
|
|
fn libc_eexist() -> i32 {
|
|
17
|
|
}
|
|
}
|