Skip to main content

yo_alloc/
lib.rs

1//! A global allocator that turns an accidental heap allocation on a shard path
2//! into a crash.
3//!
4//! Y7 says there is no global allocator call on a command path. That is easy to
5//! write down and impossible to keep by review once more than one person is in
6//! the codebase, because the allocating constructs in Rust are the comfortable
7//! ones: `format!`, `to_vec`, `collect`, `Box::new`, a `Vec` that grows, a
8//! `String` built to make an error message. Each costs tens of nanoseconds
9//! against a 150 ns budget, and none of them looks wrong in a diff.
10//!
11//! So the rule is enforced instead of reviewed. A shard thread marks itself
12//! [`enter_no_alloc`] before the command loop, and from that point any
13//! allocation aborts the process with a message naming the size. Setup, arena
14//! growth and anything else that legitimately needs the heap wraps itself in
15//! [`allow`], which is a visible, greppable, deliberate act.
16//!
17//! # Cost when it is off
18//!
19//! The check is one thread local load and a branch, on a path that already
20//! calls into the system allocator. It is not measurable next to `malloc`.
21//! Non shard threads never set the flag and pay the same single branch.
22//!
23//! # Using it
24//!
25//! ```no_run
26//! # use yo_alloc::YoAlloc;
27//! #[global_allocator]
28//! static ALLOC: YoAlloc = YoAlloc::new();
29//! ```
30//!
31//! The engine installs this in its binaries and in its test and bench targets.
32//! A library consumer of `yodb` does not get it, because choosing a global
33//! allocator is the application's call and never a library's.
34
35#![deny(missing_docs)]
36
37use std::alloc::{GlobalAlloc, Layout, System};
38use std::cell::Cell;
39
40thread_local! {
41    /// Zero means allocation is allowed. Anything above zero forbids it.
42    ///
43    /// A counter rather than a flag so that [`allow`] nests correctly, which
44    /// matters because arena growth can be reached from more than one depth.
45    static FORBID: Cell<u32> = const { Cell::new(0) };
46}
47
48/// Mark this thread as a shard thread: from here on, allocating aborts.
49///
50/// Called once by each shard as it enters its loop. There is no matching exit
51/// in normal operation because a shard thread never stops being one.
52#[inline]
53pub fn enter_no_alloc() {
54    FORBID.with(|f| f.set(f.get().saturating_add(1)));
55}
56
57/// Undo one [`enter_no_alloc`].
58///
59/// Exists for tests and for the embedded single thread mode (`15` section 7),
60/// where the caller's thread is temporarily the shard and then goes back to
61/// being the caller's thread.
62#[inline]
63pub fn exit_no_alloc() {
64    FORBID.with(|f| f.set(f.get().saturating_sub(1)));
65}
66
67/// Whether allocation is currently forbidden on this thread.
68#[inline]
69pub fn is_forbidden() -> bool {
70    FORBID.with(|f| f.get()) > 0
71}
72
73/// Run `f` with allocation permitted, then restore the previous state.
74///
75/// Every call to this is a claim that the work inside is off the command path.
76/// Wrapping a command path in it to silence an abort is the one way to misuse
77/// this module, so the calls are meant to be few and easy to find.
78#[inline]
79pub fn allow<T>(f: impl FnOnce() -> T) -> T {
80    let saved = FORBID.with(|c| c.replace(0));
81    let guard = Restore(saved);
82    let out = f();
83    drop(guard);
84    out
85}
86
87struct Restore(u32);
88
89impl Drop for Restore {
90    #[inline]
91    fn drop(&mut self) {
92        FORBID.with(|c| c.set(self.0));
93    }
94}
95
96/// The allocator. Delegates to the system allocator and checks the flag first.
97#[derive(Debug, Default, Clone, Copy)]
98pub struct YoAlloc;
99
100impl YoAlloc {
101    /// A new allocator.
102    pub const fn new() -> YoAlloc {
103        YoAlloc
104    }
105}
106
107#[cold]
108#[inline(never)]
109fn violation(layout: Layout, what: &str) -> ! {
110    // No formatting machinery here on purpose. `format!` allocates, and this is
111    // the one place in the process where allocating is known to be unavailable.
112    // Two `write_str` calls and an integer written by hand cost nothing and
113    // cannot recurse.
114    use std::io::Write as _;
115    let mut buf = [0u8; 32];
116    let n = write_usize(&mut buf, layout.size());
117    let mut err = std::io::stderr().lock();
118    let _ = err.write_all(b"yo: allocation on a shard thread: ");
119    let _ = err.write_all(what.as_bytes());
120    let _ = err.write_all(b" of ");
121    let _ = err.write_all(&buf[..n]);
122    let _ = err.write_all(
123        b" bytes.\nThis is Y7: no global allocator call on a command path.\n\
124          Move the allocation to setup, or wrap it in yo_alloc::allow if it is\n\
125          genuinely off the command path.\n",
126    );
127    let _ = err.flush();
128    std::process::abort()
129}
130
131fn write_usize(buf: &mut [u8; 32], mut v: usize) -> usize {
132    if v == 0 {
133        buf[0] = b'0';
134        return 1;
135    }
136    let mut tmp = [0u8; 32];
137    let mut n = 0;
138    while v > 0 {
139        tmp[n] = b'0' + (v % 10) as u8;
140        v /= 10;
141        n += 1;
142    }
143    for i in 0..n {
144        buf[i] = tmp[n - 1 - i];
145    }
146    n
147}
148
149// SAFETY: every method forwards to `System`, which upholds the `GlobalAlloc`
150// contract. The added check only ever diverges before calling through, so no
151// pointer is created, invalidated or leaked by it.
152unsafe impl GlobalAlloc for YoAlloc {
153    #[inline]
154    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
155        if is_forbidden() {
156            violation(layout, "alloc");
157        }
158        // SAFETY: forwarding the caller's own valid layout.
159        unsafe { System.alloc(layout) }
160    }
161
162    #[inline]
163    unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
164        if is_forbidden() {
165            violation(layout, "alloc_zeroed");
166        }
167        // SAFETY: forwarding the caller's own valid layout.
168        unsafe { System.alloc_zeroed(layout) }
169    }
170
171    #[inline]
172    unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
173        if is_forbidden() {
174            violation(layout, "realloc");
175        }
176        // SAFETY: forwarding the caller's own valid pointer and layout.
177        unsafe { System.realloc(ptr, layout, new_size) }
178    }
179
180    #[inline]
181    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
182        // Deallocation is deliberately not checked. A value allocated during
183        // setup and dropped on the shard thread is normal and harmless, and
184        // aborting on it would make the rule unusable. What costs time is the
185        // allocation, and that is what is caught.
186        //
187        // SAFETY: forwarding the caller's own valid pointer and layout.
188        unsafe { System.dealloc(ptr, layout) }
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn starts_permitted() {
198        assert!(!is_forbidden());
199    }
200
201    #[test]
202    fn enter_and_exit_are_balanced() {
203        assert!(!is_forbidden());
204        enter_no_alloc();
205        assert!(is_forbidden());
206        enter_no_alloc();
207        assert!(is_forbidden());
208        exit_no_alloc();
209        assert!(is_forbidden(), "one exit must not undo two enters");
210        exit_no_alloc();
211        assert!(!is_forbidden());
212    }
213
214    #[test]
215    fn allow_permits_and_restores() {
216        enter_no_alloc();
217        assert!(is_forbidden());
218        let v = allow(|| {
219            assert!(!is_forbidden());
220            vec![1u8, 2, 3]
221        });
222        assert_eq!(v.len(), 3);
223        assert!(is_forbidden(), "allow must restore the previous state");
224        exit_no_alloc();
225    }
226
227    #[test]
228    fn allow_nests() {
229        enter_no_alloc();
230        allow(|| {
231            allow(|| assert!(!is_forbidden()));
232            assert!(!is_forbidden());
233        });
234        assert!(is_forbidden());
235        exit_no_alloc();
236    }
237
238    #[test]
239    fn allow_restores_when_the_body_panics() {
240        enter_no_alloc();
241        let r = std::panic::catch_unwind(|| {
242            allow(|| panic!("boom"));
243        });
244        assert!(r.is_err());
245        assert!(
246            is_forbidden(),
247            "a panic inside allow must not leave the thread permitted"
248        );
249        exit_no_alloc();
250    }
251
252    /// The flag is per thread. A shard marking itself must not affect the
253    /// accept loop or a test harness thread.
254    #[test]
255    fn the_flag_does_not_cross_threads() {
256        enter_no_alloc();
257        let other = std::thread::spawn(is_forbidden).join().unwrap();
258        assert!(!other, "another thread saw this thread's flag");
259        exit_no_alloc();
260    }
261
262    #[test]
263    fn integers_render_without_allocating() {
264        let mut buf = [0u8; 32];
265        for (v, want) in [
266            (0usize, "0"),
267            (7, "7"),
268            (1024, "1024"),
269            (2097152, "2097152"),
270        ] {
271            let n = write_usize(&mut buf, v);
272            assert_eq!(std::str::from_utf8(&buf[..n]).unwrap(), want);
273        }
274    }
275}