Skip to main content

decdn_cache/
origin_range.rs

1//! Windowed origin range pulls (#2065): the requested span is fetched in bounded
2//! windows of [`RANGE_PULL_WINDOW_BYTES`] against an `{H}.obao4` outboard that
3//! the engine caches per hash, so a range pull holds `O(window + outboard)`
4//! bytes whatever its length. Each origin read runs under a time budget
5//! (see [`crate::CacheEngine::set_origin_read_budget`]).
6//!
7//! [`crate::CacheEngine::origin_range_wire`] (the own-origin serve-miss spine's
8//! pull leg) builds on the `OriginRangeCursor` here. It needs ONE coherent
9//! header-less wire for the whole draw — joined per-window encodings would
10//! repeat the shared parent nodes — so it runs a single
11//! [`bao_tree::io::fsm::encode_ranges_validated`] over an `OriginWindowReader`
12//! that pulls the cursor's windows in order, and streams the output through a
13//! bounded channel ([`OriginRangeWire`]).
14
15use std::any::Any;
16use std::future::Future;
17use std::io;
18use std::panic::AssertUnwindSafe;
19use std::sync::{Arc, Mutex, PoisonError};
20use std::time::Duration;
21
22use bao_tree::io::EncodeError;
23use bao_tree::io::fsm::encode_ranges_validated;
24use bao_tree::io::outboard::PreOrderMemOutboard;
25use bao_tree::{BaoTree, ChunkRanges};
26use bytes::{Bytes, BytesMut};
27use futures_util::FutureExt;
28use iroh_blobs::Hash;
29use iroh_io::{AsyncSliceReader, AsyncStreamWriter};
30use tokio::sync::{OwnedSemaphorePermit, mpsc};
31use tokio_util::task::AbortOnDropHandle;
32
33use crate::error::{CacheError, CacheResult};
34use crate::metrics::CacheMetrics;
35use crate::origin::{Origin, OriginKind, OriginRangeFetch, OriginRangeRequest};
36use crate::outboard_cache::OutboardCache;
37
38/// The time budget for one origin read of the range-pull path — an outboard
39/// fetch or one data window. A read of `len` bytes gets
40/// `head_start + len / min_bps`, so the budget scales with the read and never
41/// caps the blob size (see [`crate::CacheEngine::set_origin_read_budget`]).
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub(crate) struct OriginReadBudget {
44    /// Fixed allowance for the connection and the first byte.
45    pub(crate) head_start: Duration,
46    /// Average throughput, in bytes per second, the rest of the read must
47    /// sustain. Never zero.
48    pub(crate) min_bps: u64,
49}
50
51impl OriginReadBudget {
52    /// The budget for a read of `len` bytes.
53    pub(crate) fn for_len(self, len: u64) -> Duration {
54        let nanos = (u128::from(len) * 1_000_000_000).div_ceil(u128::from(self.min_bps.max(1)));
55        self.head_start.saturating_add(Duration::from_nanos(
56            u64::try_from(nanos).unwrap_or(u64::MAX),
57        ))
58    }
59}
60use crate::range_pull::{AlignedRange, IROH_BLOCK_SIZE};
61
62/// Largest data span one [`Origin::fetch_range_data`] call fetches. A multiple
63/// of the 16 KiB chunk group, and windows start on the group-aligned
64/// `fetch_start`, so no bao leaf straddles two windows. A range pull holds about
65/// two windows (the fetched span and its encoding) plus the outboard, whatever
66/// the requested length.
67pub const RANGE_PULL_WINDOW_BYTES: u64 = 4 * 1024 * 1024;
68
69/// [`crate::CacheEngine::origin_range_wire`] draws that run at once. A caller
70/// past the bound waits for a permit rather than degrading, because the degrade
71/// is a whole-blob origin pull — more egress, not less.
72pub const MAX_CONCURRENT_RANGE_PULLS: usize = 4;
73
74/// How long a draw waits on a full [`MAX_CONCURRENT_RANGE_PULLS`] pool before it
75/// logs a warning. It keeps waiting after the warning.
76pub(crate) const RANGE_PULL_PERMIT_WARN_AFTER: Duration = Duration::from_secs(10);
77
78/// Encoded chunks the origin-range encoder buffers ahead of its consumer before it
79/// parks. The encoder writes one parent pair or one chunk group per item, so the
80/// channel holds at most this many chunk groups.
81const WIRE_CHANNEL_CAP: usize = 8;
82
83/// Run one origin read `fut` of `len` bytes of `hash`'s range-pull path under
84/// `budget`. A read past its budget is an origin transport fault
85/// ([`CacheError::OriginError`]), so the caller advances the origin chain or
86/// fails the fill as for any other transport fault; it also bumps
87/// `origin_range_timeouts`. `None` runs `fut` unbounded.
88///
89/// # Errors
90///
91/// [`CacheError::OriginError`] when `fut` does not finish within its budget.
92pub(crate) async fn within_origin_timeout<F: Future>(
93    budget: Option<OriginReadBudget>,
94    len: u64,
95    hash: Hash,
96    what: &'static str,
97    metrics: Option<&CacheMetrics>,
98    fut: F,
99) -> CacheResult<F::Output> {
100    let Some(budget) = budget else {
101        return Ok(fut.await);
102    };
103    let limit = budget.for_len(len);
104    tokio::time::timeout(limit, fut).await.map_err(|_| {
105        if let Some(m) = metrics {
106            m.origin_range_timeouts.inc();
107        }
108        CacheError::OriginError {
109            hash,
110            source: anyhow::anyhow!(
111                "origin {what} of {len} bytes took longer than its {limit:?} budget \
112                 (cache.node_pull_stall_window_sec plus the size at \
113                 cache.node_pull_min_throughput_bps)"
114            ),
115        }
116    })
117}
118
119/// The window spans `[start, end)` that cover an [`AlignedRange`], in order.
120/// Each is at most [`RANGE_PULL_WINDOW_BYTES`] long and starts on a chunk-group
121/// boundary. An empty range (the 0-byte blob) yields one empty span, so the
122/// caller still imports its empty proof.
123#[derive(Debug, Clone)]
124pub(crate) struct WindowSpans {
125    next: u64,
126    end: u64,
127    done: bool,
128}
129
130impl WindowSpans {
131    pub(crate) const fn new(aligned: &AlignedRange) -> Self {
132        Self {
133            next: aligned.fetch_start(),
134            end: aligned.fetch_end(),
135            done: false,
136        }
137    }
138}
139
140impl Iterator for WindowSpans {
141    type Item = (u64, u64);
142
143    fn next(&mut self) -> Option<(u64, u64)> {
144        if self.done {
145            return None;
146        }
147        let start = self.next;
148        let end = start.saturating_add(RANGE_PULL_WINDOW_BYTES).min(self.end);
149        self.next = end;
150        self.done = end >= self.end;
151        Some((start, end))
152    }
153}
154
155/// One window of an origin span: the still-UNVERIFIED bytes of `[start, end)`,
156/// exactly `end - start` long.
157#[derive(Debug)]
158pub(crate) struct OriginWindow {
159    pub(crate) start: u64,
160    pub(crate) data: Bytes,
161}
162
163/// What an origin answered for one window. A wrong-length answer is kept apart
164/// from a decline because the two paths judge it differently: the range pull
165/// degrades, and the origin-range wire reports [`CacheError::VerifyFailed`].
166#[derive(Debug)]
167pub(crate) enum WindowFetch {
168    /// The origin served the window at its exact length.
169    Window(OriginWindow),
170    /// The origin declined the window (no `Range`, object absent or short).
171    Declined { start: u64, end: u64 },
172    /// The origin served `got` bytes for the `[start, end)` window.
173    WrongLength { start: u64, end: u64, got: usize },
174}
175
176/// One origin serving the data of `hash`, walking the windows of the span it was
177/// opened for, in order. The outboard is UNTRUSTED until a window verifies
178/// against the root `H`.
179pub(crate) struct OriginRangeCursor {
180    origin: Arc<dyn Origin>,
181    hash: Hash,
182    outboard: Bytes,
183    /// Time budget for each window fetch; `None` is unbounded.
184    budget: Option<OriginReadBudget>,
185    metrics: Option<Arc<CacheMetrics>>,
186    spans: WindowSpans,
187    /// The first window, fetched by [`Self::open`] and not yet handed out.
188    pending: Option<WindowFetch>,
189}
190
191impl OriginRangeCursor {
192    /// Open a cursor on `origin` for `aligned` against the already-read
193    /// `outboard`, and fetch the first window. `Ok(None)` when this origin
194    /// declines the first window, so the caller can advance the origin chain
195    /// before it commits to this origin. Each window fetch runs under `budget`
196    /// ([`within_origin_timeout`]).
197    ///
198    /// Meters every fetched window as `pull_through_bytes`. The outboard read
199    /// is metered where it happens, in
200    /// [`crate::CacheEngine::origin_fetch_outboard_bytes`].
201    ///
202    /// # Errors
203    ///
204    /// [`CacheError::OriginError`] for an origin transport fault or a window
205    /// fetch past its budget.
206    pub(crate) async fn open(
207        origin: Arc<dyn Origin>,
208        hash: Hash,
209        aligned: &AlignedRange,
210        outboard: Bytes,
211        budget: Option<OriginReadBudget>,
212        metrics: Option<Arc<CacheMetrics>>,
213    ) -> CacheResult<Option<Self>> {
214        let mut cursor = Self {
215            origin,
216            hash,
217            outboard,
218            budget,
219            metrics,
220            spans: WindowSpans::new(aligned),
221            pending: None,
222        };
223        match cursor.fetch_next().await? {
224            None | Some(WindowFetch::Declined { .. }) => Ok(None),
225            Some(first) => {
226                cursor.pending = Some(first);
227                Ok(Some(cursor))
228            }
229        }
230    }
231
232    /// The untrusted pre-order outboard this cursor was opened with.
233    pub(crate) fn outboard(&self) -> Bytes {
234        self.outboard.clone()
235    }
236
237    /// The kind of origin this cursor reads from.
238    pub(crate) fn origin_kind(&self) -> OriginKind {
239        self.origin.kind()
240    }
241
242    /// Whether the origin answered the first window with the wrong length.
243    pub(crate) const fn first_is_wrong_length(&self) -> bool {
244        matches!(self.pending, Some(WindowFetch::WrongLength { .. }))
245    }
246
247    /// The next window of the span, in order. `Ok(None)` once every window has
248    /// been handed out.
249    ///
250    /// # Errors
251    ///
252    /// [`CacheError::OriginError`] for an origin transport fault.
253    pub(crate) async fn next_window(&mut self) -> CacheResult<Option<WindowFetch>> {
254        if let Some(first) = self.pending.take() {
255            return Ok(Some(first));
256        }
257        self.fetch_next().await
258    }
259
260    async fn fetch_next(&mut self) -> CacheResult<Option<WindowFetch>> {
261        let Some((start, end)) = self.spans.next() else {
262            return Ok(None);
263        };
264        let req = OriginRangeRequest {
265            fetch_start: start,
266            fetch_end: end,
267        };
268        let data = match within_origin_timeout(
269            self.budget,
270            end - start,
271            self.hash,
272            "range window fetch",
273            self.metrics.as_deref(),
274            self.origin.fetch_range_data(self.hash, req),
275        )
276        .await?
277        .map_err(|e| CacheError::OriginError {
278            hash: self.hash,
279            source: e.into_inner(),
280        })? {
281            OriginRangeFetch::Ranged { data } => data,
282            OriginRangeFetch::Unsupported | OriginRangeFetch::NotFound => {
283                return Ok(Some(WindowFetch::Declined { start, end }));
284            }
285        };
286        if let Some(m) = &self.metrics {
287            m.pull_through_bytes
288                .inc_by(u64::try_from(data.len()).unwrap_or(u64::MAX));
289        }
290        if u64::try_from(data.len()).ok() != Some(end - start) {
291            return Ok(Some(WindowFetch::WrongLength {
292                start,
293                end,
294                got: data.len(),
295            }));
296        }
297        Ok(Some(WindowFetch::Window(OriginWindow { start, data })))
298    }
299}
300
301/// The typed fault an origin-range encode stopped on, shared between the encode task
302/// and its data reader. Only [`park_fault`] writes it; only
303/// [`OriginRangeWire::next_chunk`] reads it.
304type FaultSlot = Arc<Mutex<Option<CacheError>>>;
305
306/// Record `fault` unless a fault is already parked: the first fault is the
307/// most specific (the reader parks the typed cause before the encoder sees a
308/// generic read error), so it wins.
309fn park_fault(slot: &FaultSlot, fault: CacheError) {
310    let mut guard = slot.lock().unwrap_or_else(PoisonError::into_inner);
311    if guard.is_none() {
312        *guard = Some(fault);
313    }
314}
315
316/// Log a caught encode panic as the code bug it is and park it as a
317/// [`CacheError::Internal`] that carries the panic's message, so it never reads
318/// as an origin or store fault.
319fn park_panic(slot: &FaultSlot, hash: Hash, kind: OriginKind, payload: &(dyn Any + Send)) {
320    let msg = payload
321        .downcast_ref::<&str>()
322        .copied()
323        .or_else(|| payload.downcast_ref::<String>().map(String::as_str))
324        .unwrap_or("non-string panic payload");
325    tracing::error!(
326        %hash,
327        ?kind,
328        panic = msg,
329        "origin range encode panicked; internal fault (code bug), not an origin fault",
330    );
331    park_fault(
332        slot,
333        CacheError::Internal(anyhow::anyhow!(
334            "origin range encode for {hash} panicked: {msg}"
335        )),
336    );
337}
338
339/// A fault in this module's own window bookkeeping, not the origin's. Logged
340/// and reported as a local fault so it never blames the operator's origin.
341fn internal_fault(hash: Hash, what: &str) -> CacheError {
342    tracing::error!(%hash, what, "origin range reader invariant broken");
343    CacheError::Internal(anyhow::anyhow!(
344        "origin range reader invariant broken for {hash}: {what}"
345    ))
346}
347
348/// An [`AsyncSliceReader`] over one origin's span that holds one window at a
349/// time and pulls the cursor's next window when a read passes the held one. The
350/// encoder reads the range front to back, so each window is fetched once.
351///
352/// A failed read parks its typed cause first:
353/// - a window the origin declines, or an origin transport fault →
354///   [`CacheError::OriginError`];
355/// - a window of the wrong length → [`CacheError::VerifyFailed`];
356/// - a read this reader cannot place (backward, or past the span) →
357///   [`CacheError::Store`], a local fault.
358pub(crate) struct OriginWindowReader {
359    cursor: OriginRangeCursor,
360    blob_size: u64,
361    fetch_end: u64,
362    window_start: u64,
363    window: Bytes,
364    fault: FaultSlot,
365}
366
367impl OriginWindowReader {
368    const fn new(cursor: OriginRangeCursor, aligned: &AlignedRange, fault: FaultSlot) -> Self {
369        Self {
370            cursor,
371            blob_size: aligned.blob_size(),
372            fetch_end: aligned.fetch_end(),
373            window_start: aligned.fetch_start(),
374            window: Bytes::new(),
375            fault,
376        }
377    }
378
379    fn window_end(&self) -> u64 {
380        self.window_start
381            .saturating_add(u64::try_from(self.window.len()).unwrap_or(u64::MAX))
382    }
383
384    fn fail(&self, fault: CacheError) -> io::Error {
385        let msg = fault.display_chain().to_string();
386        park_fault(&self.fault, fault);
387        io::Error::other(msg)
388    }
389
390    /// Replace the held window with the cursor's next one.
391    async fn load_next(&mut self) -> io::Result<()> {
392        let hash = self.cursor.hash;
393        // Release the old window before fetching the next one.
394        self.window = Bytes::new();
395        match self.cursor.next_window().await {
396            Ok(Some(WindowFetch::Window(w))) if !w.data.is_empty() => {
397                self.window_start = w.start;
398                self.window = w.data;
399                Ok(())
400            }
401            Ok(Some(WindowFetch::Window(_)) | None) => Err(self.fail(internal_fault(
402                hash,
403                "read past the last window of the span",
404            ))),
405            Ok(Some(WindowFetch::Declined { start, end })) => {
406                tracing::warn!(
407                    %hash,
408                    kind = ?self.cursor.origin_kind(),
409                    window_start = start,
410                    window_end = end,
411                    "own origin stopped serving the range mid-stream",
412                );
413                Err(self.fail(CacheError::OriginError {
414                    hash,
415                    source: anyhow::anyhow!(
416                        "origin stopped serving range [{start}, {end}) of {hash} mid-stream"
417                    ),
418                }))
419            }
420            Ok(Some(WindowFetch::WrongLength { start, end, got })) => {
421                tracing::warn!(
422                    %hash,
423                    kind = ?self.cursor.origin_kind(),
424                    window_start = start,
425                    window_end = end,
426                    got,
427                    "own origin served a wrong-length range window; hard local-origin fault \
428                     (no degrade — committed to serving under H)",
429                );
430                Err(self.fail(CacheError::VerifyFailed { expected: hash }))
431            }
432            Err(e) => {
433                tracing::warn!(
434                    %hash,
435                    kind = ?self.cursor.origin_kind(),
436                    error = %e.display_chain(),
437                    "own origin range window fetch failed mid-stream",
438                );
439                Err(self.fail(e))
440            }
441        }
442    }
443}
444
445impl AsyncSliceReader for OriginWindowReader {
446    async fn read_at(&mut self, offset: u64, len: usize) -> io::Result<Bytes> {
447        let end = offset
448            .saturating_add(u64::try_from(len).unwrap_or(u64::MAX))
449            .min(self.fetch_end);
450        if offset < self.window_start {
451            return Err(self.fail(internal_fault(
452                self.cursor.hash,
453                "backward read before the held window",
454            )));
455        }
456        let mut out: Option<BytesMut> = None;
457        let mut pos = offset;
458        while pos < end {
459            while pos >= self.window_end() {
460                self.load_next().await?;
461            }
462            let rel = usize::try_from(pos - self.window_start).unwrap_or(usize::MAX);
463            let take_end = end.min(self.window_end());
464            let take = usize::try_from(take_end - pos).unwrap_or(usize::MAX);
465            let Some(slice) = self.window.get(rel..rel.saturating_add(take)) else {
466                return Err(self.fail(internal_fault(
467                    self.cursor.hash,
468                    "held window shorter than its span",
469                )));
470            };
471            let piece = self.window.slice_ref(slice);
472            pos = take_end;
473            // A leaf never straddles windows (see `RANGE_PULL_WINDOW_BYTES`), so
474            // a read returns a zero-copy slice of one window. The copying branch
475            // is defensive: it keeps a straddling read correct, not fast.
476            if pos >= end && out.is_none() {
477                return Ok(piece);
478            }
479            out.get_or_insert_with(BytesMut::new)
480                .extend_from_slice(&piece);
481        }
482        Ok(out.map(BytesMut::freeze).unwrap_or_default())
483    }
484
485    async fn size(&mut self) -> io::Result<u64> {
486        Ok(self.blob_size)
487    }
488}
489
490/// Pushes the encoder's output into the bounded channel [`OriginRangeWire`]
491/// drains. A dropped receiver surfaces as a write error, ending the encode.
492struct ChannelWriter {
493    tx: mpsc::Sender<Bytes>,
494}
495
496impl AsyncStreamWriter for ChannelWriter {
497    async fn write(&mut self, data: &[u8]) -> io::Result<()> {
498        self.write_bytes(Bytes::copy_from_slice(data)).await
499    }
500
501    async fn write_bytes(&mut self, data: Bytes) -> io::Result<()> {
502        if data.is_empty() {
503            return Ok(());
504        }
505        self.tx
506            .send(data)
507            .await
508            .map_err(|_| io::Error::other("origin range wire consumer dropped"))
509    }
510
511    async fn sync(&mut self) -> io::Result<()> {
512        Ok(())
513    }
514}
515
516/// Parks an `OriginError` if the encode task is cancelled before it reaches its
517/// verdict: an abort or a runtime shutdown drops it. A panic inside the encode
518/// does not reach it, because the task catches the unwind and parks a
519/// [`CacheError::Internal`]; a panic after the encode (in the verdict
520/// classification) does. So the channel never closes on an unfinished wire
521/// without a fault behind it.
522struct UnfinishedGuard {
523    slot: FaultSlot,
524    hash: Hash,
525    finished: bool,
526}
527
528impl UnfinishedGuard {
529    /// Mark the encode as having reached its verdict; the drop then parks
530    /// nothing.
531    const fn disarm(&mut self) {
532        self.finished = true;
533    }
534}
535
536impl Drop for UnfinishedGuard {
537    fn drop(&mut self) {
538        if !self.finished {
539            park_fault(
540                &self.slot,
541                CacheError::OriginError {
542                    hash: self.hash,
543                    source: anyhow::anyhow!(
544                        "origin range encode for {} was cancelled, or panicked outside the encode, before completing",
545                        self.hash
546                    ),
547                },
548            );
549        }
550    }
551}
552
553/// The header-less interleaved bao wire (ADR 038) for one range of a blob,
554/// verified against the root `H` and produced window by window out of an
555/// origin. Returned by [`crate::CacheEngine::origin_range_wire`].
556///
557/// A background task runs the encode and holds a range-pull permit until it
558/// ends. [`Self::next_chunk`] yields the wire in order, then either one
559/// terminal `Err` or `None`:
560/// - `Some(Err(_))` — the encode stopped: [`CacheError::VerifyFailed`] for a
561///   window that fails verification against `H`, [`CacheError::OriginError`]
562///   for an origin that stops serving or a task that is cancelled,
563///   [`CacheError::Internal`] for a code bug: a reader invariant break, or a
564///   panic in the encode, whose message the error carries. The wire is
565///   incomplete.
566/// - `None` with no `Err` before it — the encode completed; the wire is whole.
567///
568/// Dropping the wire aborts the encode and releases its permit.
569pub struct OriginRangeWire {
570    rx: mpsc::Receiver<Bytes>,
571    fault: FaultSlot,
572    ended: bool,
573    _task: AbortOnDropHandle<()>,
574}
575
576impl std::fmt::Debug for OriginRangeWire {
577    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
578        f.debug_struct("OriginRangeWire")
579            .field("ended", &self.ended)
580            .finish_non_exhaustive()
581    }
582}
583
584impl OriginRangeWire {
585    /// Start the encode of `aligned` over `cursor`, whose first window has the
586    /// right length (the caller advances the origin chain past one that does
587    /// not). Checks the outboard length up front: a wrong length cannot verify,
588    /// so it is [`CacheError::VerifyFailed`] at once, before any wire. A
589    /// wrong-length outboard or a failed bao verification evicts the cursor's
590    /// outboard from `outboards`, so the next draw reads it from the origin
591    /// again instead of reusing a copy that may be the bad half.
592    pub(crate) fn spawn(
593        cursor: OriginRangeCursor,
594        aligned: &AlignedRange,
595        permit: OwnedSemaphorePermit,
596        outboards: OutboardCache,
597    ) -> CacheResult<Self> {
598        let hash = cursor.hash;
599        let kind = cursor.origin_kind();
600        let tree = BaoTree::new(aligned.blob_size(), IROH_BLOCK_SIZE);
601        let outboard = cursor.outboard();
602        if u64::try_from(outboard.len()).ok() != Some(tree.outboard_size()) {
603            tracing::warn!(
604                %hash,
605                ?kind,
606                expected = tree.outboard_size(),
607                got = outboard.len(),
608                "outboard length does not match the blob size; hard local-origin fault \
609                 (no degrade — committed to serving under H)",
610            );
611            outboards.evict_if_same(hash, &outboard);
612            return Err(CacheError::VerifyFailed { expected: hash });
613        }
614        // `open_range_cursor` never hands out a cursor whose first window has
615        // the wrong length: it advances the origin chain instead.
616        debug_assert!(!cursor.first_is_wrong_length());
617        // A verify fault does not say whether the outboard or the data was bad,
618        // so the encode task evicts the outboard copy it used.
619        let used_outboard = outboard.clone();
620        let ob = PreOrderMemOutboard {
621            root: bao_tree::blake3::Hash::from(*hash.as_bytes()),
622            tree,
623            data: outboard,
624        };
625        let ranges: ChunkRanges = aligned.chunk_ranges().clone();
626        let fault: FaultSlot = Arc::new(Mutex::new(None));
627        let reader = OriginWindowReader::new(cursor, aligned, Arc::clone(&fault));
628        let (tx, rx) = mpsc::channel(WIRE_CHANNEL_CAP);
629        let mut guard = UnfinishedGuard {
630            slot: Arc::clone(&fault),
631            hash,
632            finished: false,
633        };
634        let task = tokio::spawn(async move {
635            let _permit = permit;
636            // `writer` owns the only sender. It lives outside the caught encode
637            // and drops at the end of this block, after the verdict below is
638            // parked, so the consumer never sees the channel close before the
639            // fault is in place — after a panic too.
640            let mut writer = ChannelWriter { tx };
641            let mut reader = reader;
642            let mut ob = ob;
643            // The empty range (the 0-byte blob) has an empty wire. The async
644            // encoder, unlike the sync one, does not short-circuit empty ranges
645            // and trips a debug assertion walking them, so skip it.
646            let encode = async {
647                if ranges.is_empty() {
648                    Ok(())
649                } else {
650                    encode_ranges_validated(&mut reader, &mut ob, ranges.as_ref(), &mut writer)
651                        .await
652                }
653            };
654            // A panic in the encoder or the reader is a code bug, not an origin
655            // fault. Catch it so its message reaches the fault slot and the log
656            // instead of dying with the task.
657            let result = match AssertUnwindSafe(encode).catch_unwind().await {
658                Ok(result) => result,
659                Err(payload) => {
660                    park_panic(&guard.slot, hash, kind, payload.as_ref());
661                    // The verdict is parked; nothing is left to classify.
662                    Ok(())
663                }
664            };
665            match result {
666                Ok(()) => {}
667                Err(EncodeError::ParentHashMismatch(_) | EncodeError::LeafHashMismatch(_)) => {
668                    tracing::warn!(
669                        %hash,
670                        ?kind,
671                        "own origin served a range that failed bao verification against H; \
672                         hard local-origin fault (no degrade — committed to serving under H)",
673                    );
674                    outboards.evict_if_same(hash, &used_outboard);
675                    park_fault(&guard.slot, CacheError::VerifyFailed { expected: hash });
676                }
677                // A reader-side io error has already parked its typed cause, and
678                // `park_fault` keeps it. A writer-side one (the consumer dropped
679                // the wire) parks this generic fault, which nobody reads.
680                Err(EncodeError::Io(e)) => park_fault(
681                    &guard.slot,
682                    CacheError::OriginError {
683                        hash,
684                        source: anyhow::Error::new(e).context("origin range encode failed"),
685                    },
686                ),
687                Err(other) => park_fault(
688                    &guard.slot,
689                    CacheError::OriginError {
690                        hash,
691                        source: anyhow::anyhow!("origin range encode failed: {other}"),
692                    },
693                ),
694            }
695            guard.disarm();
696            drop(writer);
697        });
698        Ok(Self {
699            rx,
700            fault,
701            ended: false,
702            _task: AbortOnDropHandle::new(task),
703        })
704    }
705
706    /// The next chunk of wire, in order. After the last chunk it yields one
707    /// `Some(Err(_))` if the encode stopped on a fault, and `None` otherwise
708    /// (see the type docs). Every later call yields `None`.
709    pub async fn next_chunk(&mut self) -> Option<CacheResult<Bytes>> {
710        if self.ended {
711            return None;
712        }
713        if let Some(chunk) = self.rx.recv().await {
714            return Some(Ok(chunk));
715        }
716        self.ended = true;
717        self.fault
718            .lock()
719            .unwrap_or_else(PoisonError::into_inner)
720            .take()
721            .map(Err)
722    }
723}
724
725#[cfg(test)]
726#[allow(
727    clippy::unwrap_used,
728    clippy::expect_used,
729    clippy::indexing_slicing,
730    clippy::cast_possible_truncation,
731    reason = "tests"
732)]
733mod tests;