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};
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_len(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_len` (from compio's
69/// `SetLen`). Every runtime backend routes its read path through here, so the
70/// single owned-buffer `unsafe` block lives behind one documented contract
71/// 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, spare_len) = {
95 let spare = buf.as_uninit();
96 let spare_len = spare.len();
97 (read(spare).await, spare_len)
98 };
99 match outcome {
100 Ok(n) => {
101 // A backend that reports more bytes than the spare slice held would
102 // make the set_len below declare uninitialized (or out-of-bounds)
103 // memory live. Catch that contract violation in debug builds.
104 debug_assert!(
105 n <= spare_len,
106 "read reported {n} bytes but the spare slice was only {spare_len}"
107 );
108 // SAFETY: per this function's contract, `read` initialized exactly
109 // the first `n` bytes of the spare slice it was handed. Declaring
110 // that same length initialized matches what was actually written.
111 unsafe {
112 buf.set_len(n);
113 }
114 BufResult(Ok(n), buf)
115 }
116 Err(e) => BufResult(Err(e), buf),
117 }
118}
119
120/// Build borrowed `IoSlice`s over the initialized bytes of each buffer in an
121/// owned vectored buffer and hand them to `f` for a single vectored write.
122///
123/// The slices borrow `buf`, so they stay valid only for the duration of `f`.
124/// A `SmallVec` keeps the common case (a frame header plus a handful of frames)
125/// off the heap; it spills to a `Vec` only past 16 buffers. Centralized here so
126/// the smol `writev` path and the instruction-count bench share one builder.
127pub fn with_vectored_slices<B, R>(buf: &B, f: impl FnOnce(&[IoSlice<'_>]) -> R) -> R
128where
129 B: IoVectoredBuf,
130{
131 let slices: SmallVec<[IoSlice<'_>; 16]> = buf.iter_slice().map(IoSlice::new).collect();
132 f(&slices)
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 #[test]
140 fn reclaim_makes_slab_use_track_bytes_read_and_freezes_only_read_bytes() {
141 // One 64 KiB slab, fully initialized so we can read pointers/contents.
142 let mut stash = BytesMut::new();
143 stash.resize(READ_SLAB_SIZE, 0);
144 let slab_ptr = stash.as_ptr();
145
146 // Carve a read buffer, "read" 10 bytes into it, and reclaim the unused
147 // tail back into the slab (the read_raw hot path).
148 let mut buf = unsafe { take_read_buffer(&mut stash, 8192) };
149 assert_eq!(buf.as_ptr(), slab_ptr, "carved from the slab front");
150 buf[..10].copy_from_slice(b"0123456789");
151 buf.truncate(10);
152 let mut reclaimed = buf.split_off(10);
153 // Scratch len so unsplit does not discard the empty tail (see read_raw).
154 // SAFETY: uninitialized scratch, overwritten before the next read.
155 unsafe {
156 reclaimed.set_len(reclaimed.capacity());
157 }
158 reclaimed.unsplit(std::mem::take(&mut stash));
159 stash = reclaimed;
160
161 // Freezing exposes exactly the 10 bytes read - never any of the
162 // uninitialized carve.
163 let frozen = buf.freeze();
164 assert_eq!(frozen.as_ref(), b"0123456789");
165 assert_eq!(
166 frozen.as_ptr(),
167 slab_ptr,
168 "frozen frame aliases the slab head"
169 );
170
171 // The reclaim returned the unused bytes: the next carve advances by the
172 // 10 bytes read, not the full 8192 carve, and reuses the same slab.
173 let next = unsafe { take_read_buffer(&mut stash, 8192) };
174 assert_eq!(
175 next.as_ptr() as usize,
176 slab_ptr as usize + 10,
177 "the next carve reuses the reclaimed space (slab use tracks bytes read)"
178 );
179 }
180
181 #[test]
182 fn take_read_buffer_over_slab_size_does_not_panic_or_corrupt() {
183 // Regression (migrated from the old arena): a read_size larger than the
184 // slab size once overflowed the backing page and panicked in freeze()
185 // with "range end out of bounds". take_read_buffer must grow to fit.
186 let mut stash = BytesMut::with_capacity(READ_SLAB_SIZE);
187 let read_size = READ_SLAB_SIZE + 1024;
188 // SAFETY: bookkeeping-only test; we truncate to the full length and
189 // never rely on the (uninitialized) contents.
190 let mut buf = unsafe { take_read_buffer(&mut stash, read_size) };
191 assert_eq!(buf.len(), read_size);
192 buf.truncate(read_size);
193 assert_eq!(buf.freeze().len(), read_size);
194 }
195
196 #[test]
197 fn take_read_buffer_splits_front_and_keeps_tail() {
198 let mut stash = BytesMut::with_capacity(READ_SLAB_SIZE);
199 // SAFETY: bookkeeping-only test; contents are never inspected.
200 let buf = unsafe { take_read_buffer(&mut stash, 256) };
201 assert_eq!(buf.len(), 256);
202 assert_eq!(stash.len(), 0);
203 assert!(stash.capacity() >= READ_SLAB_SIZE - 256);
204 }
205
206 #[test]
207 fn take_read_buffer_reuses_one_slab_across_reads() {
208 // Successive sub-slab reads carve from the same allocation, the way the
209 // bump-pointer arena shared a page: the stash tail shrinks by exactly
210 // read_size each time, with no fresh 64 KiB allocation.
211 let mut stash = BytesMut::with_capacity(READ_SLAB_SIZE);
212 // SAFETY: bookkeeping-only test; contents are never inspected.
213 let _first = unsafe { take_read_buffer(&mut stash, 4096) };
214 assert_eq!(stash.capacity(), READ_SLAB_SIZE - 4096);
215 // SAFETY: bookkeeping-only test.
216 let _second = unsafe { take_read_buffer(&mut stash, 4096) };
217 assert_eq!(stash.capacity(), READ_SLAB_SIZE - 8192);
218 }
219}