fix(io): a halted fsync is recognisable as a halt, not a hard failure

The three bounded-fsync failures returned bare io::ErrorKind values —
TimedOut, Interrupted, Other/EIO. `is_halt()` matches on
`io_error_code(e) == Some(E_HALTED)`, i.e. the "E<code>" prefix that
`From<Error> for io::Error` mints, and that is documented as the ONLY
recognised shape. A bare ErrorKind carries no prefix.

So cancelling a rip while sync_all / finish was inside the bounded fsync
made `is_halt()` return false, and the CLI reported a clean user cancel
as a hard I/O failure at the end of an otherwise complete mux.

All three were also mutually unclassifiable, which is the same
information-loss the numeric-code scheme exists to prevent: a caller
could not tell "cancelled" from "NFS wedged" from "worker died", and
should not retry the third the way it retries the first. And their
Display text is std English — "timed out", "operation interrupted" —
reaching a user from library code, which this crate does not do.

Now Error::Halted, Error::SyncTimeout (E_SYNC_TIMEOUT 9056) and
Error::SyncWorkerLost (E_SYNC_WORKER_LOST 9057), on both platforms.

E_HALTED also now maps to ErrorKind::Interrupted rather than falling into
the 6000..=6999 InvalidData bucket. A stop is an interruption, not
invalid data. Nothing branched on the old kind — every consumer uses
is_halt() — so this is safe as well as more accurate.

Two things worth recording. I first placed the E_HALTED arm AFTER the
6000..=6999 range arm and wrote a comment claiming it preceded it; match
arms are ordered, so the range won and the comment was simply false. The
test caught it. And the macOS test asserted only that each arm was
non-Ok with a particular ErrorKind — it passed throughout the period the
three were indistinguishable. It now asserts they can be TOLD APART,
which is the property that actually matters.

Found by the round-9 opus escalation over the API contract.
This commit is contained in:
Matthew Jackson
2026-07-30 20:04:08 -07:00
parent 54d038e478
commit 72bcc371fb
4 changed files with 60 additions and 17 deletions
+3 -3
View File
@@ -149,14 +149,14 @@ fn bounded_failure_to_result(e: crate::io::bounded::BoundedError) -> io::Result<
target: "mux",
"WritebackFile::sync_all fsync timed out after 60s; data NOT durably flushed, kernel will flush on close"
);
Err(io::Error::from(io::ErrorKind::TimedOut))
Err(crate::error::Error::SyncTimeout.into())
}
crate::io::bounded::BoundedError::Halted => {
tracing::warn!(
target: "mux",
"WritebackFile::sync_all fsync skipped (halt requested); data NOT durably flushed, kernel will flush on close"
);
Err(io::Error::from(io::ErrorKind::Interrupted))
Err(crate::error::Error::Halted.into())
}
crate::io::bounded::BoundedError::WorkerLost => {
tracing::error!(
@@ -166,7 +166,7 @@ fn bounded_failure_to_result(e: crate::io::bounded::BoundedError) -> io::Result<
// EIO, matching the macOS sibling: a consumer distinguishing these
// three failures does so on the same value on every platform.
// ErrorKind::Other carries nothing a caller can branch on.
Err(io::Error::from_raw_os_error(libc::EIO))
Err(crate::error::Error::SyncWorkerLost.into())
}
}
}
+23 -7
View File
@@ -129,21 +129,21 @@ fn bounded_failure_to_result(e: crate::io::bounded::BoundedError) -> io::Result<
target: "mux",
"WritebackFile::sync_all F_FULLFSYNC timed out after 60s; data NOT durably flushed, kernel will flush on close"
);
Err(io::Error::from(io::ErrorKind::TimedOut))
Err(crate::error::Error::SyncTimeout.into())
}
crate::io::bounded::BoundedError::Halted => {
tracing::warn!(
target: "mux",
"WritebackFile::sync_all F_FULLFSYNC skipped (halt requested); data NOT durably flushed, kernel will flush on close"
);
Err(io::Error::from(io::ErrorKind::Interrupted))
Err(crate::error::Error::Halted.into())
}
crate::io::bounded::BoundedError::WorkerLost => {
tracing::error!(
target: "mux",
"WritebackFile::sync_all F_FULLFSYNC worker lost before completion; data NOT durably flushed, kernel will flush on close"
);
Err(io::Error::from_raw_os_error(libc::EIO))
Err(crate::error::Error::SyncWorkerLost.into())
}
}
}
@@ -215,12 +215,28 @@ mod tests {
"a halted F_FULLFSYNC must not be reported as a completed sync"
);
// The three arms must be DISTINGUISHABLE, not merely non-Ok. Each
// carries its own numeric code through the "E<code>" prefix that
// `From<Error> for io::Error` mints — the only shape `io_error_code`
// recognises. A bare `ErrorKind` cannot be classified, which is how a
// user cancel here used to read as a hard I/O failure.
let lost = bounded_failure_to_result(BoundedError::WorkerLost)
.expect_err("a lost F_FULLFSYNC worker must be an error");
assert_eq!(
lost.raw_os_error(),
Some(libc::EIO),
"a lost F_FULLFSYNC worker must not be reported as a completed sync"
assert!(
lost.to_string()
.starts_with(&format!("E{}", crate::error::E_SYNC_WORKER_LOST)),
"a lost worker must be identifiable, got {lost}"
);
assert!(
timeout
.to_string()
.starts_with(&format!("E{}", crate::error::E_SYNC_TIMEOUT)),
"a timeout must be distinguishable from a lost worker, got {timeout}"
);
assert!(
crate::error::is_halt(&halted),
"a halt must satisfy the crate's own is_halt(), or the CLI reports a \
user cancel as a failure; got {halted}"
);
}
+11 -7
View File
@@ -178,15 +178,19 @@ impl WritebackFile {
/// is left to the kernel's normal flush-on-close path — best
/// effort, but bounded.
///
/// A bounded-fsync failure (timeout / halt / lost worker) is returned as
/// an `Err` on BOTH macOS and Linux, with the same `ErrorKind` per case and
/// `EIO` for the lost worker. So `Ok(())` means the `F_FULLFSYNC` (macOS)
/// or `fsync` (Linux) completed, on either platform, and a caller needing
/// A bounded-fsync failure is returned as an `Err` on BOTH platforms, so
/// `Ok(())` means the flush completed and a caller needing
/// crash-consistency can treat it as a durability barrier.
///
/// Linux used to return `Ok(())` for all three failures with only a
/// `tracing` record; that was fixed, and this doc said otherwise for
/// longer than the bug existed.
/// The three causes are DISTINGUISHABLE by numeric code, because a caller
/// should not retry a lost worker the way it retries a timeout, and must
/// not report a user cancel as a failure:
///
/// * [`E_SYNC_TIMEOUT`](crate::error::E_SYNC_TIMEOUT) — deadline expired
/// * [`E_HALTED`](crate::error::E_HALTED) — cancelled;
/// [`is_halt`](crate::error::is_halt) recognises it
/// * [`E_SYNC_WORKER_LOST`](crate::error::E_SYNC_WORKER_LOST) — the worker
/// thread died before reporting
pub fn sync_all(&mut self) -> io::Result<()> {
if self.seek_count > 0 {
tracing::debug!(