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.
12 KiB
src/io/pipeline.rs — bounded producer/consumer pipeline
The file-backed mux highway is built on this primitive. (An earlier sweep sink also used it; sweep moved to freemkv-engine in 1.6.0.)
Cancellation and error semantics
- Producer dropping the channel (via
Pipeline::finishdroppingtx) signals end-of-stream; consumer flushes viaclose()and returns itsOutput. - Consumer returning [
Flow::Stop] also callsclose()and returns itsOutput.send()from the producer will then either succeed (if the item already fit in the channel buffer) or fail withErr(item)once the consumer has dropped its receiver. - Consumer returning
Errfromapplyskipsclose()entirely; the consumer keeps draining the channel so the producer never blocks on a dead receiver, and the first error is propagated as theJoinHandleresult. - Consumer panic is converted into [
Error::PipelineConsumerPanicked] (the panic message is logged, not embedded in the error value).
Debug logging
Set FREEMKV_DEBUG=1 environment variable to enable verbose debug
logging throughout the pipeline (channel sends/receives, backpressure,
consumer lag detection). This is critical for diagnosing stalls.
JOIN_TIMEOUT_SECS / FINISH_GRACE_SECS
JOIN_TIMEOUT_SECS (10 minutes) is a backstop, not a normal timeout —
the consumer is expected to drain in seconds. If it fires, something is
wedged inside a kernel call the consumer thread can't unwind from. It is
deliberately long so a consuming application's own (shorter) stall
watchdog gets the first chance to escalate; this join only fires when no
such watchdog intervenes.
FINISH_GRACE_SECS is the short grace period spun after a halt or the
10-minute timeout fires in Pipeline::finish_with_halt. Most wedged
consumers that are "about to return" when the halt fires will unblock
within a few seconds (e.g. their bounded_syscall timeout returns and the
consumer drains). Spinning here converts those into clean joins and
releases the output file handle, at the cost of at most this much extra
latency on a genuinely stuck consumer before the leak is accepted.
SEND_HALT_CHECK_INTERVAL
Halt-check cadence for the send loop. Producer blocks on
crossbeam_channel::Sender::send_timeout for this slice — the kernel
wakes it the instant the consumer drains a slot, so on the happy path
there's no throughput cap from this primitive at all (the cap is
whatever the underlying medium can sustain). When the consumer is
genuinely wedged, the timeout fires every crate::halt::POLL_INTERVAL
and the producer checks the halt token; that's the latency a stop
request will observe. Single source of truth for the interval lives in
crate::halt::POLL_INTERVAL (also used by bounded_syscall); aliased
here for readability of the send/finish call sites.
0.21.7 replaced an old std::sync::mpsc::sync_channel + 50 ms
thread::sleep polling loop that capped mux throughput at ~20
frames/sec ≈ 1 MB/s on saturated channels.
consumer_panicked
Turns a consumer-thread panic payload into the numeric
Error::PipelineConsumerPanicked variant. The original panic message
(the two stdlib formats panic! produces: &str / String) is logged
at the join site for diagnostics — it is NOT baked into the error value,
since the library carries no English text in its errors. Callers
discriminate on the variant.
state module
Consumer lifecycle state, shared between the caller and the consumer
thread. A plain AtomicBool could not make "the caller abandons" and
"the consumer commits to finalising" mutually exclusive: the consumer
loaded the flag, the caller stored it, and the consumer then finalised
the container anyway — the caller reporting the rip as interrupted while
a fully finalised MKV (Cues written, Segment size patched) landed on
disk, indistinguishable from a complete one. The two transitions are
therefore a single compare-exchange each, out of state::RUNNING:
whoever wins decides, and the loser observes the winner.
finish_with_grace
After a halt or deadline fires, spin-polls handle.is_finished() for
grace before accepting the thread leak. This converts the common
"nearly-done" consumer (whose own bounded_syscall just returned and is
about to drop its output file) into a clean join, releasing the file
handle without waiting the full grace period.
If the consumer is still running when the grace expires, the abandoned
flag is set and the JoinHandle is dropped, detaching from the thread.
The leaked consumer keeps running until its current kernel call returns,
then — observing abandoned — exits WITHOUT calling close(), so it
does not finalise an output the caller has already reported as failed.
It does not unblock the in-flight syscall itself; that still returns on
its own (or at process exit).
Pipeline::spawn naming history
This module's only in-crate spawn_named caller is the mux driver,
which names its thread freemkv-mux-consumer; Pipeline::spawn (the
default name) is used only by this module's unit tests. Earlier
revisions of this doc twice named a caller that had left the crate
(disc::patch, then Sweep's freemkv-sweep-consumer thread); both went
to freemkv-engine with the recovery passes in 1.6.0, and each in turn
sent readers hunting a component that is not here. Name callers that
live in THIS crate, or none.
Pipeline::send vs send_with_halt
send_with_halt is NOT a foo_with_X variant of send, despite the
name — the two encode OPPOSITE policies on the same event, each with its
own test: after the consumer's apply has failed, send still succeeds
(the consumer keeps draining, so the channel accepts the item), while
send_with_halt hands the item straight back — so a producer does not
read an hour of disc for a write that died on the first frame.
Collapsing them into one Option-parameterised method deletes one of
those behaviours; it was tried and apply_error_drains_then_propagates
caught it.
Pipeline::finish vs finish_with_halt
Likewise not a foo_with_X variant: finish joins and waits however
long the consumer needs, while finish_with_halt gives up after
JOIN_TIMEOUT_SECS and reports halted. Which is right depends on
whether the caller has a user waiting to cancel — the mux driver does
and uses finish_with_halt; the unit tests do not and use the plain
join. Merging them means picking one of those policies for both.
Test notes
empty_pipeline_still_calls_close: zero items sent, closing the pipeline immediately must still callclose()exactly once and return itsOutput. The consumer loop'swhile let Ok = rx.recv()exits on the droppedtxwith zero iterations, then runssink.close(). Mutation: movingclose()inside the loop would never call it here.close_error_propagates_from_finish:close()returningErrmust surface that error fromfinish, not be swallowed — a clean producer drop still flushes viaclose()and anErrOutputis a valid return. Mutation: if the consumer ignoredclose()'sResultand returnedOk, this fails.try_send_reports_full_when_saturated:try_sendmust reportFullwhen the channel is saturated and the consumer is wedged, NOT block. Wedges the consumer on the first item (depth=1), fills the one buffer slot, then assertstry_sendreturnsFullimmediately. Mutation: routingtry_sendto the blockingsendwould hang.try_send_reports_disconnected_after_consumer_gone:try_sendmust reportDisconnectedonce the consumer thread has exited (here via a panic), with the item handed back inside theDisconnectedvariant. Mutation: iftry_sendmappedDisconnected→Fullit would mis-signal a permanently-dead consumer as transient backpressure.send_returns_item_after_consumer_panicked: plainsendmust hand the item back viaErr(item)once the consumer has gone away (panic). The first send may race the panic, so the test loops until one fails and asserts the returned item identity. Mutation: ifsend'sErrarm returned a different/default item, the identity assert fails.send_with_halt_returns_item_on_disconnect:send_with_haltmust return the exact item viaErr(item)when the consumer has disconnected (theDisconnectedarm). Panics the consumer, waits for it to fully exit, then callssend_with_haltwith a live halt and a long deadline — the only way it can returnErris the disconnect arm. Mutation: if that arm returned a default item instead of the channel's returned item, the identity assert fails.finish_with_halt_none_does_not_spuriously_halt:finish_with_halt(None)with a wedged consumer and NO halt token must NOT return early — it must keep polling until theJOIN_TIMEOUT_SECSdeadline (it cannot observe a halt that was never supplied). Since the test can't wait 10 minutes, it asserts the weaker but still-meaningful property that with aNonehalt and a wedged consumer,finish_with_haltdoes not return within a short window (genuinely blocked, not spuriously returningHalted), then releases the consumer and confirmsOk. Mutation: if theNonebranch erroneously treatedNoneas "cancelled", it would returnHaltedimmediately and this fails.finish_with_halt_joins_cleanly_when_consumer_finishes_in_grace: regression for the "consumer thread / output-file leak on halt" fix. When the halt fires but the consumer finishes WITHIN the grace period,finish_with_haltmust join cleanly and returnOk— not leak the thread or returnErr(Halted). Uses a sink that sleeps briefly (well insideFINISH_GRACE_SECS) after the producer drops the channel; fires the halt immediately sofinish_with_haltenters the grace spin, and the consumer finishes during the grace window. Without the fix (old behaviour: immediate leak on halt) this would have returnedErr(Halted)and theSumSinktotal would be unobservable.leaked_consumer_skips_close_after_abandonment: regression for the "leaked consumer finalises an abandoned output" bug. When the grace period expires and the consumer is leaked, the consumer — once its wedgedapplysyscall returns — must observe the abandonment flag and exit WITHOUT callingclose(). For the mux writer,close()is where the MKV is finalised (Cues + segment-header patch); running it on a file the caller already reported as failed is the write race this fix prevents. Uses a sink whoseapplyblocks until released (simulating a wedged write syscall) and records whetherclose()ran; fires the halt, letsfinish_with_haltspin through the full grace period and leak the thread, THEN releases the wedgedapply. The consumer drains to EOF (txalready dropped) and must skipclose(). Without the fix, the leaked consumer would fall through tosink.close().consumer_finishing_in_grace_still_calls_close: companion to the above — the abandonment guard must NOT fire on the normal halt path where the consumer finishes inside the grace window; there,close()runs and the output is finalised as usual. (Covered for the value-return case byfinish_with_halt_joins_cleanly_when_consumer_finishes_in_grace; this one assertsclose()specifically ran.)finish_with_halt_no_halt_token_normal_completion: regression thatfinish_with_haltwith no halt token and a consumer that completes normally must still returnOk(theNone-halt polling path is unchanged by the grace-period fix) — the pre-existing happy-path test reproduced with an explicit timing floor to guard against spurious early returns.send_with_halt_fails_fast_once_apply_has_failed: a fatalapplyerror must become visible to the PRODUCER, not only tofinish(). The consumer keeps draining after the error (so the producer never blocks on a dead receiver), which meant everysend_with_haltreturnedOkfor the rest of the run: on a 60 GBmkv://mux that hit ENOSPC on the first frame, the mux driver read the entire remaining title — an hour of optical-drive time — before learning the write had died.abandon_loses_to_a_close_already_committed: the abandon/finalise race. A consumer that has ALREADY committed toclose()when the grace period expires cannot be stopped — the finalise is happening — so the caller must wait for its result instead of reporting the output as un-finalised. With a plain flag the consumer read it as clear, the caller then stored it, and the caller returnedErr(Halted)(completed = false) while a fully finalised MKV (Cues written, Segment size patched) landed on disk — a truncated rip indistinguishable from a complete one.