monocoque_core/io.rs
1//! Owned-buffer I/O helpers shared by every runtime backend.
2//!
3//! The runtime adapters (`rt::tokio`, `rt::smol`, and the native `rt::compio`
4//! path) all speak compio's owned-buffer read/write contract. Two pieces of
5//! that contract are fiddly enough that duplicating them per backend invites
6//! drift:
7//!
8//! - declaring how many bytes a read initialized in a buffer's spare capacity,
9//! which is the workspace's single owned-buffer `unsafe` operation
10//! ([`fill_read`]); and
11//! - building borrowed `IoSlice`s over an owned vectored buffer for one
12//! `writev` ([`with_vectored_slices`]).
13//!
14//! Centralizing both here gives each backend one call site instead of its own
15//! copy, and keeps the lone `set_buf_init` `unsafe` behind a single documented
16//! contract rather than repeated in every adapter.
17//!
18//! Like `tcp`, this module encapsulates its `unsafe` behind a safe API, so it
19//! opts back into `unsafe_code` that the crate otherwise denies.
20#![allow(unsafe_code)]
21
22use bytes::BytesMut;
23use compio_buf::{BufResult, IoBufMut, IoVectoredBuf, SetBufInit};
24use smallvec::SmallVec;
25use std::io::{self, IoSlice};
26use std::mem::MaybeUninit;
27
28/// Size of a read scratch slab.
29///
30/// Doubles as the ceiling `read_buffer_size` clamps to and the minimum capacity
31/// [`take_read_buffer`] grows a stash to. Equal to the old arena page size, so
32/// the per-read buffer bound and its resident footprint are unchanged.
33pub const READ_SLAB_SIZE: usize = 64 * 1024;
34
35/// Take a read-sized scratch buffer from the front of `stash`, leaving the
36/// remaining tail in `stash` to hand out on the next call.
37///
38/// This replaces the old bump-pointer read arena: successive reads carve
39/// `read_size` chunks off one `READ_SLAB_SIZE` allocation until it is used up,
40/// then a fresh slab is allocated. Freezing a returned buffer shares that
41/// slab's allocation (via `bytes` refcounting), so a lagging consumer pins the
42/// slab exactly as the arena page did.
43///
44/// # Safety
45///
46/// The returned buffer reports `read_size` initialized bytes that are in fact
47/// uninitialized, so it can be handed to an owned-buffer read without
48/// zero-filling first. The caller must pass it straight to a read and
49/// `truncate` it to the number of bytes actually read before freezing,
50/// inspecting, or otherwise exposing its contents.
51pub unsafe fn take_read_buffer(stash: &mut BytesMut, read_size: usize) -> BytesMut {
52 if stash.capacity() < read_size {
53 *stash = BytesMut::with_capacity(read_size.max(READ_SLAB_SIZE));
54 }
55 if stash.len() < read_size {
56 // SAFETY: `read_size` is within capacity per the check above. The
57 // function's contract requires the caller to overwrite these bytes via
58 // the read and truncate to the real count before exposing them.
59 unsafe { stash.set_buf_init(read_size) };
60 }
61 let tail = stash.split_off(read_size);
62 std::mem::replace(stash, tail)
63}
64
65/// Read into an owned buffer's spare capacity, then declare the bytes written
66/// as initialized.
67///
68/// This is the one place in the workspace that calls `set_buf_init` (from
69/// compio's `SetBufInit`). Every runtime backend routes its read path through
70/// here, so the single owned-buffer `unsafe` block lives behind one documented
71/// contract instead of a copy per adapter.
72///
73/// `read` is an async closure handed the buffer's uninitialized spare capacity
74/// as `&mut [MaybeUninit<u8>]`; it performs the actual read into the front of
75/// that slice: tokio wraps it in a `ReadBuf`, smol hands it to `recv`, so the
76/// read mechanism stays with the backend while the buffer bookkeeping stays
77/// here. `AsyncFnOnce` lets the returned future borrow the spare slice, which a
78/// plain `FnOnce` returning a future cannot.
79///
80/// # Contract
81///
82/// On `Ok(n)`, `read` must have initialized exactly the first `n` bytes of the
83/// slice it was given (`n` never exceeds the slice length). `set_buf_init(n)`
84/// then declares precisely those bytes live, matching what was written. A
85/// backend that reported more bytes than it wrote, or wrote them anywhere but
86/// the front of the slice, would break this contract and the `unsafe` below.
87pub async fn fill_read<B, F>(mut buf: B, read: F) -> BufResult<usize, B>
88where
89 B: IoBufMut,
90 F: AsyncFnOnce(&mut [MaybeUninit<u8>]) -> io::Result<usize>,
91{
92 // Scope the spare-capacity borrow to the read so `buf` is free to mutate
93 // again once the count is known.
94 let outcome = {
95 let spare = buf.as_mut_slice();
96 read(spare).await
97 };
98 match outcome {
99 Ok(n) => {
100 // SAFETY: per this function's contract, `read` initialized exactly
101 // the first `n` bytes of the spare slice it was handed. Declaring
102 // that same length initialized matches what was actually written.
103 unsafe {
104 buf.set_buf_init(n);
105 }
106 BufResult(Ok(n), buf)
107 }
108 Err(e) => BufResult(Err(e), buf),
109 }
110}
111
112/// Build borrowed `IoSlice`s over the initialized bytes of each buffer in an
113/// owned vectored buffer and hand them to `f` for a single vectored write.
114///
115/// The slices borrow `buf`, so they stay valid only for the duration of `f`.
116/// A `SmallVec` keeps the common case (a frame header plus a handful of frames)
117/// off the heap; it spills to a `Vec` only past 16 buffers. Centralized here so
118/// the smol `writev` path and the instruction-count bench share one builder.
119pub fn with_vectored_slices<B, R>(buf: &B, f: impl FnOnce(&[IoSlice<'_>]) -> R) -> R
120where
121 B: IoVectoredBuf,
122{
123 let slices: SmallVec<[IoSlice<'_>; 16]> = buf
124 .as_dyn_bufs()
125 .map(|b| IoSlice::new(b.as_slice()))
126 .collect();
127 f(&slices)
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 #[test]
135 fn take_read_buffer_over_slab_size_does_not_panic_or_corrupt() {
136 // Regression (migrated from the old arena): a read_size larger than the
137 // slab size once overflowed the backing page and panicked in freeze()
138 // with "range end out of bounds". take_read_buffer must grow to fit.
139 let mut stash = BytesMut::with_capacity(READ_SLAB_SIZE);
140 let read_size = READ_SLAB_SIZE + 1024;
141 // SAFETY: bookkeeping-only test; we truncate to the full length and
142 // never rely on the (uninitialized) contents.
143 let mut buf = unsafe { take_read_buffer(&mut stash, read_size) };
144 assert_eq!(buf.len(), read_size);
145 buf.truncate(read_size);
146 assert_eq!(buf.freeze().len(), read_size);
147 }
148
149 #[test]
150 fn take_read_buffer_splits_front_and_keeps_tail() {
151 let mut stash = BytesMut::with_capacity(READ_SLAB_SIZE);
152 // SAFETY: bookkeeping-only test; contents are never inspected.
153 let buf = unsafe { take_read_buffer(&mut stash, 256) };
154 assert_eq!(buf.len(), 256);
155 assert_eq!(stash.len(), 0);
156 assert!(stash.capacity() >= READ_SLAB_SIZE - 256);
157 }
158
159 #[test]
160 fn take_read_buffer_reuses_one_slab_across_reads() {
161 // Successive sub-slab reads carve from the same allocation, the way the
162 // bump-pointer arena shared a page: the stash tail shrinks by exactly
163 // read_size each time, with no fresh 64 KiB allocation.
164 let mut stash = BytesMut::with_capacity(READ_SLAB_SIZE);
165 // SAFETY: bookkeeping-only test; contents are never inspected.
166 let _first = unsafe { take_read_buffer(&mut stash, 4096) };
167 assert_eq!(stash.capacity(), READ_SLAB_SIZE - 4096);
168 // SAFETY: bookkeeping-only test.
169 let _second = unsafe { take_read_buffer(&mut stash, 4096) };
170 assert_eq!(stash.capacity(), READ_SLAB_SIZE - 8192);
171 }
172}