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}