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/// Frame-size threshold below which a received frame is copied out of the
36/// shared read slab into a right-sized allocation instead of being frozen in
37/// place.
38///
39/// Freezing a small frame shares the whole [`READ_SLAB_SIZE`] slab by refcount,
40/// so a single small frame that stays queued (a slow consumer, a lagging SUB)
41/// pins 64 KiB. Copying frames below this threshold releases the slab
42/// immediately; larger frames stay zero-copy, where the per-byte copy would
43/// outweigh the footprint saving.
44pub const COPY_OUT_THRESHOLD: usize = 4 * 1024;
45
46/// Take a read-sized scratch buffer from the front of `stash`, leaving the
47/// remaining tail in `stash` to hand out on the next call.
48///
49/// This replaces the old bump-pointer read arena: successive reads carve
50/// `read_size` chunks off one `READ_SLAB_SIZE` allocation until it is used up,
51/// then a fresh slab is allocated. Freezing a returned buffer shares that
52/// slab's allocation (via `bytes` refcounting), so a lagging consumer pins the
53/// slab exactly as the arena page did.
54///
55/// # Safety
56///
57/// The returned buffer reports `read_size` initialized bytes that are in fact
58/// uninitialized, so it can be handed to an owned-buffer read without
59/// zero-filling first. The caller must pass it straight to a read and
60/// `truncate` it to the number of bytes actually read before freezing,
61/// inspecting, or otherwise exposing its contents.
62pub unsafe fn take_read_buffer(stash: &mut BytesMut, read_size: usize) -> BytesMut {
63 if stash.capacity() < read_size {
64 *stash = BytesMut::with_capacity(read_size.max(READ_SLAB_SIZE));
65 }
66 if stash.len() < read_size {
67 // SAFETY: `read_size` is within capacity per the check above. The
68 // function's contract requires the caller to overwrite these bytes via
69 // the read and truncate to the real count before exposing them.
70 unsafe { stash.set_len(read_size) };
71 }
72 let tail = stash.split_off(read_size);
73 std::mem::replace(stash, tail)
74}
75
76/// Read into an owned buffer's spare capacity, then declare the bytes written
77/// as initialized.
78///
79/// This is the one place in the workspace that calls `set_len` (from compio's
80/// `SetLen`). Every runtime backend routes its read path through here, so the
81/// single owned-buffer `unsafe` block lives behind one documented contract
82/// instead of a copy per adapter.
83///
84/// `read` is an async closure handed the buffer's uninitialized spare capacity
85/// as `&mut [MaybeUninit<u8>]`; it performs the actual read into the front of
86/// that slice: tokio wraps it in a `ReadBuf`, smol hands it to `recv`, so the
87/// read mechanism stays with the backend while the buffer bookkeeping stays
88/// here. `AsyncFnOnce` lets the returned future borrow the spare slice, which a
89/// plain `FnOnce` returning a future cannot.
90///
91/// # Contract
92///
93/// On `Ok(n)`, `read` must have initialized exactly the first `n` bytes of the
94/// slice it was given (`n` never exceeds the slice length). `set_buf_init(n)`
95/// then declares precisely those bytes live, matching what was written. A
96/// backend that reported more bytes than it wrote, or wrote them anywhere but
97/// the front of the slice, would break this contract and the `unsafe` below.
98pub async fn fill_read<B, F>(mut buf: B, read: F) -> BufResult<usize, B>
99where
100 B: IoBufMut,
101 F: AsyncFnOnce(&mut [MaybeUninit<u8>]) -> io::Result<usize>,
102{
103 // Scope the spare-capacity borrow to the read so `buf` is free to mutate
104 // again once the count is known.
105 let outcome = {
106 let spare = buf.as_uninit();
107 read(spare).await
108 };
109 match outcome {
110 Ok(n) => {
111 // SAFETY: per this function's contract, `read` initialized exactly
112 // the first `n` bytes of the spare slice it was handed. Declaring
113 // that same length initialized matches what was actually written.
114 unsafe {
115 buf.set_len(n);
116 }
117 BufResult(Ok(n), buf)
118 }
119 Err(e) => BufResult(Err(e), buf),
120 }
121}
122
123/// Build borrowed `IoSlice`s over the initialized bytes of each buffer in an
124/// owned vectored buffer and hand them to `f` for a single vectored write.
125///
126/// The slices borrow `buf`, so they stay valid only for the duration of `f`.
127/// A `SmallVec` keeps the common case (a frame header plus a handful of frames)
128/// off the heap; it spills to a `Vec` only past 16 buffers. Centralized here so
129/// the smol `writev` path and the instruction-count bench share one builder.
130pub fn with_vectored_slices<B, R>(buf: &B, f: impl FnOnce(&[IoSlice<'_>]) -> R) -> R
131where
132 B: IoVectoredBuf,
133{
134 let slices: SmallVec<[IoSlice<'_>; 16]> = buf.iter_slice().map(IoSlice::new).collect();
135 f(&slices)
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 #[test]
143 fn small_frame_copy_releases_the_slab() {
144 use bytes::Bytes;
145
146 // One 64 KiB slab, fully initialized so we can read pointers safely.
147 let mut stash = BytesMut::new();
148 stash.resize(READ_SLAB_SIZE, 0);
149 let slab_ptr = stash.as_ptr();
150
151 // Freezing a small frame in place shares the slab: the frozen Bytes
152 // points into the original 64 KiB allocation, pinning all of it.
153 let mut a = unsafe { take_read_buffer(&mut stash, 8192) };
154 a.truncate(10);
155 let a_ptr = a.as_ptr();
156 assert_eq!(
157 a_ptr, slab_ptr,
158 "chunk should be carved from the slab front"
159 );
160 let frozen = a.freeze();
161 assert_eq!(
162 frozen.as_ptr(),
163 slab_ptr,
164 "freeze() shares the slab allocation"
165 );
166
167 // Copying a small frame out yields a fresh, right-sized allocation that
168 // does not reference the slab, so the slab is free once the chunk drops.
169 let mut b = unsafe { take_read_buffer(&mut stash, 8192) };
170 b.truncate(10);
171 let b_ptr = b.as_ptr();
172 let copied = Bytes::copy_from_slice(&b);
173 assert_ne!(
174 copied.as_ptr(),
175 b_ptr,
176 "copy_from_slice must allocate off the slab, not alias it"
177 );
178 // The 10-byte frame above is well below the copy-out threshold, so the
179 // read path takes the copy branch for it.
180 let _ = COPY_OUT_THRESHOLD;
181 }
182
183 #[test]
184 fn take_read_buffer_over_slab_size_does_not_panic_or_corrupt() {
185 // Regression (migrated from the old arena): a read_size larger than the
186 // slab size once overflowed the backing page and panicked in freeze()
187 // with "range end out of bounds". take_read_buffer must grow to fit.
188 let mut stash = BytesMut::with_capacity(READ_SLAB_SIZE);
189 let read_size = READ_SLAB_SIZE + 1024;
190 // SAFETY: bookkeeping-only test; we truncate to the full length and
191 // never rely on the (uninitialized) contents.
192 let mut buf = unsafe { take_read_buffer(&mut stash, read_size) };
193 assert_eq!(buf.len(), read_size);
194 buf.truncate(read_size);
195 assert_eq!(buf.freeze().len(), read_size);
196 }
197
198 #[test]
199 fn take_read_buffer_splits_front_and_keeps_tail() {
200 let mut stash = BytesMut::with_capacity(READ_SLAB_SIZE);
201 // SAFETY: bookkeeping-only test; contents are never inspected.
202 let buf = unsafe { take_read_buffer(&mut stash, 256) };
203 assert_eq!(buf.len(), 256);
204 assert_eq!(stash.len(), 0);
205 assert!(stash.capacity() >= READ_SLAB_SIZE - 256);
206 }
207
208 #[test]
209 fn take_read_buffer_reuses_one_slab_across_reads() {
210 // Successive sub-slab reads carve from the same allocation, the way the
211 // bump-pointer arena shared a page: the stash tail shrinks by exactly
212 // read_size each time, with no fresh 64 KiB allocation.
213 let mut stash = BytesMut::with_capacity(READ_SLAB_SIZE);
214 // SAFETY: bookkeeping-only test; contents are never inspected.
215 let _first = unsafe { take_read_buffer(&mut stash, 4096) };
216 assert_eq!(stash.capacity(), READ_SLAB_SIZE - 4096);
217 // SAFETY: bookkeeping-only test.
218 let _second = unsafe { take_read_buffer(&mut stash, 4096) };
219 assert_eq!(stash.capacity(), READ_SLAB_SIZE - 8192);
220 }
221}