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
+23
View File
@@ -213,6 +213,14 @@ pub const E_MP4_MISSING_CODEC_PRIVATE: u16 = 9050;
/// the sink would have to write 0x0, producing a structurally complete file no
/// player can render. Refuse instead.
pub const E_MP4_UNKNOWN_RESOLUTION: u16 = 9055;
/// The bounded durable flush did not complete within its deadline. The data is
/// NOT known to be on stable storage; the kernel will still flush on close, but
/// that is a probability, not a barrier.
pub const E_SYNC_TIMEOUT: u16 = 9056;
/// The bounded durable flush's worker thread was lost before it reported. Same
/// durability consequence as [`E_SYNC_TIMEOUT`], different cause — a caller
/// retrying a timeout should not retry this.
pub const E_SYNC_WORKER_LOST: u16 = 9057;
/// READ CAPACITY returned a short or overflowing transfer.
pub const E_DISC_CAPACITY_MALFORMED: u16 = 9047;
@@ -557,6 +565,10 @@ pub enum Error {
/// `mp4://` video track has no resolved frame dimensions. See
/// [`E_MP4_UNKNOWN_RESOLUTION`].
Mp4UnknownResolution,
/// The bounded durable flush timed out. See [`E_SYNC_TIMEOUT`].
SyncTimeout,
/// The bounded durable flush's worker was lost. See [`E_SYNC_WORKER_LOST`].
SyncWorkerLost,
PesFrameTooLarge {
size: usize,
},
@@ -747,6 +759,8 @@ impl Error {
Error::Mp4Invalid => E_MP4_INVALID,
Error::Mp4MissingCodecPrivate => E_MP4_MISSING_CODEC_PRIVATE,
Error::Mp4UnknownResolution => E_MP4_UNKNOWN_RESOLUTION,
Error::SyncTimeout => E_SYNC_TIMEOUT,
Error::SyncWorkerLost => E_SYNC_WORKER_LOST,
Error::PesFrameTooLarge { .. } => E_PES_FRAME_TOO_LARGE,
Error::PesInvalidMagic => E_PES_INVALID_MAGIC,
Error::PesTrackTooLarge { .. } => E_PES_TRACK_TOO_LARGE,
@@ -957,6 +971,9 @@ impl From<Error> for std::io::Error {
3000..=3999 => std::io::ErrorKind::PermissionDenied,
4000..=4999 => std::io::ErrorKind::Other,
5000..=5999 => std::io::ErrorKind::Other,
// A stop is an interruption, not invalid data. MUST precede the
// 6000..=6999 arm — E_HALTED is 6010 and match arms are ordered.
E_HALTED => std::io::ErrorKind::Interrupted,
6000..=6999 => std::io::ErrorKind::InvalidData,
7000..=7999 => std::io::ErrorKind::PermissionDenied,
8000..=8999 => std::io::ErrorKind::Other,
@@ -1006,6 +1023,10 @@ impl From<Error> for std::io::Error {
| E_MP4_INVALID
| E_MP4_MISSING_CODEC_PRIVATE
| E_MP4_UNKNOWN_RESOLUTION => std::io::ErrorKind::InvalidData,
// Durability, not data validity: the write landed, the flush did
// not. TimedOut keeps the std kind a caller might already branch on
// while the E-code carries the distinction.
E_SYNC_TIMEOUT | E_SYNC_WORKER_LOST => std::io::ErrorKind::TimedOut,
// 9030 ExtentNotUnitAligned: a malformed/non-AACS-aligned
// extent was handed to the prefetch producer.
9030 => std::io::ErrorKind::InvalidInput,
@@ -1708,6 +1729,8 @@ mod tests {
(Error::Mp4Invalid, E_MP4_INVALID),
(Error::Mp4MissingCodecPrivate, E_MP4_MISSING_CODEC_PRIVATE),
(Error::Mp4UnknownResolution, E_MP4_UNKNOWN_RESOLUTION),
(Error::SyncTimeout, E_SYNC_TIMEOUT),
(Error::SyncWorkerLost, E_SYNC_WORKER_LOST),
(Error::M2tsPacketMalformed, E_M2TS_PACKET_MALFORMED),
(Error::ExtentNotUnitAligned, E_EXTENT_NOT_UNIT_ALIGNED),
(Error::DiscCapacityMalformed, E_DISC_CAPACITY_MALFORMED),