Files
libfreemkv/docs/prefetched.md
T
Matthew Jackson 6e14fc0c0c libfreemkv: 1.7.0 groundwork — sector-recycle soundness + CI hardening
Security
- Remove the unsafe Vec::set_len over recycled sector buffers — UB reachable
  from safe code (GHSA-j8ww-f5fg-9pmh, low). Uses Vec::resize; pooling unchanged.

CI / tooling
- clippy on stable with the MSRV single-sourced from Cargo.toml; cargo-deny
  dependency-audit gate with first-party git sources allowed (#15); audience-based
  comment-guard (#17).

Docs
- README (keydb ownership, BD-J parser count), drive-access transport/reset
  notes, mux timeline module-doc links.
2026-09-02 12:40:55 -07:00

238 lines
13 KiB
Markdown

# `sector::prefetched` — design notes
## Why a producer thread
The mux consumer (demux + codec parsing + frame output) is single-threaded
by nature (streams are sequential). The mux producer (read sectors + AACS
decrypt) is also single-threaded per-call but does CPU-heavy work (AES per
6144-byte unit). Running both on the same thread means the disk and decrypt
cores sit idle while the demux runs, and vice versa.
Splitting them across two threads with a bounded channel between lets both
run in parallel — peak throughput becomes `min(producer_rate, consumer_rate)`
instead of `1 / (1/producer + 1/consumer)`.
## Lifecycle
The producer thread is spawned by `PrefetchedSectorSource::new` (which
returns `Err` if the OS refuses the thread spawn). It walks the supplied
extent list in order, reads the configured batch size at each LBA, and
sends the resulting plaintext buffer into a `crossbeam_channel::bounded`
channel of small depth (so the producer stays a couple of batches ahead
without unbounded memory growth).
When the channel sender drops (either because all extents were served or
because the `Halt` token cancelled), the consumer observes `RecvError` on
the next `read_sectors` and treats it as end-of-stream. Errors from the
underlying reader are forwarded verbatim through the channel.
## Read API
`read_sectors` ignores its `lba`/`count` arguments — the producer has
already chosen what to read, in the order the extents dictate. This is
sound for the mux read path, which always walks extents sequentially and
never seeks. For random-access callers (sweep patch retries) this wrapper
is the wrong tool — they should keep reading the underlying source
directly.
## `new` — unit-alignment precondition
Each extent's `sector_count` should be a multiple of `SECTOR_ALIGNMENT`
(3 sectors / one 6144-byte AACS aligned unit). Blu-ray m2ts extents satisfy
this by spec. If an extent has a trailing 1-2 sectors that cannot fill a
complete unit, the producer surfaces `Error::ExtentNotUnitAligned` through
the channel rather than handing the decrypt step a sub-unit chunk it would
silently leave encrypted.
## `into_channels` — zero-copy pipeline mode
The caller (typically `super::super::mux::demux_thread::DemuxThread`) pulls
buffers from `rx`, consumes them, and pushes the empty `Vec<u8>` back
through `recycle_tx` so the producer can re-fill it. The producer-thread
`JoinHandle` stays with the returned `PrefetchedSectorSource` shell; drop
that to join. The shell only holds the join handle and total_sectors for
`capacity_sectors` queries; its `SectorSource` impl becomes invalid after
this call (data has been moved out).
## Test notes
- `drop_undrained_source_joins_cleanly`: regression for dropping a
`PrefetchedSectorSource` DIRECTLY — the public `new` + documented
direct-read path, and any error/halt exit before the extents are
drained — must join the producer cleanly. `Drop::drop` used to `join()`
while the struct still held `rx` and `recycle_tx` (sibling fields drop
only AFTER `Drop::drop` returns), so a producer parked in the plain
blocking `tx.send(Ok(buf))` never saw a disconnect and the dropping
thread blocked in `join()` forever — a permanent two-thread deadlock
that cancelling the `Halt` could not escape, because that `send` has no
timeout and never re-polls the token. 300 sectors at batch=3 is 100
batches against a forward channel of depth `PREFETCH_CHANNEL_DEPTH` (2),
so the producer is guaranteed to be blocked in `send` by the time the
drop runs. Mirrors `byte_prefetcher::drop_endless_prefetcher_joins_cleanly`,
whose `Drop` already had the correct shape.
- `into_channels_drop_releases_producer`: the CRITICAL regression — after
`into_channels`, dropping the returned forward receiver + recycle sender
must let the producer observe disconnection and exit, so dropping the
`PrefetchShell` (which joins the producer) returns promptly. With the
old clone+forget the leaked endpoints kept the producer blocked and this
join hung forever.
- `halt_releases_producer`: same property via the halt path — cancel the
token, then the producer must exit and the shell join must complete. The
producer parks in a BLOCKING `tx.send` on the forward channel and only
checks `halt` at the loop top, so `halt.cancel()` cannot interrupt a send
that is already blocked on a full channel. To keep the test deterministic
under load, we drain the forward receiver on a background thread: every
send then makes progress, the producer reaches the loop top, observes
the cancelled halt, and exits — so the shell join completes promptly
regardless of scheduling. (Channel-disconnection shutdown is covered
separately by `into_channels_drop_releases_producer`.)
- `zero_unit_align_rejected`: `unit_align == 0` must be rejected by the
constructor, not turned into a divide-by-zero panic on the producer
thread. Before the guard, `new_with_events` returned `Ok` and the
producer evaluated `remaining % 0`, panicking ("attempt to calculate the
remainder with a divisor of zero"); `catch_unwind` then reported the
read as `DemuxThreadPanicked` instead of the `InvalidInput` its sibling
parameter gets — a panic printed out of a public constructor's own
thread.
- `direct_reads_past_pool_depth_do_not_deadlock`: more than 3 sequential
direct `read_sectors` calls must succeed. The recycle pool seeds
PREFETCH_CHANNEL_DEPTH+1 (3) buffers; before the fix the direct path
dropped each drained buffer, so the 4th call deadlocked.
- `event_fn_fires_bytes_read_per_batch`: the producer-thread `event_fn`
must fire a `BytesRead` event for every batch it reads, with a
monotonically increasing cumulative byte count that reaches the full
extent size at EOF. This is the contract autorip's mux progress bar +
soft-stall watchdog depend on: the resume/multipass paths pass an
`event_fn` so `latest_bytes_read` tracks read-ahead progress and
`wd_last_frame` is refreshed on each sector read (not only on the slower
write cadence). Before this guard nothing asserted the callback fired at
all, so a path that passed `None` (the `resume_remux` regression)
compiled and ran silently with the bar stuck at write-lagged progress.
- `non_multiple_of_three_extent_errors_on_tail`: an extent whose
sector_count is not a multiple of 3 must not emit a still-encrypted
sub-unit tail. The producer delivers the readable full units, then
surfaces a typed error on the tail instead of a short batch.
- `short_read_does_not_desync_stream`: a short read (inner source returns
fewer sectors than requested) must advance the extent cursor by the
sectors actually read, not the requested count — otherwise the bytes
between the short read and the request size are silently skipped.
- `capacity_sectors_sums_all_extents`: `capacity_sectors` returns the sum
of all extents' sector_counts, computed once at construction. Grounding:
doc comment on `total_sectors` — "the sum of each extent's sector_count".
- `capacity_sectors_clamps_on_overflow`: total-sector accumulation must
clamp at u32::MAX rather than panic (debug overflow) or wrap (release)
on a hostile extent set whose summed sector_count exceeds u32.
Grounding: the `new` comment — "Accumulate in u64 then clamp ... a naive
u32 sum() could panic in debug / wrap in release".
- `producer_walks_extents_in_order_at_correct_lbas`: the producer must
walk extents in list order and start each extent at its `start_lba`
(plus running offset within the extent), never reorder or merge them.
Grounding: lifecycle doc — "walks the supplied extent list in order" and
`lba = extent.start_lba.saturating_add(offset)`.
- `batch_trimmed_to_whole_units`: a batch larger than one unit must be
trimmed DOWN to a whole number of 3-sector units before issuing the
read — never a sub-unit count that decrypt would leave partially
encrypted. batch=5 → trimmed to 3 (5 - 5%3). Grounding: the unit-trim
block `sectors -= sectors % SECTOR_ALIGNMENT`.
- `unit_aligned_extent_delivers_all_and_eofs`: an extent whose
sector_count IS a multiple of 3 must deliver exactly that many sectors
and then cleanly EOF (no error on the final aligned batch). Grounding:
the trailing-tail guard only fires for sub-unit leftovers; a unit-aligned
extent forms full units on its own.
- `reader_error_propagates_with_kind`: the underlying reader's error must
propagate to the consumer as an error (not Ok(0)/EOF), and its
ErrorKind must survive the round-trip through the channel. Grounding:
the producer's `Err(e) => tx.send(Err(e.into()))` arm, and
`read_sectors`' `Ok(Err(e)) => { self.producer_failed = true;
Err(Error::from(e)) }`. That arm recovers the producer's TYPED error by
downcast rather than blanket-wrapping it as `Error::IoError`, so the
kind survives; the `producer_failed` latch it also sets is what turns
the channel close that follows into `SourceTerminated` instead of a
clean EOF.
- `inner_source_quitting_early_is_not_reported_as_end_of_stream`: an inner
source that answers a mid-extent read with `Ok(0)` has quit early: the
extent list still has sectors to serve. The producer must say so, not
simply drop `tx` — a closed channel reads as clean end-of-stream, and
`DiscStream::fill_extents` then fabricates zeros for every remaining
sector of the title and reports the pass complete. Same rule as the
panic sentinel: a truncated title must never be finalized as success.
- `non_sector_multiple_read_rejected`: a read returning a byte count that
is not a whole number of sectors (n % 2048 != 0) must be rejected —
never truncated and advanced, which would split a sector and hand
decrypt a partial unit. Grounding: the `if n % 2048 != 0 { send Err }`
guard.
- `direct_read_too_small_buffer_errors`: a too-small consumer buffer in
the direct `read_sectors` path must error (InvalidInput), never
silently drop the bytes past `buf.len()`. Grounding: the
`if filled.len() > buf.len()` guard in `read_sectors` ("would silently
drop filled[buf.len()..], desyncing the stream").
- `too_small_buffer_repeated_does_not_deadlock_pool`: regression for the
fix-1 deadlock — calling read_sectors with a too-small buffer more times
than the pool depth (PREFETCH_CHANNEL_DEPTH+1 = 3) must NOT deadlock and
the pool must remain usable afterwards. Before the fix, each
too-small-buffer error path returned without recycling the received
buffer, draining the fixed pool. On the 4th call the producer blocked on
recycle_rx.recv() while the consumer blocked on rx.recv() — permanent
deadlock.
- `delivered_bytes_match_source_exactly`: the producer delivers exactly
the bytes the inner source produced, in order, byte-for-byte.
PatternSource tags each sector with `(lba & 0xff)`, so the assembled
stream must match a reconstruction from the extent's LBA range. Guards
against off-by-one/duplicate/reorder in the offset bookkeeping.
- `empty_extents_eof_immediately`: an empty extent list must EOF
immediately (capacity 0, first direct read returns Ok(0)) and must not
deadlock. Grounding: the producer's `while ext_idx < extents.len()` loop
body never runs, so `tx` drops and the consumer sees RecvError → Ok(0).
- `zero_length_extent_is_skipped`: a zero-length extent in the middle of
the list must be skipped (remaining == 0 → advance to next extent)
without emitting a batch and without stalling. Grounding: the
`if remaining == 0 { ext_idx += 1; continue }` branch.
- `four_sector_extent_errors_on_one_sector_tail`: a 4-sector extent (one
full unit + a 1-sector tail) must deliver the 3-sector unit and then
error on the 1-sector remainder — exercising the trim-within-batch path
(`sectors -= sectors % 3` lands on 3, leaving remaining=1) that then
hits the sub-unit guard on the next iteration. Distinct control flow
from the 8-sector case. Grounding: trailing-tail guard plus the
unit-trim block.
- `many_extents_drain_without_deadlock`: many sequential direct reads
across MANY extents must all flow through the fixed recycle pool
without deadlock — a stronger version of the pool-depth regression that
also crosses extent boundaries (offset reset to 0, ext_idx advance).
- `bad_sector_across_channel_is_not_a_transport_failure`: REGRESSION — an
ordinary MEDIUM ERROR bad sector that crosses the prefetch channel must
NOT be classified as a SCSI transport failure. `read_sectors` used to
re-wrap EVERY channel error as `Error::IoError { source }`, and
`is_scsi_transport_failure` matches `IoError` (the wedged-USB-bridge
arm). So a skippable bad sector arrived at `DiscStream::fill_extents`
looking like a dead bus and aborted the whole pass instead of honouring
`skip_errors`.
- `transport_failure_across_channel_still_classifies`: OPPOSITE-DIRECTION
CONTROL — a genuine transport failure (status 0xFF, wedged USB bridge)
crossing the same channel MUST still classify as a transport failure, so
`fill_extents` / sweep keep aborting the pass instead of zero-filling
every read against a dead bus.