Skip to main content

gunnar_wire/
wire_io.rs

1//! Reading and writing the framed stream, and the sideband multiplexer.
2//!
3//! Everything here works on `dyn BufRead` / `dyn Write` and nothing else. That
4//! is the transport-agnosticism the crate promises: an SSH channel, a pipe, a
5//! `Vec<u8>` and a test fixture are all the same to these functions, so the
6//! state machines can be driven from a unit test with no socket in sight.
7
8use std::io::{BufRead, Write};
9
10use gunnar_pktline::{
11    decode, Decoded, Packet, BAND_ERROR, BAND_PACK, BAND_PROGRESS, SIDEBAND_CHUNK,
12};
13
14use crate::{Error, Result};
15
16/// **How much a transport must buffer under these writers**, and why one
17/// sideband packet is the unit that decides it.
18///
19/// [`SidebandWriter`] hands the transport one packet at a time, and
20/// `gunnar_pktline::write_sideband` hands it that packet as **three** calls: a
21/// 4-byte length, a 1-byte band, then [`SIDEBAND_CHUNK`] bytes of payload. Put a
22/// `BufWriter` under that and its capacity decides how those three become
23/// writes on the stream underneath:
24///
25/// | capacity | writes per 65 520-byte packet | smallest write |
26/// |---|---|---|
27/// | 8 KiB (`BufWriter::new`) | **2** | **5 bytes** |
28/// | this constant | 1 per four packets | 262 080 bytes |
29///
30/// The 8 KiB case is not a small buffer, it is a **defeated** one: 65 515 bytes
31/// exceeds the capacity, so `BufWriter` flushes the 5 bytes it is holding as a
32/// write of its own and passes the payload straight through. Every packet
33/// therefore costs two writes, one of them five bytes.
34///
35/// That matters because of what is under the transport. `gunnar-cli`'s
36/// `WireTransport` runs this state machine on a `spawn_blocking` thread writing
37/// into a `SyncIoBridge` over a `tokio::io::duplex`, so **one write is one
38/// hand-off** — a `block_on`, a waker, and, when the reader is parked, a futex
39/// pair. `gunnar-http`'s `Streamed` body doc records the other half of the same
40/// arithmetic measured 2026-08-04: 7 307–10 936 futex calls per clone of
41/// the 28 893-object `year` corpus, and −25% of them from widening the
42/// **reader**'s frame alone. The writer was still handing over 64 KiB at a time
43/// in two pieces.
44///
45/// 256 KiB is four whole packets, which is where the ratio stops improving: the
46/// duplex window this feeds is 256 KiB (`HttpConfig::stream_window`), so a
47/// larger buffer would only split its flush against a window that cannot take
48/// it. It is deliberately **not** a copy of that number — a transport buffer and
49/// a flow-control window are different things that happen to agree — but it must
50/// stay at or above one whole packet or the 5-byte write comes back.
51/// `transport_buffer_holds_whole_sideband_packets` asserts that on the applied
52/// output: it counts writes, not capacities.
53pub const TRANSPORT_BUFFER_BYTES: usize = 256 * 1024;
54
55/// Read exactly one pkt-line, or `None` at a clean end of stream.
56///
57/// **Exact reads, never buffered read-ahead.** `receive-pack` follows its
58/// framed command list with a raw packfile on the very same stream, so a reader
59/// that pulled "whatever was available" would swallow the head of the pack and
60/// the pack's trailing checksum would then fail over bytes nobody can point at.
61/// Reading the 4-byte header and then exactly the declared payload leaves the
62/// stream positioned on the first pack byte, which is what
63/// [`crate::receive_pack`] relies on.
64pub(crate) fn read_packet(input: &mut dyn BufRead) -> Result<Option<Packet>> {
65    let mut head = [0u8; 4];
66    match read_up_to(input, &mut head)? {
67        0 => return Ok(None),
68        4 => {}
69        _ => return Err(Error::UnexpectedEof("a pkt-line header")),
70    }
71
72    // `decode` validates the header (hex digits, the reserved `0003`, the
73    // length ceiling) and answers outright for the three control packets,
74    // whose whole line is those four bytes. `Incomplete` here therefore means
75    // "a valid header for a data packet whose payload has not arrived", which
76    // is the only case where the length has to be re-read below.
77    match decode(&head)? {
78        Decoded::Packet(pkt, _) => return Ok(Some(pkt)),
79        Decoded::Incomplete => {}
80    }
81
82    let len = line_length(&head);
83    let mut line = vec![0u8; len];
84    line[..4].copy_from_slice(&head);
85    input.read_exact(&mut line[4..]).map_err(|err| {
86        if err.kind() == std::io::ErrorKind::UnexpectedEof {
87            Error::UnexpectedEof("a pkt-line payload")
88        } else {
89            Error::Io(err)
90        }
91    })?;
92
93    match decode(&line)? {
94        Decoded::Packet(pkt, used) if used == len => Ok(Some(pkt)),
95        _ => Err(Error::protocol(format!(
96            "a {len}-byte pkt-line did not decode as one packet"
97        ))),
98    }
99}
100
101/// One pkt-line, refusing a clean end of stream.
102pub(crate) fn expect_packet(input: &mut dyn BufRead, what: &'static str) -> Result<Packet> {
103    read_packet(input)?.ok_or(Error::UnexpectedEof(what))
104}
105
106/// The declared total length of a line whose header `decode` already accepted.
107///
108/// Four hex digits, which `decode` has necessarily just parsed successfully;
109/// it returned `Incomplete` rather than `BadLength`. The parse is repeated here
110/// only because `Decoded::Incomplete` carries no length; nothing else in gunnar
111/// parses a pkt-line header.
112fn line_length(head: &[u8; 4]) -> usize {
113    head.iter().fold(0usize, |acc, &b| {
114        let nibble = match b {
115            b'0'..=b'9' => b - b'0',
116            b'a'..=b'f' => b - b'a' + 10,
117            b'A'..=b'F' => b - b'A' + 10,
118            _ => 0,
119        };
120        (acc << 4) | nibble as usize
121    })
122}
123
124fn read_up_to(input: &mut dyn BufRead, buf: &mut [u8]) -> Result<usize> {
125    let mut filled = 0;
126    while filled < buf.len() {
127        match input.read(&mut buf[filled..])? {
128            0 => break,
129            n => filled += n,
130        }
131    }
132    Ok(filled)
133}
134
135/// Read pkt-lines until a flush-pkt, returning the payloads with their trailing
136/// newline stripped.
137///
138/// A clean end of stream before the flush is an error: every section in both
139/// protocols is flush-terminated, so an absent flush means a truncated peer.
140///
141/// `budget` is the caller's ceiling on how many of the peer's bytes this may
142/// hold. The loop terminates on a flush-pkt the **peer** chooses to send, so
143/// without one the only bound on `lines` is the peer's patience.
144pub(crate) fn read_section(
145    input: &mut dyn BufRead,
146    what: &'static str,
147    budget: &mut crate::limits::Budget,
148) -> Result<Vec<Vec<u8>>> {
149    let mut lines = Vec::new();
150    loop {
151        match expect_packet(input, what)? {
152            Packet::Flush => return Ok(lines),
153            Packet::Data(data) => {
154                budget.spend(data.len(), what)?;
155                let text = data.strip_suffix(b"\n").unwrap_or(&data);
156                lines.push(text.to_vec());
157            }
158            other => {
159                return Err(Error::protocol(format!(
160                    "unexpected {other:?} while reading {what}"
161                )))
162            }
163        }
164    }
165}
166
167/// Multiplexes a byte stream onto one sideband, buffering up to a full line.
168///
169/// The band byte is part of the pkt-line payload, so a full sideband line
170/// carries `SIDEBAND_CHUNK` bytes of data and not `MAX_PAYLOAD`; that
171/// subtraction is made once, in `gunnar-pktline`, and this type only has to
172/// respect the resulting chunk size.
173pub(crate) struct SidebandWriter<'a> {
174    out: &'a mut dyn Write,
175    band: u8,
176    buf: Vec<u8>,
177}
178
179impl<'a> SidebandWriter<'a> {
180    pub(crate) fn new(out: &'a mut dyn Write, band: u8) -> Self {
181        SidebandWriter {
182            out,
183            band,
184            buf: Vec::with_capacity(SIDEBAND_CHUNK),
185        }
186    }
187
188    fn emit(&mut self) -> std::io::Result<()> {
189        if self.buf.is_empty() {
190            return Ok(());
191        }
192        gunnar_pktline::write_sideband(&mut self.out, self.band, &self.buf).map_err(as_io)?;
193        self.buf.clear();
194        Ok(())
195    }
196}
197
198impl Write for SidebandWriter<'_> {
199    fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
200        if data.is_empty() {
201            return Ok(0);
202        }
203        let room = SIDEBAND_CHUNK - self.buf.len();
204        let take = room.min(data.len());
205        self.buf.extend_from_slice(&data[..take]);
206        if self.buf.len() == SIDEBAND_CHUNK {
207            self.emit()?;
208        }
209        Ok(take)
210    }
211
212    fn flush(&mut self) -> std::io::Result<()> {
213        self.emit()?;
214        self.out.flush()
215    }
216}
217
218fn as_io(err: gunnar_pktline::Error) -> std::io::Error {
219    match err {
220        gunnar_pktline::Error::Io(err) => err,
221        other => std::io::Error::other(other.to_string()),
222    }
223}
224
225/// Where a packfile goes: straight down the wire, or on sideband 1.
226///
227/// Protocol v2 always sidebands the packfile; protocol v0 only does when the
228/// client asked for `side-band-64k`. Both are the same `Write` to the pack
229/// writer, so the pack streams either way instead of being buffered whole,
230/// which for a clone is the difference between a constant and a repository-sized
231/// allocation.
232pub(crate) enum PackOutput<'a> {
233    Raw(&'a mut dyn Write),
234    Sideband(SidebandWriter<'a>),
235}
236
237impl<'a> PackOutput<'a> {
238    pub(crate) fn new(out: &'a mut dyn Write, sideband: bool) -> Self {
239        if sideband {
240            PackOutput::Sideband(SidebandWriter::new(out, BAND_PACK))
241        } else {
242            PackOutput::Raw(out)
243        }
244    }
245}
246
247impl Write for PackOutput<'_> {
248    fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
249        match self {
250            PackOutput::Raw(out) => out.write(data),
251            PackOutput::Sideband(out) => out.write(data),
252        }
253    }
254
255    fn flush(&mut self) -> std::io::Result<()> {
256        match self {
257            PackOutput::Raw(out) => out.flush(),
258            PackOutput::Sideband(out) => out.flush(),
259        }
260    }
261}
262
263/// Write one text pkt-line to an unsized writer.
264///
265/// `gunnar-pktline`'s encoders are generic over `W: Write`, which is right for
266/// a leaf crate but means `dyn Write` (the shape every entry point in this
267/// crate takes) needs a reborrow at each call. These five wrappers make that
268/// reborrow once instead of at ninety call sites.
269pub(crate) fn text(out: &mut dyn Write, line: &[u8]) -> Result<()> {
270    // The reborrow needs a named binding: `&mut *out` on its own makes
271    // inference pick `W = dyn Write`, which is unsized.
272    let mut sink: &mut dyn Write = out;
273    gunnar_pktline::write_text(&mut sink, line)?;
274    Ok(())
275}
276
277/// Write one data pkt-line verbatim, adding no newline.
278pub(crate) fn data(out: &mut dyn Write, payload: &[u8]) -> Result<()> {
279    let mut sink: &mut dyn Write = out;
280    gunnar_pktline::write_data(&mut sink, payload)?;
281    Ok(())
282}
283
284/// Write a flush-pkt.
285pub(crate) fn flush(out: &mut dyn Write) -> Result<()> {
286    let mut sink: &mut dyn Write = out;
287    gunnar_pktline::write_flush(&mut sink)?;
288    Ok(())
289}
290
291/// Write a delim-pkt.
292pub(crate) fn delim(out: &mut dyn Write) -> Result<()> {
293    let mut sink: &mut dyn Write = out;
294    gunnar_pktline::write_delim(&mut sink)?;
295    Ok(())
296}
297
298/// Write sideband lines on `band`.
299pub(crate) fn sideband(out: &mut dyn Write, band: u8, payload: &[u8]) -> Result<()> {
300    let mut sink: &mut dyn Write = out;
301    gunnar_pktline::write_sideband(&mut sink, band, payload)?;
302    Ok(())
303}
304
305/// Send a progress message on band 2, or nothing when progress is suppressed
306/// or the client is not reading a sideband.
307pub(crate) fn progress(out: &mut dyn Write, enabled: bool, message: &str) -> Result<()> {
308    if !enabled || message.is_empty() {
309        return Ok(());
310    }
311    sideband(out, BAND_PROGRESS, message.as_bytes())?;
312    Ok(())
313}
314
315/// Send a fatal message on band 3. The client aborts on it.
316pub(crate) fn sideband_error(out: &mut dyn Write, message: &str) -> Result<()> {
317    let mut text = message.to_owned();
318    if !text.ends_with('\n') {
319        text.push('\n');
320    }
321    sideband(out, BAND_ERROR, text.as_bytes())?;
322    Ok(())
323}
324
325/// Send an `ERR <message>` pkt-line, the way a v0 server reports a fatal
326/// condition *before* any sideband exists. Public for the transport's
327/// first-repository door, which refuses in the client's own protocol after the
328/// advertisement and before any sideband.
329pub fn err_packet(out: &mut dyn Write, message: &str) -> Result<()> {
330    let mut line = String::from("ERR ");
331    line.push_str(message);
332    text(out, line.as_bytes())?;
333    Ok(())
334}
335
336#[cfg(test)]
337mod transport_buffer_tests {
338    use std::io::{BufWriter, Write};
339
340    use super::{PackOutput, TRANSPORT_BUFFER_BYTES};
341
342    /// A sink that remembers how it was called, not what it was given.
343    ///
344    /// The applied output this file is about is the **sequence of writes**, not
345    /// the bytes: the bytes are identical at every buffer size and a test that
346    /// compared them would pass at 8 KiB and at 256 KiB alike.
347    #[derive(Default)]
348    struct Calls {
349        sizes: Vec<usize>,
350    }
351
352    impl Write for Calls {
353        fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
354            self.sizes.push(data.len());
355            Ok(data.len())
356        }
357        fn flush(&mut self) -> std::io::Result<()> {
358            Ok(())
359        }
360    }
361
362    /// Push `bytes` of packfile through the real sideband writer over a
363    /// `BufWriter` of `capacity`, and report every write the stream underneath
364    /// actually saw.
365    fn writes_under(capacity: usize, bytes: usize) -> Vec<usize> {
366        let payload = vec![0x5Au8; bytes];
367        let mut calls = Calls::default();
368        {
369            let mut buffered = BufWriter::with_capacity(capacity, &mut calls);
370            {
371                let mut pack = PackOutput::new(&mut buffered, true);
372                pack.write_all(&payload)
373                    .expect("a counting sink cannot fail");
374                pack.flush().expect("a counting sink cannot fail");
375            }
376            buffered.flush().expect("a counting sink cannot fail");
377        }
378        calls.sizes
379    }
380
381    /// **The write count is the measurement, and 8 KiB is the red arm.**
382    ///
383    /// One MiB of packfile is sixteen sideband packets. Under the default
384    /// `BufWriter::new` capacity each of them costs two writes — a 5-byte one
385    /// and a 65 515-byte one — because the payload does not fit and `BufWriter`
386    /// dumps its header to get out of the way. Under
387    /// [`TRANSPORT_BUFFER_BYTES`] four packets accumulate into one write and no
388    /// write is small.
389    ///
390    /// In `gunnar-cli`'s `WireTransport` each of those writes is a `block_on`
391    /// across a `tokio::io::duplex`, so this ratio is a hand-off ratio.
392    #[test]
393    fn transport_buffer_holds_whole_sideband_packets() {
394        const MIB: usize = 1024 * 1024;
395
396        let red = writes_under(8 * 1024, MIB);
397        assert!(
398            red.iter().any(|n| *n <= 8),
399            "the 8 KiB arm is supposed to expose a 5-byte write per sideband packet and did not, \
400             so this test is no longer measuring what it says: {red:?}"
401        );
402        assert!(
403            red.len() >= 32,
404            "1 MiB is 16 sideband packets and the 8 KiB arm should cost two writes each; it cost \
405             {} — the red arm has stopped being red",
406            red.len()
407        );
408
409        let green = writes_under(TRANSPORT_BUFFER_BYTES, MIB);
410        assert!(
411            green.iter().all(|n| *n > 8),
412            "a buffer of {TRANSPORT_BUFFER_BYTES} bytes still handed the stream a write of {:?} \
413             bytes, which is a pkt-line header on its own — the payload no longer fits the \
414             buffer",
415            green.iter().min()
416        );
417        assert!(
418            green.len() * 4 <= red.len(),
419            "{} write(s) at {TRANSPORT_BUFFER_BYTES} bytes against {} at 8 KiB is under the 4x \
420             this buffer exists to buy",
421            green.len(),
422            red.len()
423        );
424    }
425}