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}