Skip to main content

net/adapter/net/dataforts/blob/
transfer.rs

1//! Blob transfer over router streams (FairScheduler transport plan, T-1).
2//!
3//! On-demand cross-peer blob fetch that moves bytes over the router's
4//! reliable, scheduled streams — NOT RedEX replication (a replication
5//! primitive) and NOT nRPC (a request/reply primitive). See
6//! `docs/plans/FAIRSCHEDULER_TRANSPORT_PLAN.md`.
7//!
8//! T-1 (this slice): the subprotocol ID and the stream-allocation
9//! convention. The control packet that initiates a transfer and the
10//! bulk data both ride [`SUBPROTOCOL_BLOB_TRANSFER`]; transfer streams
11//! draw their IDs from a reserved region of the shared `u64` stream-id
12//! space so they never alias channel-publisher, subprotocol, or control
13//! streams. (T-2 discovery→stream bridge, T-3 serving handler, T-4
14//! receive reassembly land on top.)
15//!
16//! # Stream-id convention
17//!
18//! The substrate's stream-id space is shared (the session keys stream
19//! state and the [`FairScheduler`](crate::adapter::net::router::FairScheduler)
20//! keys queues by raw `stream_id`), with soft conventions per subsystem:
21//!
22//! - **Channel-publisher streams** always SET bit 48
23//!   (`MeshNode::publish_stream_id` = `0x0001_0000_0000_0000 | hash`).
24//! - **Subprotocol streams** use the small subprotocol-id value
25//!   (`< 0x1100`).
26//! - **Control stream** is `u64::MAX` (bit 48 set).
27//!
28//! Transfer streams therefore use **bit 61 set AND bit 48 clear**:
29//! distinct from channel/control streams (which always set bit 48) and
30//! from subprotocol streams (which never set bit 61). The low 48 bits
31//! carry a per-transfer nonce, so bit 48 stays clear by construction.
32
33use std::collections::BTreeMap;
34use std::sync::atomic::{AtomicU64, Ordering};
35use std::sync::{Arc, Weak};
36
37use bytes::Bytes;
38use dashmap::DashMap;
39use serde::{Deserialize, Serialize};
40
41use super::error::BlobError;
42use super::mesh::MeshBlobAdapter;
43use crate::adapter::net::{MeshNode, Reliability, Stream, StreamConfig};
44
45/// Subprotocol ID for blob transfer. Next free family after the
46/// existing `0x04xx..0x10xx` allocations (fold is `0x1000`). Both the
47/// transfer control packet and its bulk data carry this ID so inbound
48/// dispatch routes them to the transfer handler (T-3).
49pub const SUBPROTOCOL_BLOB_TRANSFER: u16 = 0x1100;
50
51/// Marker bit (61) on a transfer stream ID. Combined with bit 48 clear
52/// (channel-publisher / control streams always set bit 48), this keeps
53/// transfer stream IDs disjoint from every other subsystem's streams.
54const TRANSFER_STREAM_FLAG: u64 = 1 << 61;
55
56/// Bit 48 — the channel-publisher discriminator. Transfer stream IDs
57/// keep it CLEAR (their nonce occupies only bits 0..47), which is what
58/// makes them disjoint from channel/control streams.
59const CHANNEL_STREAM_BIT: u64 = 1 << 48;
60
61/// Mask for the per-transfer nonce (bits 0..47). Keeping the nonce
62/// below bit 48 guarantees [`CHANNEL_STREAM_BIT`] stays clear.
63const TRANSFER_NONCE_MASK: u64 = (1 << 48) - 1;
64
65/// Construct a transfer stream ID from a per-transfer `nonce`. Only the
66/// low 48 bits of `nonce` are used.
67pub fn transfer_stream_id(nonce: u64) -> u64 {
68    TRANSFER_STREAM_FLAG | (nonce & TRANSFER_NONCE_MASK)
69}
70
71/// True iff `stream_id` is a blob-transfer stream (bit 61 set, bit 48
72/// clear). Channel/control streams (bit 48 set) and subprotocol streams
73/// (bit 61 clear) both return `false`.
74pub fn is_transfer_stream_id(stream_id: u64) -> bool {
75    stream_id & TRANSFER_STREAM_FLAG != 0 && stream_id & CHANNEL_STREAM_BIT == 0
76}
77
78/// Process-wide nonce source for transfer streams. A monotonic counter
79/// is enough: collisions only at 2^48 concurrent-lifetime transfers,
80/// far beyond any real workload, and a wrapped nonce only risks
81/// aliasing a *still-open* transfer stream (closed ones are cleaned up).
82static TRANSFER_STREAM_NONCE: AtomicU64 = AtomicU64::new(1);
83
84/// Allocate a fresh transfer stream ID (unique within this process for
85/// the next 2^48 allocations).
86pub fn next_transfer_stream_id() -> u64 {
87    let nonce = TRANSFER_STREAM_NONCE.fetch_add(1, Ordering::Relaxed);
88    transfer_stream_id(nonce)
89}
90
91/// Per-data-event byte cap. Kept under `MAX_PAYLOAD_SIZE` (8108) minus
92/// the event-frame length prefix so each raw data event rides one
93/// packet without overflowing the payload, and each `send_on_stream`
94/// of a single event sends exactly one packet (no partial-batch on
95/// backpressure).
96const DATA_FRAME_BYTES: usize = 8000;
97
98/// Tx-credit window for a serving transfer stream, in on-wire bytes.
99///
100/// Sized to ≈ `DEFAULT_MAX_PENDING` (32) frames worth: `DEFAULT_MAX_PENDING
101/// × DATA_FRAME_BYTES` ≈ 256 KiB. Charged on-wire bytes per packet exceed
102/// `DATA_FRAME_BYTES` (Net header + AEAD tag + framing), so this admits
103/// *fewer* than 32 packets in flight.
104///
105/// Since H-1 the reliability **retransmit window auto-sizes from this
106/// tx-window** (`ReliableStream::max_pending_for_window`), so the
107/// "in-flight ≤ retransmit-window" invariant holds automatically for any
108/// window value — an unacked packet aged past the retransmit window
109/// would be evicted and unrecoverable, but the retransmit window now
110/// always covers the flow-control window. This constant therefore no
111/// longer *manually* couples to the fixed 32 (pre-H-1 it had to).
112///
113/// It's kept modest because a larger window buys nothing here: measured
114/// single-stream throughput is bounded by per-datagram loopback latency,
115/// not credit, and concurrent transfers keep the pipe full across
116/// streams. (An earlier 5 MiB window pre-dated H-1's auto-sizing and,
117/// against the then-fixed 32-packet retransmit window, let concurrent
118/// large transfers drop packets past recovery — see
119/// tests/transfer_concurrency.rs.) A multi-MiB chunk refills this window
120/// several times; a refill that finds no credit pays `send_with_retry`'s
121/// backoff, which is negligible on the loopback-latency-bound path.
122const TRANSFER_STREAM_WINDOW_BYTES: u32 =
123    crate::adapter::net::ReliableStream::DEFAULT_MAX_PENDING as u32 * DATA_FRAME_BYTES as u32;
124
125/// Upper bound the receiver accepts for a chunk's `total_len`, so a
126/// misbehaving holder can't claim a huge length and OOM the buffer.
127/// Generous above the 4 MiB single-chunk max.
128const TRANSFER_MAX_CHUNK_BYTES: u64 = 16 * 1024 * 1024;
129
130/// How long a requester waits for a transfer to complete before giving
131/// up (and letting the caller retry another holder).
132const TRANSFER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
133
134/// Retry budget for an individual stream send under backpressure.
135const SEND_RETRIES: usize = 64;
136
137/// Cap on how far ahead of the next-expected sequence the receiver will
138/// buffer out-of-order transfer packets. The sender can't have more than
139/// its tx window (`TRANSFER_STREAM_WINDOW_BYTES` ≈ 32 frames) in flight,
140/// so legitimate reordering never spans more than that; 1024 leaves
141/// margin while bounding the reorder buffer (a far-future seq is dropped
142/// and the sender retransmits it once the gap closes). Also bounds memory
143/// against a misbehaving holder spraying sparse high sequences.
144const MAX_REORDER_AHEAD: u64 = 1024;
145
146// ── Wire frames ────────────────────────────────────────────────────
147
148/// Control frame, carried on a `SUBPROTOCOL_BLOB_TRANSFER` packet with
149/// the transfer stream ID. Sent requester → holder to initiate.
150#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
151pub enum TransferControl {
152    /// "Send me the chunk addressed by `hash` on this stream."
153    Request {
154        /// 32-byte BLAKE3 content address.
155        hash: [u8; 32],
156    },
157}
158
159/// First data-plane event on the transfer stream, holder → requester.
160/// Subsequent events on the stream are raw chunk bytes.
161#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
162pub enum TransferHeader {
163    /// The holder has the chunk; `total_len` bytes follow as raw events.
164    Found {
165        /// Total chunk length the following raw events sum to.
166        total_len: u64,
167    },
168    /// The holder doesn't have the chunk; no bytes follow.
169    NotFound,
170}
171
172// ── Engine ─────────────────────────────────────────────────────────
173
174type DoneTx = tokio::sync::oneshot::Sender<Result<Bytes, BlobError>>;
175
176/// Outcome of folding one reassembly event. Hoisted to module scope so
177/// both `on_data` (drives the loop) and `process_event` (folds one
178/// event) share it without re-acquiring the DashMap guard across a
179/// `finish` (which removes the entry).
180enum ReassembleStep {
181    /// More events expected.
182    Continue,
183    /// Terminal failure (NotFound, over-length, bad header, cap).
184    Fail(BlobError),
185    /// All declared bytes received — verify + deliver.
186    Complete,
187}
188
189/// Requester-side in-flight transfer state, keyed by transfer stream id.
190struct PendingInbound {
191    /// The peer we're fetching from — needed to close the receive-side
192    /// stream once the transfer settles (otherwise streams leak: one
193    /// per fetched chunk, reclaimed only at the 300 s idle timeout,
194    /// which exhausts memory at directory scale).
195    holder: u64,
196    expected_hash: [u8; 32],
197    /// `None` until the `TransferHeader` lands; then the declared length.
198    total_len: Option<u64>,
199    buf: Vec<u8>,
200    /// Next reliable sequence to process. The divert delivers packets in
201    /// ARRIVAL order (the substrate's `on_receive` accepts out-of-order
202    /// sequences for SACK), so the engine reorders by sequence: header is
203    /// seq 0, data frames are seq 1..N in send order.
204    next_seq: u64,
205    /// Out-of-order packets buffered until their sequence becomes
206    /// contiguous, keyed by sequence. Bounded by [`MAX_REORDER_AHEAD`].
207    reorder: BTreeMap<u64, Vec<Bytes>>,
208    /// Taken and fired once on completion (success / NotFound / error).
209    done: Option<DoneTx>,
210}
211
212impl PendingInbound {
213    /// Point-in-time snapshot of this transfer for operator introspection.
214    fn status(&self, stream_id: u64) -> TransferStatus {
215        TransferStatus {
216            stream_id,
217            holder: self.holder,
218            expected_hash: self.expected_hash,
219            bytes_received: self.buf.len() as u64,
220            total_bytes: self.total_len,
221        }
222    }
223}
224
225/// A point-in-time snapshot of one **requester-side** in-flight transfer,
226/// for operator introspection (the `blob.transfers` RPC behind
227/// `net transfer ls` / `status`). Serving-side tasks are not tracked, so
228/// this reflects what the node is currently *fetching*, not what it serves.
229#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
230pub struct TransferStatus {
231    /// Transfer stream id — the transfer's identity / cancel handle.
232    pub stream_id: u64,
233    /// Peer node id the bytes are being fetched from.
234    pub holder: u64,
235    /// BLAKE3 content address being fetched.
236    pub expected_hash: [u8; 32],
237    /// Bytes reassembled so far.
238    pub bytes_received: u64,
239    /// Declared total once the transfer header arrived; `None` before then.
240    pub total_bytes: Option<u64>,
241}
242
243/// Drives blob transfer over router streams (FairScheduler transport).
244/// Installed on a node via
245/// [`crate::adapter::net::MeshNode::serve_blob_transfer`]. Holds a
246/// `Weak<MeshNode>` (to open reply streams without an adapter↔mesh
247/// cycle) and the local [`MeshBlobAdapter`] (for content lookup), plus
248/// the requester-side pending map.
249pub struct BlobTransferEngine {
250    mesh: Weak<MeshNode>,
251    adapter: Arc<MeshBlobAdapter>,
252    pending: DashMap<u64, PendingInbound>,
253}
254
255impl BlobTransferEngine {
256    /// Construct an engine over the local node + adapter.
257    pub fn new(mesh: &Arc<MeshNode>, adapter: Arc<MeshBlobAdapter>) -> Self {
258        Self {
259            mesh: Arc::downgrade(mesh),
260            adapter,
261            pending: DashMap::new(),
262        }
263    }
264
265    /// Register a requester-side pending transfer before the Request is
266    /// sent, so the reply (header/data on `stream_id`) can be matched.
267    /// `holder` is the serving peer, recorded so the receive-side stream
268    /// can be closed when the transfer settles.
269    pub fn register_pending(
270        &self,
271        stream_id: u64,
272        holder: u64,
273        expected_hash: [u8; 32],
274        done: DoneTx,
275    ) {
276        self.pending.insert(
277            stream_id,
278            PendingInbound {
279                holder,
280                expected_hash,
281                total_len: None,
282                buf: Vec::new(),
283                next_seq: 0,
284                reorder: BTreeMap::new(),
285                done: Some(done),
286            },
287        );
288    }
289
290    /// Drop a pending transfer (timeout / give-up). Idempotent. Closes the
291    /// receive-side stream too (mirroring the settle path) so a cancelled
292    /// transfer doesn't linger as a live stream until the 300 s idle
293    /// timeout — the same leak the completion path avoids.
294    pub fn cancel_pending(&self, stream_id: u64) {
295        self.remove_and_close(stream_id);
296    }
297
298    /// Remove a pending transfer and tear down its receive-side stream,
299    /// reporting whether one was present. Shared by [`Self::cancel_pending`]
300    /// and [`Self::cancel_pending_reporting`]; the held `holder` is what
301    /// lets us close the stream without re-deriving the peer.
302    fn remove_and_close(&self, stream_id: u64) -> bool {
303        match self.pending.remove(&stream_id) {
304            Some((_, pending)) => {
305                self.close_receive_stream(pending.holder, stream_id);
306                true
307            }
308            None => false,
309        }
310    }
311
312    /// Snapshot every requester-side in-flight transfer (operator
313    /// introspection — `net transfer ls`). Receiver-side only.
314    pub fn list_pending(&self) -> Vec<TransferStatus> {
315        self.pending
316            .iter()
317            .map(|e| e.value().status(*e.key()))
318            .collect()
319    }
320
321    /// Snapshot one in-flight transfer by stream id, or `None` if it isn't
322    /// pending (already settled, cancelled, or never existed).
323    pub fn get_pending(&self, stream_id: u64) -> Option<TransferStatus> {
324        self.pending
325            .get(&stream_id)
326            .map(|e| e.value().status(stream_id))
327    }
328
329    /// Like [`Self::cancel_pending`] but reports whether a transfer was
330    /// actually removed — for the operator cancel surface, which
331    /// distinguishes "cancelled" from "no such transfer". Closes the
332    /// receive-side stream and drops the entry's `done` sender, failing the
333    /// awaiting fetch.
334    pub fn cancel_pending_reporting(&self, stream_id: u64) -> bool {
335        self.remove_and_close(stream_id)
336    }
337
338    /// The holder's reliable layer gave up retransmitting this transfer
339    /// stream (STREAM_RETRANSMIT H-3 reset). Fail the pending read now
340    /// with a distinct error so the caller can fail over to another
341    /// holder immediately instead of waiting for the 30 s timeout.
342    /// No-op if the transfer already settled.
343    pub fn on_reset(&self, stream_id: u64) {
344        self.finish(
345            stream_id,
346            Err(BlobError::Backend(
347                "transfer: holder reset stream (retransmit exhausted)".into(),
348            )),
349        );
350    }
351
352    /// Serving side: a `TransferControl::Request` arrived on `stream_id`
353    /// from `requester`. Spawn a task that reads the chunk locally and
354    /// streams it back on the same (transfer) stream.
355    ///
356    /// # Authorization model: possession-of-hash is the capability
357    ///
358    /// A transfer is **content-addressed** — the request names a 32-byte
359    /// BLAKE3 hash, not a channel. A blob can belong to many channels (or
360    /// none), so channel-scoped read-auth doesn't map onto a bare hash.
361    /// The deliberate model (chosen over channel-auth / capability
362    /// tokens) is **possession-of-hash**: a peer that presents a valid
363    /// content hash may fetch the bytes that hash to it. The 256-bit
364    /// BLAKE3 digest is an unguessable bearer capability — you cannot
365    /// enumerate or forge it, so knowing it is itself the grant.
366    ///
367    /// Two substrate guarantees backstop this, both already enforced:
368    /// 1. **Authenticated session.** This handler only runs for a packet
369    ///    that AEAD-decrypted under an established session with a
370    ///    resolved `requester` (the dispatch branch rejects `from_node
371    ///    == 0`), so an unauthenticated/forged peer never reaches here.
372    /// 2. **Established peer for the reply.** `serve_chunk` streams the
373    ///    bytes via `MeshNode::open_stream(requester, …)`, which requires
374    ///    `requester` to be a connected peer — bytes never flow to an
375    ///    unknown origin.
376    ///
377    /// **Caveat (by design):** the hash is a *bearer* token — anyone who
378    /// learns it can fetch the content from any holder. Callers that need
379    /// stronger confinement must treat content hashes for sensitive blobs
380    /// as secrets (don't log/publish them to parties who shouldn't read
381    /// the content), or layer channel/capability auth above this transport.
382    pub fn on_request(&self, requester: u64, stream_id: u64, payload: &[u8]) {
383        let control: TransferControl = match postcard::from_bytes(payload) {
384            Ok(c) => c,
385            Err(e) => {
386                tracing::debug!(error = %e, requester, "blob transfer: bad control frame");
387                return;
388            }
389        };
390        let TransferControl::Request { hash } = control;
391        let Some(mesh) = self.mesh.upgrade() else {
392            return;
393        };
394        let adapter = self.adapter.clone();
395        tokio::spawn(async move {
396            serve_chunk(mesh, adapter, requester, stream_id, hash).await;
397        });
398    }
399
400    /// Requester side: a transfer packet at reliable sequence `seq` was
401    /// diverted here. **Events arrive in transmission order only when the
402    /// wire didn't reorder** — the substrate's `on_receive` accepts
403    /// out-of-order sequences (for SACK), and the divert hands them over
404    /// in arrival order, so this method reorders by `seq` itself: it
405    /// buffers out-of-order packets and processes events strictly in
406    /// sequence (header = seq 0, data = seq 1..N). Duplicates (seq already
407    /// processed or buffered) and far-future seqs are dropped; the sender
408    /// retransmits a dropped far-future packet once the gap closes.
409    pub fn on_data(&self, stream_id: u64, seq: u64, events: Vec<Bytes>) {
410        let outcome = {
411            let mut entry = match self.pending.get_mut(&stream_id) {
412                Some(e) => e,
413                None => return, // already completed / cancelled
414            };
415            // Dedup + bound: ignore already-consumed sequences, duplicate
416            // buffered ones, and anything beyond the reorder horizon.
417            if seq < entry.next_seq
418                || entry.reorder.contains_key(&seq)
419                || seq >= entry.next_seq.saturating_add(MAX_REORDER_AHEAD)
420            {
421                return;
422            }
423            entry.reorder.insert(seq, events);
424
425            // Release every now-contiguous packet in sequence order,
426            // processing its events until one is terminal.
427            let mut outcome = ReassembleStep::Continue;
428            loop {
429                let ns = entry.next_seq;
430                let Some(ready) = entry.reorder.remove(&ns) else {
431                    break;
432                };
433                entry.next_seq += 1;
434                for event in &ready {
435                    outcome = Self::process_event(&mut entry, event);
436                    if !matches!(outcome, ReassembleStep::Continue) {
437                        break;
438                    }
439                }
440                if !matches!(outcome, ReassembleStep::Continue) {
441                    break;
442                }
443            }
444            outcome
445        };
446        match outcome {
447            ReassembleStep::Continue => {}
448            ReassembleStep::Fail(err) => self.finish(stream_id, Err(err)),
449            ReassembleStep::Complete => self.finish_verified(stream_id),
450        }
451    }
452
453    /// Fold one in-sequence event into the pending reassembly: the first
454    /// event (seq 0) is the [`TransferHeader`]; the rest are raw chunk
455    /// bytes appended in order.
456    fn process_event(entry: &mut PendingInbound, event: &Bytes) -> ReassembleStep {
457        if entry.total_len.is_none() {
458            match postcard::from_bytes::<TransferHeader>(event) {
459                Ok(TransferHeader::NotFound) => {
460                    ReassembleStep::Fail(BlobError::NotFound("transfer: holder NotFound".into()))
461                }
462                Ok(TransferHeader::Found { total_len }) if total_len > TRANSFER_MAX_CHUNK_BYTES => {
463                    ReassembleStep::Fail(BlobError::Backend(format!(
464                        "transfer: total_len {total_len} exceeds cap"
465                    )))
466                }
467                Ok(TransferHeader::Found { total_len }) => {
468                    entry.total_len = Some(total_len);
469                    entry
470                        .buf
471                        .reserve(total_len.min(TRANSFER_MAX_CHUNK_BYTES) as usize);
472                    if total_len == 0 {
473                        ReassembleStep::Complete
474                    } else {
475                        ReassembleStep::Continue
476                    }
477                }
478                Err(e) => {
479                    ReassembleStep::Fail(BlobError::Backend(format!("transfer: bad header: {e}")))
480                }
481            }
482        } else {
483            let total = entry.total_len.unwrap_or(0);
484            if (entry.buf.len() as u64).saturating_add(event.len() as u64) > total {
485                ReassembleStep::Fail(BlobError::Backend(
486                    "transfer: holder sent more than total_len".into(),
487                ))
488            } else {
489                entry.buf.extend_from_slice(event);
490                if entry.buf.len() as u64 >= total {
491                    ReassembleStep::Complete
492                } else {
493                    ReassembleStep::Continue
494                }
495            }
496        }
497    }
498
499    /// Remove the pending entry and fire its oneshot with `result`.
500    fn finish(&self, stream_id: u64, result: Result<Bytes, BlobError>) {
501        if let Some((_, mut pending)) = self.pending.remove(&stream_id) {
502            if let Some(tx) = pending.done.take() {
503                let _ = tx.send(result);
504            }
505            self.close_receive_stream(pending.holder, stream_id);
506        }
507    }
508
509    /// Remove the pending entry, verify the assembled bytes against the
510    /// expected hash, and fire its oneshot.
511    fn finish_verified(&self, stream_id: u64) {
512        let Some((_, mut pending)) = self.pending.remove(&stream_id) else {
513            return;
514        };
515        let bytes = std::mem::take(&mut pending.buf);
516        let result = {
517            let computed: [u8; 32] = blake3::hash(&bytes).into();
518            if computed == pending.expected_hash {
519                Ok(Bytes::from(bytes))
520            } else {
521                Err(BlobError::HashMismatch {
522                    expected: pending.expected_hash,
523                    actual: computed,
524                })
525            }
526        };
527        if let Some(tx) = pending.done.take() {
528            let _ = tx.send(result);
529        }
530        self.close_receive_stream(pending.holder, stream_id);
531    }
532
533    /// Tear down the receive-side stream once a transfer settles. The
534    /// data is fully received (or the transfer failed), so no more
535    /// packets are expected; reclaiming the stream keeps a high-file-
536    /// count directory pull from accumulating one live stream per chunk
537    /// until the 300 s idle timeout (which exhausts memory at scale). A
538    /// late retransmit after close is harmless — it re-creates an empty
539    /// stream that finds no pending entry and idles out.
540    fn close_receive_stream(&self, holder: u64, stream_id: u64) {
541        if let Some(mesh) = self.mesh.upgrade() {
542            mesh.close_stream(holder, stream_id);
543        }
544    }
545}
546
547/// Serving-side: read `hash` locally and stream it to `requester` on
548/// `stream_id` over a reliable, scheduled stream (FairScheduler).
549async fn serve_chunk(
550    mesh: Arc<MeshNode>,
551    adapter: Arc<MeshBlobAdapter>,
552    requester: u64,
553    stream_id: u64,
554    hash: [u8; 32],
555) {
556    let cfg = StreamConfig::new()
557        .with_reliability(Reliability::Reliable)
558        .with_scheduled(true)
559        .with_window_bytes(TRANSFER_STREAM_WINDOW_BYTES)
560        .with_fairness_weight(1);
561    // `open_stream` requires `requester` to be a connected peer (an
562    // established, authenticated session), so this is also the
563    // authorization gate for the possession-of-hash model (see
564    // `BlobTransferEngine::on_request`): bytes only ever flow to a peer
565    // we have a live session with, and only for the exact hash it asked.
566    let stream = match mesh.open_stream(requester, stream_id, cfg) {
567        Ok(s) => s,
568        Err(e) => {
569            tracing::debug!(error = %e, requester, "blob transfer: open reply stream failed");
570            return;
571        }
572    };
573
574    // `fetch_chunk` here is the local content-addressed read (this
575    // branch has no peer-fetch fallback on the adapter), so a serving
576    // node always answers from its own store — no recursion risk.
577    let local = adapter.fetch_chunk(&hash).await;
578    match local {
579        Ok(bytes) => {
580            let header = TransferHeader::Found {
581                total_len: bytes.len() as u64,
582            };
583            if send_one(&mesh, &stream, postcard_event(&header))
584                .await
585                .is_ok()
586            {
587                // One reliable event per ~8 KiB frame. Because
588                // `TRANSFER_STREAM_WINDOW_BYTES` covers a whole chunk's
589                // on-wire size, the per-event credit never runs dry
590                // mid-chunk, so these sends don't stall into
591                // `send_with_retry`'s backoff (the 64 KiB default window
592                // exhausted every ~8 frames and each stall paid ≥5 ms
593                // even though the receiver's grant lands in <1 ms).
594                // Per-event (not batched) keeps each `send_with_retry`
595                // independently safe: a one-packet call can't partially
596                // commit and then resend a duplicate under a fresh
597                // sequence on retry.
598                // PERF_AUDIT §6.6 — `bytes` is already a refcounted
599                // `Bytes`; slice into it instead of copying each
600                // 8 KiB frame. A 16 MiB chunk served to a peer
601                // previously paid ~2048 allocations + a full 16 MiB
602                // memcpy here; with slicing it pays N refcount
603                // bumps and zero memcpy. `Bytes::slice` returns a
604                // sub-view that keeps the original buffer alive,
605                // which is exactly what we want — the source
606                // `bytes` stays in scope for the whole loop.
607                let total = bytes.len();
608                let mut offset = 0;
609                while offset < total {
610                    let end = (offset + DATA_FRAME_BYTES).min(total);
611                    if send_one(&mesh, &stream, bytes.slice(offset..end))
612                        .await
613                        .is_err()
614                    {
615                        break;
616                    }
617                    offset = end;
618                }
619            }
620        }
621        Err(_) => {
622            // Absent locally or local read error → NotFound (never serve
623            // suspect bytes). The requester fails over to another holder.
624            let _ = send_one(&mesh, &stream, postcard_event(&TransferHeader::NotFound)).await;
625        }
626    }
627
628    // Close gracefully (H-7): wait until the receiver has acked every
629    // sent byte (so NACK-driven resends can still fill gaps) before
630    // tearing down the retransmit window — closing eagerly strands a lost
631    // tail packet on a lossy link. Bounded by `TRANSFER_TIMEOUT` so a
632    // vanished receiver can't pin the stream; reclaiming it also stops
633    // directory-scale fan-out from leaking one live stream per chunk.
634    mesh.close_stream_graceful(requester, stream_id, TRANSFER_TIMEOUT)
635        .await;
636}
637
638fn postcard_event<T: Serialize>(value: &T) -> Bytes {
639    Bytes::from(postcard::to_allocvec(value).unwrap_or_default())
640}
641
642async fn send_one(mesh: &Arc<MeshNode>, stream: &Stream, event: Bytes) -> Result<(), ()> {
643    mesh.send_with_retry(stream, std::slice::from_ref(&event), SEND_RETRIES)
644        .await
645        .map_err(|e| {
646            tracing::debug!(error = %e, "blob transfer: stream send failed");
647        })
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653
654    #[test]
655    fn transfer_ids_are_disjoint_from_channel_and_control_streams() {
656        // Channel-publisher streams always set bit 48.
657        let channel_like = CHANNEL_STREAM_BIT | 0xDEAD_BEEF_CAFE;
658        assert!(!is_transfer_stream_id(channel_like));
659        // Control stream is u64::MAX (bit 48 set).
660        assert!(!is_transfer_stream_id(u64::MAX));
661        // Subprotocol streams are small (bit 61 clear).
662        assert!(!is_transfer_stream_id(SUBPROTOCOL_BLOB_TRANSFER as u64));
663        assert!(!is_transfer_stream_id(0x1000));
664    }
665
666    #[test]
667    fn transfer_ids_round_trip_and_self_identify() {
668        for nonce in [1u64, 42, 0xFFFF, (1 << 48) - 1] {
669            let id = transfer_stream_id(nonce);
670            assert!(is_transfer_stream_id(id), "id {id:#x} must self-identify");
671            // bit 48 clear by construction.
672            assert_eq!(id & CHANNEL_STREAM_BIT, 0);
673            // bit 61 set.
674            assert_ne!(id & TRANSFER_STREAM_FLAG, 0);
675        }
676    }
677
678    #[test]
679    fn allocator_yields_distinct_transfer_ids() {
680        let a = next_transfer_stream_id();
681        let b = next_transfer_stream_id();
682        assert_ne!(a, b);
683        assert!(is_transfer_stream_id(a) && is_transfer_stream_id(b));
684    }
685
686    /// The introspection accessors behind `blob.transfers` (list / get /
687    /// cancel-reporting) must reflect `register_pending` and report removal.
688    #[tokio::test]
689    async fn engine_accessors_report_and_cancel_pending() {
690        use crate::adapter::net::identity::EntityKeypair;
691        use crate::adapter::net::redex::Redex;
692        use crate::adapter::net::MeshNodeConfig;
693
694        let addr = "127.0.0.1:0".parse().expect("addr");
695        let node = Arc::new(
696            MeshNode::new(
697                EntityKeypair::generate(),
698                MeshNodeConfig::new(addr, [0x17u8; 32]),
699            )
700            .await
701            .expect("node"),
702        );
703        let adapter = Arc::new(MeshBlobAdapter::new("t", Arc::new(Redex::new())));
704        let engine = BlobTransferEngine::new(&node, adapter);
705
706        assert!(engine.list_pending().is_empty());
707
708        let sid = transfer_stream_id(99);
709        let (tx, _rx) = tokio::sync::oneshot::channel();
710        engine.register_pending(sid, 7, [0xABu8; 32], tx);
711
712        let listed = engine.list_pending();
713        assert_eq!(listed.len(), 1);
714        assert_eq!(listed[0].stream_id, sid);
715        assert_eq!(listed[0].holder, 7);
716        assert_eq!(listed[0].expected_hash, [0xABu8; 32]);
717        assert_eq!(listed[0].bytes_received, 0);
718        assert_eq!(listed[0].total_bytes, None);
719
720        let got = engine.get_pending(sid).expect("pending present");
721        assert_eq!(got.holder, 7);
722        assert!(engine.get_pending(transfer_stream_id(1234)).is_none());
723
724        // Cancel reports existence once, then is idempotently false.
725        assert!(engine.cancel_pending_reporting(sid));
726        assert!(!engine.cancel_pending_reporting(sid));
727        assert!(engine.list_pending().is_empty());
728    }
729}