Skip to main content

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}