Skip to main content

runsync_transfer/
send.rs

1//! Sender pipeline.
2//!
3//! Shape of the thing:
4//!
5//! ```text
6//!   ChunkCursor ──jobs──> rayon pool (read → compress → seal) ──> N stream tasks ──> wire
7//!        │                        (cfg.workers threads)              (cfg.streams)
8//!    one lock per chunk                                        queue_depth in flight each
9//! ```
10//!
11//! The cursor hands out one chunk at a time and holds the open reader for the
12//! file it is currently walking, so the encode workers never open a file, never
13//! share a file cursor, and never consult a cache. Each stream task keeps
14//! `queue_depth` encodes in flight, which is what bounds memory: nothing scales
15//! with file size, only with `streams × queue_depth × chunk_size`.
16
17use crate::codec::compress::{self, FileHint};
18use crate::codec::crypto::{Handshake, Role, Sealer, HANDSHAKE_MSG_LEN};
19use crate::config::Config;
20use crate::error::{Error, Result};
21use crate::io::ChunkReader;
22use crate::manifest::{self, Manifest, Source};
23use crate::metrics::{Metrics, Progress, ProgressFn};
24use crate::pool::{BufPool, ObjPool};
25use crate::resume::ChunkBitmap;
26use crate::transport::{BoxRecv, BoxSend, Transport};
27use crate::wire::{self, Control, EntryKind, FrameHeader};
28use futures_util::stream::{FuturesOrdered, StreamExt};
29use parking_lot::Mutex;
30use std::collections::HashMap;
31use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
32use std::sync::Arc;
33use tokio::io::AsyncWriteExt;
34use tokio::sync::mpsc;
35
36/// One unit of work: a byte range of one file.
37struct ChunkJob {
38    file_id: u32,
39    chunk_index: u64,
40    offset: u64,
41    len: usize,
42    last: bool,
43    /// The receiver already holds this chunk. It still has to be read and
44    /// hashed so the file's Merkle root covers the whole file, but nothing is
45    /// compressed, sealed, or sent.
46    hash_only: bool,
47    /// Shared per-file state, carrying the running compression verdict.
48    progress: Option<Arc<FileProgress>>,
49    /// What the receiver already holds at this chunk index, if anything. A
50    /// match means the chunk need not be sent at all.
51    peer_hash: Option<[u8; 32]>,
52    /// What we hashed this chunk to last time. When it equals `peer_hash`,
53    /// neither side needs to read the file at all.
54    cached_hash: Option<[u8; 32]>,
55    reader: Arc<ChunkReader>,
56    hint: FileHint,
57}
58
59/// An encoded chunk, ready to hand to a stream. `frame` already contains the
60/// header, so a chunk is exactly one write.
61struct EncodedChunk {
62    frame: Vec<u8>,
63    file_id: u32,
64    chunk_index: u64,
65    raw_len: usize,
66    compressed: bool,
67    /// BLAKE3 of the plaintext, folded into the file's Merkle root.
68    hash: [u8; 32],
69    /// Hashed but not sent; `frame` is empty.
70    skipped: bool,
71    /// Sent as "you already have this" rather than as data.
72    reused: bool,
73    /// The compressor was invoked for this chunk, win or lose.
74    compressor_ran: bool,
75}
76
77/// Is every byte zero?
78///
79/// Public so benchmarks measure the implementation that actually ships; a
80/// private copy in a bench file is a benchmark of the bench file.
81///
82/// Two stages, and no `unsafe`. Real data almost always has a non-zero byte in
83/// its first cache line, so the prefix check rejects it after a single load. A
84/// genuine hole then runs the OR-fold, which has no per-word branch and so
85/// vectorises — unlike a short-circuiting `all()`, which does not.
86#[inline]
87pub fn is_all_zero(buf: &[u8]) -> bool {
88    const PREFIX: usize = 64;
89    if buf.len() >= PREFIX && buf[..PREFIX].iter().any(|&b| b != 0) {
90        return false;
91    }
92    let mut words = buf.chunks_exact(8);
93    let folded = words
94        .by_ref()
95        .map(|w| u64::from_ne_bytes(w.try_into().expect("chunks_exact(8) yields 8 bytes")))
96        .fold(0u64, |acc, w| acc | w);
97    folded == 0 && words.remainder().iter().all(|&b| b == 0)
98}
99
100/// Walks the manifest producing jobs, skipping whatever the receiver already has.
101struct ChunkCursor {
102    entry_idx: usize,
103    chunk_index: u64,
104    reader: Option<Arc<ChunkReader>>,
105    hint: FileHint,
106}
107
108struct CursorShared {
109    manifest: Arc<Manifest>,
110    /// file_id -> chunks the peer already holds.
111    resume: HashMap<u32, ChunkBitmap>,
112    state: Mutex<ChunkCursor>,
113    metrics: Metrics,
114    /// Per-file count of chunks not yet written to a stream. Reaching zero is
115    /// what lets us announce the file as complete.
116    outstanding: HashMap<u32, Arc<FileProgress>>,
117    /// Mirrors `Config::verify_hashes`; decides whether resumed chunks still
118    /// have to be read for their hash.
119    verify_hashes: bool,
120    /// Per-chunk hashes of what the receiver already has, by file.
121    peer_index: HashMap<u32, Vec<[u8; 32]>>,
122    /// Our own remembered hashes, by file. Lets an unchanged chunk be
123    /// recognised without opening the source.
124    own_index: HashMap<u32, Vec<[u8; 32]>>,
125}
126
127/// How many chunks to try before deciding whether a file compresses at all.
128const COMPRESSION_PROBE_CHUNKS: u32 = 8;
129/// Below this fraction of winning chunks, stop trying for the rest of the file.
130const COMPRESSION_WIN_RATE: f32 = 0.25;
131
132struct FileProgress {
133    remaining: AtomicU64,
134    chunk_hashes: Mutex<Vec<[u8; 32]>>,
135    announced: AtomicBool,
136    /// Chunks we have actually run the compressor on for this file.
137    probe_attempts: AtomicU32,
138    /// Of those, how many beat `min_gain` and were sent compressed.
139    probe_wins: AtomicU32,
140    /// Latched once the probe concludes the file is not worth compressing.
141    give_up: AtomicBool,
142}
143
144impl FileProgress {
145    /// Should the next chunk of this file be handed to the compressor?
146    ///
147    /// The per-chunk entropy probe is cheap but only samples 16 KiB, so it lets
148    /// through content that compresses *slightly* — and slightly is the worst
149    /// case: the codec runs at full cost and `min_gain` then discards the
150    /// result. PCM audio sits exactly there, at roughly a 5% gain against a 6%
151    /// gate, which is most of what a music transfer carries.
152    ///
153    /// So the decision is also made per file: try the first few chunks, and if
154    /// almost none of them pay off, stop trying for the rest of that file.
155    fn should_compress(&self) -> bool {
156        !self.give_up.load(Ordering::Relaxed)
157    }
158
159    /// Feed one chunk's outcome back into the per-file decision.
160    fn record_compression(&self, won: bool) {
161        if self.give_up.load(Ordering::Relaxed) {
162            return;
163        }
164        if won {
165            self.probe_wins.fetch_add(1, Ordering::Relaxed);
166        }
167        let attempts = self.probe_attempts.fetch_add(1, Ordering::Relaxed) + 1;
168        if attempts < COMPRESSION_PROBE_CHUNKS {
169            return;
170        }
171        let wins = self.probe_wins.load(Ordering::Relaxed);
172        if (wins as f32) < attempts as f32 * COMPRESSION_WIN_RATE {
173            // Latch, so the remaining chunks skip the codec entirely.
174            self.give_up.store(true, Ordering::Relaxed);
175        }
176    }
177}
178
179impl CursorShared {
180    /// Next job, or `None` when the manifest is exhausted.
181    ///
182    /// Returns `Err` only for an unreadable source file, which aborts the
183    /// transfer rather than silently shipping a short file.
184    fn next_job(&self) -> Result<Option<ChunkJob>> {
185        let mut st = self.state.lock();
186        loop {
187            let Some(entry) = self.manifest.entries.get(st.entry_idx) else {
188                return Ok(None);
189            };
190
191            if entry.kind != EntryKind::File || entry.size == 0 {
192                st.entry_idx += 1;
193                st.chunk_index = 0;
194                st.reader = None;
195                continue;
196            }
197
198            let total = entry.chunk_count();
199            if st.chunk_index >= total {
200                st.entry_idx += 1;
201                st.chunk_index = 0;
202                st.reader = None;
203                continue;
204            }
205
206            // Open on first use, keep for the rest of the file, drop at its end.
207            if st.reader.is_none() {
208                let path = &self.manifest.local_paths[st.entry_idx];
209                let r = ChunkReader::open(path)?;
210                // A file that changed size since the scan would produce short
211                // or misaligned chunks; better to fail loudly.
212                if r.len() != entry.size {
213                    return Err(Error::Io(std::io::Error::other(format!(
214                        "{} changed size during transfer ({} -> {})",
215                        path.display(),
216                        entry.size,
217                        r.len()
218                    ))));
219                }
220                st.reader = Some(Arc::new(r));
221                st.hint = FileHint {
222                    known_incompressible: entry.incompressible,
223                    audio: self.manifest.audio.get(st.entry_idx).copied().flatten(),
224                    chunk_offset: 0,
225                };
226            }
227
228            let idx = st.chunk_index;
229            st.chunk_index += 1;
230
231            let chunk_size = entry.chunk_size as u64;
232            let offset = idx * chunk_size;
233            let len = chunk_size.min(entry.size - offset) as usize;
234            let last = idx + 1 == total;
235
236            // Already on the far side from an earlier attempt.
237            let already_there = self
238                .resume
239                .get(&entry.file_id)
240                .is_some_and(|bm| bm.get(idx));
241
242            if already_there && !self.verify_hashes {
243                // Nothing to compute, so do not even open the bytes.
244                self.metrics.chunk_skipped(len as u64);
245                if let Some(fp) = self.outstanding.get(&entry.file_id) {
246                    fp.remaining.fetch_sub(1, Ordering::AcqRel);
247                }
248                continue;
249            }
250
251            return Ok(Some(ChunkJob {
252                file_id: entry.file_id,
253                chunk_index: idx,
254                offset,
255                len,
256                last,
257                hash_only: already_there,
258                progress: self.outstanding.get(&entry.file_id).cloned(),
259                peer_hash: self
260                    .peer_index
261                    .get(&entry.file_id)
262                    .and_then(|h| h.get(idx as usize))
263                    .copied(),
264                cached_hash: self
265                    .own_index
266                    .get(&entry.file_id)
267                    .and_then(|h| h.get(idx as usize))
268                    .copied(),
269                reader: st.reader.clone().expect("reader opened above"),
270                hint: FileHint {
271                    chunk_offset: offset,
272                    ..st.hint
273                },
274            }));
275        }
276    }
277}
278
279/// Read, compress, and seal one chunk. Runs on a rayon worker.
280fn encode_chunk(
281    job: &ChunkJob,
282    cfg: &Config,
283    sealer: &mut Sealer,
284    pool: &BufPool,
285) -> Result<EncodedChunk> {
286    // Both sides already know this chunk by the same hash, so there is nothing
287    // to read, compress, seal or send. This is the case that makes re-syncing
288    // an unchanged tree cost a `stat` per file instead of a full pass over it.
289    if let (Some(mine), Some(theirs)) = (job.cached_hash, job.peer_hash) {
290        if mine == theirs {
291            let mut frame = pool.take();
292            frame.resize(wire::FRAME_HEADER_LEN, 0);
293            let mut flags = wire::flags::REUSE_LOCAL;
294            if job.last {
295                flags |= wire::flags::LAST_CHUNK;
296            }
297            let header = FrameHeader {
298                flags,
299                algorithm: compress::Algorithm::None,
300                file_id: job.file_id,
301                chunk_index: job.chunk_index,
302                epoch: 0,
303                raw_len: job.len as u32,
304                payload_len: 0,
305            };
306            let head: &mut [u8; wire::FRAME_HEADER_LEN] = (&mut frame[..])
307                .try_into()
308                .expect("frame is exactly a header");
309            header.encode(head);
310            return Ok(EncodedChunk {
311                frame,
312                file_id: job.file_id,
313                chunk_index: job.chunk_index,
314                raw_len: job.len,
315                compressed: false,
316                hash: mine,
317                skipped: false,
318                reused: true,
319                compressor_ran: false,
320            });
321        }
322    }
323
324    // Read straight into the frame buffer, after the space the header will
325    // occupy. Everything downstream — hashing, the zero scan, compression, the
326    // AEAD — then works in place, so a chunk that ships uncompressed (every
327    // chunk of a .flac or .mp4) is never copied at all.
328    const HDR: usize = wire::FRAME_HEADER_LEN;
329    let mut frame = pool.take();
330    frame.resize(HDR + job.len, 0);
331    let n = job.reader.read_at(job.offset, &mut frame[HDR..])?;
332    if n != job.len {
333        return Err(Error::Io(std::io::Error::new(
334            std::io::ErrorKind::UnexpectedEof,
335            format!(
336                "short read at offset {} of file {}: wanted {}, got {n}",
337                job.offset, job.file_id, job.len
338            ),
339        )));
340    }
341
342    let hash = *blake3::hash(&frame[HDR..]).as_bytes();
343
344    if job.hash_only {
345        // The receiver has these bytes already. Hashing them is what lets the
346        // file's Merkle root still cover the whole file after a resume.
347        let raw_len = frame.len() - HDR;
348        pool.put(frame);
349        return Ok(EncodedChunk {
350            frame: Vec::new(),
351            file_id: job.file_id,
352            chunk_index: job.chunk_index,
353            raw_len,
354            compressed: false,
355            hash,
356            skipped: true,
357            reused: false,
358            compressor_ran: false,
359        });
360    }
361
362    // The receiver already has these exact bytes at this offset in its older
363    // copy. Say so in 28 bytes rather than sending a megabyte of what it holds.
364    if job.peer_hash == Some(hash) {
365        let raw_len = frame.len() - HDR;
366        frame.truncate(HDR);
367        let mut flags = wire::flags::REUSE_LOCAL;
368        if job.last {
369            flags |= wire::flags::LAST_CHUNK;
370        }
371        let header = FrameHeader {
372            flags,
373            algorithm: compress::Algorithm::None,
374            file_id: job.file_id,
375            chunk_index: job.chunk_index,
376            epoch: 0,
377            raw_len: raw_len as u32,
378            payload_len: 0,
379        };
380        let head: &mut [u8; wire::FRAME_HEADER_LEN] = (&mut frame[..])
381            .try_into()
382            .expect("frame is exactly a header");
383        header.encode(head);
384        return Ok(EncodedChunk {
385            frame,
386            file_id: job.file_id,
387            chunk_index: job.chunk_index,
388            raw_len,
389            compressed: false,
390            hash,
391            skipped: false,
392            reused: true,
393            compressor_ran: false,
394        });
395    }
396
397    // A hole travels as a flag. The receiver's file is already zero-filled from
398    // its preallocation, so there is nothing to send and nothing to write.
399    if cfg.sparse && is_all_zero(&frame[HDR..]) {
400        let raw_len = frame.len() - HDR;
401        frame.truncate(HDR);
402        let mut flags = wire::flags::ZERO;
403        if job.last {
404            flags |= wire::flags::LAST_CHUNK;
405        }
406        let header = FrameHeader {
407            flags,
408            algorithm: compress::Algorithm::None,
409            file_id: job.file_id,
410            chunk_index: job.chunk_index,
411            epoch: 0,
412            raw_len: raw_len as u32,
413            payload_len: 0,
414        };
415        let head: &mut [u8; wire::FRAME_HEADER_LEN] = (&mut frame[..])
416            .try_into()
417            .expect("frame is exactly a header");
418        header.encode(head);
419        return Ok(EncodedChunk {
420            frame,
421            file_id: job.file_id,
422            chunk_index: job.chunk_index,
423            raw_len,
424            compressed: false,
425            hash,
426            skipped: false,
427            reused: false,
428            compressor_ran: false,
429        });
430    }
431
432    // A file that has already shown it does not compress skips the codec for
433    // its remaining chunks, rather than paying for it and discarding the result.
434    let mut hint = job.hint;
435    let probing =
436        !hint.known_incompressible && job.progress.as_ref().is_some_and(|p| !p.should_compress());
437    if probing {
438        hint.known_incompressible = true;
439    }
440
441    let enc =
442        compress::with_codec(|c| c.compress_in_place(&cfg.compression, hint, &mut frame, HDR))?;
443
444    if !hint.known_incompressible {
445        if let Some(p) = job.progress.as_ref() {
446            p.record_compression(enc.algorithm != compress::Algorithm::None);
447        }
448    }
449
450    let payload_len = frame.len() - HDR;
451    let sealed = !sealer.is_passthrough();
452
453    let mut flags = 0u8;
454    if job.last {
455        flags |= wire::flags::LAST_CHUNK;
456    }
457    if sealed {
458        flags |= wire::flags::SEALED;
459    }
460
461    let header = FrameHeader {
462        flags,
463        algorithm: enc.algorithm,
464        file_id: job.file_id,
465        chunk_index: job.chunk_index,
466        // Chunks are encoded exactly once per session, so the epoch is always
467        // zero here; the field exists so a future retry path can vary the
468        // nonce without reusing one.
469        epoch: 0,
470        raw_len: enc.raw_len as u32,
471        payload_len: payload_len as u32,
472    };
473    let (head, body) = frame.split_at_mut(wire::FRAME_HEADER_LEN);
474    let head: &mut [u8; wire::FRAME_HEADER_LEN] = head.try_into().expect("split at header length");
475    header.encode(head);
476
477    if sealed {
478        // The header is authenticated but sent in the clear, so a peer cannot
479        // re-point this chunk at a different file or offset undetected.
480        let tag = sealer.seal(job.file_id, job.chunk_index, 0, head, body)?;
481        frame.extend_from_slice(&tag);
482    }
483
484    Ok(EncodedChunk {
485        frame,
486        file_id: job.file_id,
487        chunk_index: job.chunk_index,
488        raw_len: enc.raw_len,
489        compressed: enc.algorithm != compress::Algorithm::None,
490        hash,
491        skipped: false,
492        reused: false,
493        compressor_ran: !hint.known_incompressible,
494    })
495}
496
497/// Send `sources` over `transport`.
498pub async fn send(
499    transport: Arc<dyn Transport>,
500    sources: &[Source],
501    cfg: &Config,
502    progress: Option<ProgressFn>,
503) -> Result<Progress> {
504    cfg.validate()?;
505    let metrics = Metrics::new();
506
507    // Control stream first: everything else is negotiated over it.
508    let (mut ctl_w, mut ctl_r) = transport.open_bi().await?;
509
510    let hs = Handshake::new(Role::Initiator, &cfg.secrecy, cfg.cipher);
511    ctl_w.write_all(hs.message()).await?;
512    ctl_w.flush().await?;
513    let mut peer = [0u8; HANDSHAKE_MSG_LEN];
514    tokio::time::timeout(cfg.handshake_timeout, read_exact(&mut ctl_r, &mut peer))
515        .await
516        .map_err(|_| Error::Handshake("timed out waiting for the peer's handshake".into()))??;
517    let crypto = Arc::new(hs.finish(&peer)?);
518
519    // Scanning can be slow on a large tree; the peer is already waiting on the
520    // control stream, so nothing is blocked by doing it here.
521    let manifest = Arc::new(manifest::build(sources, cfg).await?);
522    metrics.set_totals(manifest.file_count() as u64, manifest.total_bytes());
523    tracing::info!(
524        files = manifest.file_count(),
525        bytes = manifest.total_bytes(),
526        peer = %transport.peer_label(),
527        "manifest ready"
528    );
529
530    wire::write_control(&mut ctl_w, &Control::Manifest(manifest.entries.clone())).await?;
531
532    // The receiver answers with the blocks it already holds, then with its
533    // resume state. The index may arrive as several messages.
534    let mut peer_index: HashMap<u32, Vec<[u8; 32]>> = HashMap::new();
535    let resume = loop {
536        match wire::read_control(&mut ctl_r, cfg.max_frame_bytes, cfg.max_manifest_entries).await? {
537            Control::LocalIndex(entries) => {
538                for e in entries {
539                    peer_index.entry(e.file_id).or_default().extend(e.hashes);
540                }
541                continue;
542            }
543            other => break other,
544        }
545    };
546    let resume = match resume {
547        Control::ResumeState(entries) => {
548            let mut map = HashMap::new();
549            for e in entries {
550                let Some(me) = manifest.entries.iter().find(|m| m.file_id == e.file_id) else {
551                    continue;
552                };
553                // A bitmap that does not describe this file is a protocol
554                // error, not something to guess around.
555                let bm = ChunkBitmap::from_bytes(e.have, me.chunk_count())?;
556                if bm.count() > 0 {
557                    map.insert(e.file_id, bm);
558                }
559            }
560            map
561        }
562        Control::Abort { reason } => return Err(Error::Closed(reason)),
563        other => {
564            return Err(Error::protocol(format!(
565                "expected ResumeState, got {other:?}"
566            )))
567        }
568    };
569
570    // Our own remembered hashes for the files we are about to send. Only
571    // consulted where the receiver offered something to match against.
572    let mut own_index: HashMap<u32, Vec<[u8; 32]>> = HashMap::new();
573    if cfg.delta && cfg.trust_mtime && !peer_index.is_empty() {
574        if let Some(root) = index_root(sources) {
575            let cache = crate::index::ChunkIndex::load(&root);
576            for (i, e) in manifest.entries.iter().enumerate() {
577                if e.kind != EntryKind::File || !peer_index.contains_key(&e.file_id) {
578                    continue;
579                }
580                let Some(path) = manifest.local_paths.get(i) else {
581                    continue;
582                };
583                let Ok(meta) = std::fs::metadata(path) else {
584                    continue;
585                };
586                if let Some(h) = cache.get(
587                    &e.path,
588                    meta.len(),
589                    crate::index::mtime_of(&meta),
590                    e.chunk_size,
591                ) {
592                    own_index.insert(e.file_id, h.to_vec());
593                }
594            }
595        }
596    }
597
598    let resumed_chunks: u64 = resume.values().map(|b| b.count()).sum();
599    if resumed_chunks > 0 {
600        tracing::info!(
601            chunks = resumed_chunks,
602            "receiver already holds chunks; skipping them"
603        );
604    }
605
606    // Per-file outstanding counters, so a file can be announced the moment its
607    // last chunk is on a stream.
608    let mut outstanding = HashMap::new();
609    for e in &manifest.entries {
610        if e.kind == EntryKind::File {
611            let total = e.chunk_count();
612            outstanding.insert(
613                e.file_id,
614                Arc::new(FileProgress {
615                    remaining: AtomicU64::new(total),
616                    chunk_hashes: Mutex::new(vec![[0u8; 32]; total as usize]),
617                    announced: AtomicBool::new(false),
618                    probe_attempts: AtomicU32::new(0),
619                    probe_wins: AtomicU32::new(0),
620                    give_up: AtomicBool::new(false),
621                }),
622            );
623        }
624    }
625
626    let shared = Arc::new(CursorShared {
627        manifest: manifest.clone(),
628        resume,
629        state: Mutex::new(ChunkCursor {
630            entry_idx: 0,
631            chunk_index: 0,
632            reader: None,
633            hint: FileHint::default(),
634        }),
635        metrics: metrics.clone(),
636        outstanding,
637        verify_hashes: cfg.verify_hashes,
638        peer_index,
639        own_index,
640    });
641
642    // Files whose chunks were *all* skipped by resume finished before a single
643    // stream opened; announce them now.
644    let (ctl_tx, mut ctl_rx) = mpsc::unbounded_channel::<Control>();
645    for e in &manifest.entries {
646        if e.kind != EntryKind::File {
647            continue;
648        }
649        let fp = &shared.outstanding[&e.file_id];
650        if fp.remaining.load(Ordering::Acquire) == 0 && !fp.announced.swap(true, Ordering::AcqRel) {
651            // Every chunk was skipped by resume, so this side never read the
652            // file and has no hashes to fold. The receiver holds the data
653            // already; sending no root tells it to accept what it has.
654            let _ = ctl_tx.send(Control::FileComplete {
655                file_id: e.file_id,
656                hash: None,
657            });
658            metrics.file_done();
659        }
660    }
661
662    wire::write_control(
663        &mut ctl_w,
664        &Control::Start {
665            streams: cfg.streams as u32,
666        },
667    )
668    .await?;
669
670    // A dedicated task owns the control writer from here, so stream tasks can
671    // announce completions without contending for it.
672    // Hands the writer back whatever happens, so the completion handshake
673    // below can still end the stream after a failure.
674    let ctl_task = tokio::spawn(async move {
675        let mut failure = None;
676        while let Some(msg) = ctl_rx.recv().await {
677            if let Err(e) = wire::write_control(&mut ctl_w, &msg).await {
678                failure = Some(e);
679                break;
680            }
681        }
682        if failure.is_none() {
683            if let Err(e) = wire::write_control(&mut ctl_w, &Control::AllComplete).await {
684                failure = Some(e);
685            }
686        }
687        // Deliberately *not* shut down here. The receiver treats our end-of-
688        // stream as "the sender has read my AllComplete and is done", so
689        // closing now would let it tear the connection down while our final
690        // read is still in flight.
691        (ctl_w, failure)
692    });
693
694    // Rayon pool sized to `workers`, keeping CPU parallelism explicit rather
695    // than inheriting tokio's blocking pool (which is sized for blocking I/O,
696    // not for saturating cores).
697    let cpu = Arc::new(
698        rayon::ThreadPoolBuilder::new()
699            .num_threads(cfg.workers)
700            .thread_name(|i| format!("rst-encode-{i}"))
701            .build()
702            .map_err(|e| Error::Worker(e.to_string()))?,
703    );
704
705    // Two buffers per in-flight chunk (plaintext + frame), plus slack.
706    let pool = BufPool::new(
707        cfg.streams * cfg.queue_depth * 2 + cfg.workers * 2,
708        cfg.chunk_size + cfg.chunk_size / 8,
709    );
710
711    // One sealer per concurrent encode at most; workers hand them back when a
712    // chunk is done, so the per-file subkey caches stay warm.
713    let sealers: Arc<ObjPool<Sealer>> = ObjPool::new(cfg.workers + cfg.streams);
714
715    let progress_task = progress.map(|f| spawn_progress(metrics.clone(), f));
716
717    let mut stream_tasks = Vec::with_capacity(cfg.streams);
718    for _ in 0..cfg.streams {
719        let sink = transport.open_uni().await?;
720        stream_tasks.push(tokio::spawn(run_stream(
721            sink,
722            shared.clone(),
723            cfg.clone(),
724            sealers.clone(),
725            crypto.clone(),
726            cpu.clone(),
727            pool.clone(),
728            metrics.clone(),
729            ctl_tx.clone(),
730        )));
731    }
732    // Drop our handle so the control task ends once every stream is done.
733    drop(ctl_tx);
734
735    let mut first_err = None;
736    for t in stream_tasks {
737        match t.await {
738            Ok(Ok(())) => {}
739            Ok(Err(e)) => {
740                tracing::error!(error = %e, "data stream failed");
741                first_err.get_or_insert(e);
742            }
743            Err(e) => {
744                first_err.get_or_insert(Error::Worker(e.to_string()));
745            }
746        }
747    }
748
749    let (mut ctl_w, ctl_failure) = ctl_task.await.map_err(|e| Error::Worker(e.to_string()))?;
750
751    if let Some(e) = first_err.or(ctl_failure) {
752        return Err(e);
753    }
754
755    // Wait for the receiver to confirm it committed everything. Without this a
756    // sender could exit while the last chunks are still being written, and a
757    // caller that deletes its source on success would be deleting live data.
758    loop {
759        match wire::read_control(&mut ctl_r, cfg.max_frame_bytes, cfg.max_manifest_entries).await {
760            Ok(Control::AllComplete) => break,
761            Ok(Control::Abort { reason }) => return Err(Error::Closed(reason)),
762            Ok(_) => continue,
763            Err(Error::Closed(m)) => {
764                return Err(Error::Closed(format!(
765                    "receiver closed before confirming completion: {m}"
766                )))
767            }
768            Err(e) => return Err(e),
769        }
770    }
771
772    // Now that the receiver's confirmation is in hand, end our control stream.
773    // That end-of-stream is what releases the receiver: it is the only signal
774    // that we actually read what it sent.
775    let _ = ctl_w.shutdown().await;
776
777    if let Some(p) = progress_task {
778        p.abort();
779    }
780    // Remember what we just hashed, so the next send of this tree can
781    // recognise unchanged chunks without opening a single file.
782    if cfg.delta && cfg.trust_mtime {
783        if let Some(root) = index_root(sources) {
784            let mut cache = crate::index::ChunkIndex::load(&root);
785            let mut seen = std::collections::HashSet::new();
786            for (i, e) in manifest.entries.iter().enumerate() {
787                if e.kind != EntryKind::File || e.size == 0 {
788                    continue;
789                }
790                seen.insert(e.path.clone());
791                let Some(fp) = shared.outstanding.get(&e.file_id) else {
792                    continue;
793                };
794                let hashes = fp.chunk_hashes.lock().clone();
795                if hashes.is_empty() || hashes.iter().all(|h| *h == [0u8; 32]) {
796                    continue;
797                }
798                if let Some(meta) = manifest
799                    .local_paths
800                    .get(i)
801                    .and_then(|p| std::fs::metadata(p).ok())
802                {
803                    cache.insert(
804                        &e.path,
805                        meta.len(),
806                        crate::index::mtime_of(&meta),
807                        e.chunk_size,
808                        hashes,
809                    );
810                }
811            }
812            cache.retain(&seen);
813            if let Err(err) = cache.save() {
814                tracing::warn!(error = %err, "could not persist the source chunk index");
815            }
816        }
817    }
818
819    let final_snapshot = metrics.snapshot();
820    tracing::info!(summary = %final_snapshot, "send complete");
821    // The connection belongs to the caller — it may be multiplexing other work
822    // over it, or reusing it for the next transfer. Closing it here would be a
823    // library reaching past its own boundary.
824    Ok(final_snapshot)
825}
826
827/// Drive one data stream until the cursor is exhausted.
828#[allow(clippy::too_many_arguments)]
829async fn run_stream(
830    mut sink: BoxSend,
831    shared: Arc<CursorShared>,
832    cfg: Config,
833    sealers: Arc<ObjPool<Sealer>>,
834    crypto: Arc<crate::codec::crypto::SessionCrypto>,
835    cpu: Arc<rayon::ThreadPool>,
836    pool: Arc<BufPool>,
837    metrics: Metrics,
838    ctl: mpsc::UnboundedSender<Control>,
839) -> Result<()> {
840    let mut inflight = FuturesOrdered::new();
841    let mut drained = false;
842
843    loop {
844        // Top up the encode pipeline.
845        while !drained && inflight.len() < cfg.queue_depth {
846            match shared.next_job()? {
847                Some(job) => {
848                    let (tx, rx) = tokio::sync::oneshot::channel();
849                    let cfg2 = cfg.clone();
850                    let sealers2 = sealers.clone();
851                    let crypto2 = crypto.clone();
852                    let pool2 = pool.clone();
853                    cpu.spawn(move || {
854                        // No lock is held across the file read: each worker
855                        // borrows a sealer for the duration of its own chunk.
856                        let mut sealer = sealers2.take_or(|| crypto2.sealer());
857                        let r = encode_chunk(&job, &cfg2, &mut sealer, &pool2);
858                        sealers2.put(sealer);
859                        // A dropped receiver means the transfer is unwinding;
860                        // the error that caused it is already on its way up.
861                        let _ = tx.send(r);
862                    });
863                    inflight.push_back(rx);
864                }
865                None => drained = true,
866            }
867        }
868
869        let Some(res) = inflight.next().await else {
870            break;
871        };
872        let encoded = res.map_err(|_| Error::Worker("encode worker vanished".into()))??;
873
874        if encoded.compressor_ran {
875            metrics.compressor_ran();
876        }
877        if encoded.skipped {
878            metrics.chunk_skipped(encoded.raw_len as u64);
879        } else {
880            let is_hole = encoded.frame.len() == wire::FRAME_HEADER_LEN && !encoded.reused;
881            sink.write_all(&encoded.frame).await?;
882            let wire_len = encoded.frame.len() as u64;
883            pool.put(encoded.frame);
884            if encoded.reused {
885                metrics.chunk_reused(encoded.raw_len as u64, wire_len);
886            } else if is_hole {
887                metrics.chunk_zero(encoded.raw_len as u64, wire_len);
888            } else {
889                metrics.chunk_done(encoded.raw_len as u64, wire_len, encoded.compressed);
890            }
891        }
892
893        // Record the chunk hash and, if this was the file's last outstanding
894        // chunk, announce it. `announced` makes that exactly-once even though
895        // several streams may hit zero concurrently.
896        if let Some(fp) = shared.outstanding.get(&encoded.file_id) {
897            if cfg.verify_hashes {
898                let mut hashes = fp.chunk_hashes.lock();
899                if let Some(slot) = hashes.get_mut(encoded.chunk_index as usize) {
900                    *slot = encoded.hash;
901                }
902            }
903            let left = fp.remaining.fetch_sub(1, Ordering::AcqRel) - 1;
904            if left == 0 && !fp.announced.swap(true, Ordering::AcqRel) {
905                let hash = if cfg.verify_hashes {
906                    Some(merkle_root(&fp.chunk_hashes.lock()))
907                } else {
908                    None
909                };
910                let _ = ctl.send(Control::FileComplete {
911                    file_id: encoded.file_id,
912                    hash,
913                });
914                metrics.file_done();
915            }
916        }
917    }
918
919    // Finish the stream so the receiver sees a clean EOF rather than waiting on
920    // a connection timeout.
921    sink.shutdown().await?;
922    Ok(())
923}
924
925/// Where a source tree's chunk index lives: inside the directory being sent,
926/// or beside a lone file.
927fn index_root(sources: &[Source]) -> Option<std::path::PathBuf> {
928    let first = sources.first()?;
929    if first.path.is_dir() {
930        Some(first.path.clone())
931    } else {
932        first.path.parent().map(|p| p.to_path_buf())
933    }
934}
935
936/// Fold per-chunk hashes into one value for the file.
937///
938/// A flat BLAKE3 over the file would require hashing in order, which would
939/// serialise a pipeline whose entire point is that chunks complete out of
940/// order. Hashing the ordered concatenation of chunk hashes gives an
941/// equally strong end-to-end check that each side can compute in parallel.
942pub fn merkle_root(chunk_hashes: &[[u8; 32]]) -> [u8; 32] {
943    let mut h = blake3::Hasher::new();
944    for c in chunk_hashes {
945        h.update(c);
946    }
947    *h.finalize().as_bytes()
948}
949
950fn spawn_progress(metrics: Metrics, f: ProgressFn) -> tokio::task::JoinHandle<()> {
951    tokio::spawn(async move {
952        let mut tick = tokio::time::interval(std::time::Duration::from_millis(500));
953        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
954        loop {
955            tick.tick().await;
956            f(metrics.snapshot());
957        }
958    })
959}
960
961async fn read_exact(r: &mut BoxRecv, buf: &mut [u8]) -> Result<()> {
962    use tokio::io::AsyncReadExt;
963    r.read_exact(buf).await.map_err(|e| {
964        if e.kind() == std::io::ErrorKind::UnexpectedEof {
965            Error::Closed("peer closed during handshake".into())
966        } else {
967            Error::Io(e)
968        }
969    })?;
970    Ok(())
971}