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//! # Three modes, and why an abort is not the only one
24//!
25//! An abort tells you about one violation per run, which is the wrong tool for
26//! finding out how many there are. Nothing in this project had been checked
27//! against Y7 since the rule was written down, so the first question is not
28//! "stop on the first one" but "what is the list".
29//!
30//! [`Mode::Report`] answers that. It suspends the check, captures a backtrace,
31//! prints each distinct site once and counts the repeats, and lets the
32//! allocation through. It allocates while it does this, on purpose and with the
33//! check turned off around it, because a debugging mode that cannot use the heap
34//! cannot tell you where you are.
35//!
36//! [`Mode::Abort`] is the rule as written, for a build that is expected to be
37//! clean. [`Mode::Off`] is the default, so installing the allocator does not
38//! change what a shipped binary does until somebody asks for it.
39//!
40//! Off costs nothing rather than costing a branch, because nothing arms the
41//! thread: [`guard`] is where the mode is read, and when it is off the thread
42//! flag is never set and the allocator's check is the same false it would be on
43//! any other thread.
44//!
45//! # What it found the first time it was armed
46//!
47//! `yodb` installs this and `pump` wraps its dispatch in [`guard`], so
48//! `YO_ALLOC=report yodb serve` answers the question. Driven with about seventy
49//! commands covering every type, it reported 31 distinct sites, and they are not
50//! one problem:
51//!
52//! Most of them are the first touch of a key. Creating a set, hash, list or
53//! zset allocates the body, and the slab that holds bodies of that type doubles
54//! when it fills. That is real allocation on a command path and it is also the
55//! only sensible place for it, so those sites want a claim written down and an
56//! [`allow`] around them rather than a fix.
57//!
58//! The rest are the ones worth having: a `to_vec` of the value in `APPEND`,
59//! `SETRANGE`, `EXPIRE` and `DUMP`, a `Vec` built per call to hold the operands
60//! of a set operation, a boxed `dyn FnMut` in the intersection, and a number
61//! rendered into a fresh `Vec` in `LMOVE` and in `ZADD`. Every one of those is
62//! per command and in steady state, which is exactly what Y7 is about.
63//!
64//! So the order is: report first, sort the list into the two piles, fix the
65//! second pile and annotate the first, and only then turn [`Mode::Abort`] on for
66//! a build that has to stay clean.
67//!
68//! # Using it
69//!
70//! ```no_run
71//! # use yo_alloc::YoAlloc;
72//! #[global_allocator]
73//! static ALLOC: YoAlloc = YoAlloc::new();
74//! ```
75//!
76//! The engine installs this in `yodb`. A library consumer of `yodb` does not get
77//! it, because choosing a global allocator is the application's call and never a
78//! library's.
79
80#![deny(missing_docs)]
81
82use std::alloc::{GlobalAlloc, Layout, System};
83use std::cell::Cell;
84use std::collections::BTreeMap;
85use std::sync::Mutex;
86use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
87
88thread_local! {
89    /// Zero means allocation is allowed. Anything above zero forbids it.
90    ///
91    /// A counter rather than a flag so that [`allow`] nests correctly, which
92    /// matters because arena growth can be reached from more than one depth.
93    static FORBID: Cell<u32> = const { Cell::new(0) };
94}
95
96/// What happens when a marked thread allocates.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
98pub enum Mode {
99    /// Nothing. [`guard`] does not mark the thread and the check never fires.
100    ///
101    /// The default, so that installing the allocator in a binary is not on its
102    /// own a change to what that binary does.
103    #[default]
104    Off,
105    /// Print each distinct site once, count the rest, and carry on.
106    Report,
107    /// Abort the process on the first one. Y7 as written.
108    Abort,
109}
110
111/// The mode, as a number, because a static has to be something an atomic holds.
112static MODE: AtomicU8 = AtomicU8::new(0);
113
114/// How many violations have been seen in [`Mode::Report`].
115static SEEN_TOTAL: AtomicU64 = AtomicU64::new(0);
116
117/// One entry per distinct backtrace, so a site in a loop prints once.
118static SITES: Mutex<BTreeMap<u64, Site>> = Mutex::new(BTreeMap::new());
119
120/// What was seen at one site.
121#[derive(Debug)]
122struct Site {
123    /// How many allocations landed here.
124    count: u64,
125    /// The largest one, which is usually the one worth looking at first.
126    largest: usize,
127}
128
129/// What a marked thread does when it allocates.
130#[must_use]
131pub fn mode() -> Mode {
132    match MODE.load(Ordering::Relaxed) {
133        1 => Mode::Report,
134        2 => Mode::Abort,
135        _ => Mode::Off,
136    }
137}
138
139/// Set what a marked thread does when it allocates.
140///
141/// Meant to be called once, from `main`, before any thread is marked. It is an
142/// atomic store rather than a `OnceLock` so that a test can set it and put it
143/// back, which is the only reason it is allowed to happen twice.
144pub fn set_mode(m: Mode) {
145    MODE.store(
146        match m {
147            Mode::Off => 0,
148            Mode::Report => 1,
149            Mode::Abort => 2,
150        },
151        Ordering::Relaxed,
152    );
153}
154
155/// Set the mode from `YO_ALLOC`, which is `off`, `report` or `abort`.
156///
157/// Answers `None` when the variable is set to something else, and the caller is
158/// expected to refuse to start rather than carry on. A typo that silently turns
159/// the check off is precisely the failure this module exists to prevent, so it
160/// is not treated as an unset variable.
161///
162/// An unset variable is [`Mode::Off`] and is not an error.
163#[must_use]
164pub fn set_mode_from_env() -> Option<Mode> {
165    let m = parse_mode(std::env::var("YO_ALLOC").ok().as_deref())?;
166    set_mode(m);
167    Some(m)
168}
169
170/// The reading half of [`set_mode_from_env`], split out so it can be tested
171/// without a process wide environment change.
172fn parse_mode(v: Option<&str>) -> Option<Mode> {
173    match v {
174        None | Some("" | "off") => Some(Mode::Off),
175        Some("report") => Some(Mode::Report),
176        Some("abort") => Some(Mode::Abort),
177        Some(_) => None,
178    }
179}
180
181/// Mark this thread for the length of the returned value, if the mode says so.
182///
183/// This is what a command loop wraps its dispatch in. It is a guard rather than
184/// a pair of calls because a panic in the middle of a batch would otherwise
185/// leave the thread marked for the rest of the process, and a thread that can
186/// never allocate again is a worse failure than the one being looked for.
187///
188/// A no-op under [`Mode::Off`], down to not touching the thread local, so the
189/// cost of having this in the loop when nobody asked for it is one relaxed load
190/// per batch.
191#[must_use = "the mark lasts as long as the guard, so dropping it here does nothing"]
192pub fn guard() -> Guard {
193    let on = mode() != Mode::Off;
194    if on {
195        enter_no_alloc();
196    }
197    Guard(on)
198}
199
200/// The mark from [`guard`], undone when it goes out of scope.
201#[derive(Debug)]
202pub struct Guard(bool);
203
204impl Drop for Guard {
205    #[inline]
206    fn drop(&mut self) {
207        if self.0 {
208            exit_no_alloc();
209        }
210    }
211}
212
213/// Everything [`Mode::Report`] collected, as `(sites, allocations)`.
214///
215/// Both are zero in the other two modes, which is what makes this worth calling
216/// unconditionally at shutdown.
217#[must_use]
218pub fn seen() -> (usize, u64) {
219    let n = SITES.lock().map_or(0, |s| s.len());
220    (n, SEEN_TOTAL.load(Ordering::Relaxed))
221}
222
223/// Mark this thread as a shard thread: from here on, allocating aborts.
224///
225/// Called once by each shard as it enters its loop. There is no matching exit
226/// in normal operation because a shard thread never stops being one. A loop that
227/// is not a shard's, and so does want the mark to end, wants [`guard`].
228#[inline]
229pub fn enter_no_alloc() {
230    FORBID.with(|f| f.set(f.get().saturating_add(1)));
231}
232
233/// Undo one [`enter_no_alloc`].
234///
235/// Exists for tests and for the embedded single thread mode (`15` section 7),
236/// where the caller's thread is temporarily the shard and then goes back to
237/// being the caller's thread.
238#[inline]
239pub fn exit_no_alloc() {
240    FORBID.with(|f| f.set(f.get().saturating_sub(1)));
241}
242
243/// Whether allocation is currently forbidden on this thread.
244#[inline]
245pub fn is_forbidden() -> bool {
246    FORBID.with(|f| f.get()) > 0
247}
248
249/// Run `f` with allocation permitted, then restore the previous state.
250///
251/// Every call to this is a claim that the work inside is off the command path.
252/// Wrapping a command path in it to silence an abort is the one way to misuse
253/// this module, so the calls are meant to be few and easy to find.
254#[inline]
255pub fn allow<T>(f: impl FnOnce() -> T) -> T {
256    let saved = FORBID.with(|c| c.replace(0));
257    let guard = Restore(saved);
258    let out = f();
259    drop(guard);
260    out
261}
262
263/// Run `f`, which is a key coming into existence for the first time.
264///
265/// [`allow`] with a name on it, and the name is the claim. Y7 says no allocation
266/// on a command path, and the first `SADD` to a key that was not there has to
267/// make a set somewhere. There is no arrangement of this code that avoids it and
268/// no reason to want one: it happens once per key rather than once per command,
269/// and a workload that creates a key on every command is one where the
270/// allocation is the smallest thing it is paying for.
271///
272/// So the rule this module enforces is the one that is actually true. Nothing on
273/// a command path allocates except a key being created, and every place that
274/// does is this call, which makes the list of them a grep rather than an
275/// argument.
276///
277/// This is the one way to misuse the module. Wrapping steady state work in it to
278/// stop an abort would leave the check passing and the rule broken, which is
279/// worse than not having the check.
280#[inline]
281pub fn first_touch<T>(f: impl FnOnce() -> T) -> T {
282    allow(f)
283}
284
285struct Restore(u32);
286
287impl Drop for Restore {
288    #[inline]
289    fn drop(&mut self) {
290        FORBID.with(|c| c.set(self.0));
291    }
292}
293
294/// The allocator. Delegates to the system allocator and checks the flag first.
295#[derive(Debug, Default, Clone, Copy)]
296pub struct YoAlloc;
297
298impl YoAlloc {
299    /// A new allocator.
300    pub const fn new() -> YoAlloc {
301        YoAlloc
302    }
303}
304
305/// A marked thread allocated. Report it or stop the process.
306///
307/// [`Mode::Off`] lands here too and aborts, because a thread is only ever marked
308/// because something asked for it. [`guard`] does not mark under `Off`, so the
309/// only way to reach this with the mode off is a direct [`enter_no_alloc`], and
310/// that call means what it has always meant.
311#[cold]
312#[inline(never)]
313fn violation(layout: Layout, what: &str) {
314    if mode() == Mode::Report {
315        report(layout, what);
316        return;
317    }
318    abort_now(layout, what)
319}
320
321/// Note the site and let the allocation through.
322///
323/// Runs with the check suspended, because everything here allocates: capturing a
324/// backtrace, rendering it, and keeping it in a map. That is the deal in this
325/// mode. Without the suspension the first violation would recurse until the
326/// stack ran out, which is a worse way to learn about a `format!` in a hot loop
327/// than being told where it is.
328fn report(layout: Layout, what: &str) {
329    SEEN_TOTAL.fetch_add(1, Ordering::Relaxed);
330    allow(|| {
331        let trace = std::backtrace::Backtrace::force_capture().to_string();
332        // The whole rendered trace is the identity of the site. Two allocations
333        // from the same line reached by different callers are different entries,
334        // which is what you want when the line is inside a helper.
335        let key = fnv1a(trace.as_bytes());
336        let Ok(mut sites) = SITES.lock() else {
337            return;
338        };
339        let size = layout.size();
340        match sites.entry(key) {
341            std::collections::btree_map::Entry::Occupied(mut e) => {
342                let s = e.get_mut();
343                s.count += 1;
344                s.largest = s.largest.max(size);
345            }
346            std::collections::btree_map::Entry::Vacant(e) => {
347                e.insert(Site {
348                    count: 1,
349                    largest: size,
350                });
351                // Printed once, on the way in, so that a run that ends in a
352                // crash still leaves the list behind.
353                eprintln!("yo: allocation on a marked thread: {what} of {size} bytes\n{trace}");
354            }
355        }
356    });
357}
358
359/// 64 bit FNV-1a. Enough to tell two backtraces apart and short enough to write
360/// out rather than take a dependency for.
361fn fnv1a(bytes: &[u8]) -> u64 {
362    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
363    for &b in bytes {
364        h ^= u64::from(b);
365        h = h.wrapping_mul(0x0000_0100_0000_01b3);
366    }
367    h
368}
369
370#[cold]
371#[inline(never)]
372fn abort_now(layout: Layout, what: &str) -> ! {
373    // No formatting machinery here on purpose. `format!` allocates, and this is
374    // the one place in the process where allocating is known to be unavailable.
375    // Two `write_str` calls and an integer written by hand cost nothing and
376    // cannot recurse.
377    use std::io::Write as _;
378    let mut buf = [0u8; 32];
379    let n = write_usize(&mut buf, layout.size());
380    let mut err = std::io::stderr().lock();
381    let _ = err.write_all(b"yo: allocation on a shard thread: ");
382    let _ = err.write_all(what.as_bytes());
383    let _ = err.write_all(b" of ");
384    let _ = err.write_all(&buf[..n]);
385    let _ = err.write_all(
386        b" bytes.\nThis is Y7: no global allocator call on a command path.\n\
387          Move the allocation to setup, or wrap it in yo_alloc::allow if it is\n\
388          genuinely off the command path.\n",
389    );
390    let _ = err.flush();
391    std::process::abort()
392}
393
394fn write_usize(buf: &mut [u8; 32], mut v: usize) -> usize {
395    if v == 0 {
396        buf[0] = b'0';
397        return 1;
398    }
399    let mut tmp = [0u8; 32];
400    let mut n = 0;
401    while v > 0 {
402        tmp[n] = b'0' + (v % 10) as u8;
403        v /= 10;
404        n += 1;
405    }
406    for i in 0..n {
407        buf[i] = tmp[n - 1 - i];
408    }
409    n
410}
411
412// SAFETY: every method forwards to `System`, which upholds the `GlobalAlloc`
413// contract. The added check only ever diverges before calling through, so no
414// pointer is created, invalidated or leaked by it.
415unsafe impl GlobalAlloc for YoAlloc {
416    #[inline]
417    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
418        if is_forbidden() {
419            violation(layout, "alloc");
420        }
421        // SAFETY: forwarding the caller's own valid layout.
422        unsafe { System.alloc(layout) }
423    }
424
425    #[inline]
426    unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
427        if is_forbidden() {
428            violation(layout, "alloc_zeroed");
429        }
430        // SAFETY: forwarding the caller's own valid layout.
431        unsafe { System.alloc_zeroed(layout) }
432    }
433
434    #[inline]
435    unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
436        if is_forbidden() {
437            violation(layout, "realloc");
438        }
439        // SAFETY: forwarding the caller's own valid pointer and layout.
440        unsafe { System.realloc(ptr, layout, new_size) }
441    }
442
443    #[inline]
444    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
445        // Deallocation is deliberately not checked. A value allocated during
446        // setup and dropped on the shard thread is normal and harmless, and
447        // aborting on it would make the rule unusable. What costs time is the
448        // allocation, and that is what is caught.
449        //
450        // SAFETY: forwarding the caller's own valid pointer and layout.
451        unsafe { System.dealloc(ptr, layout) }
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    #[test]
460    fn starts_permitted() {
461        assert!(!is_forbidden());
462    }
463
464    #[test]
465    fn enter_and_exit_are_balanced() {
466        assert!(!is_forbidden());
467        enter_no_alloc();
468        assert!(is_forbidden());
469        enter_no_alloc();
470        assert!(is_forbidden());
471        exit_no_alloc();
472        assert!(is_forbidden(), "one exit must not undo two enters");
473        exit_no_alloc();
474        assert!(!is_forbidden());
475    }
476
477    #[test]
478    fn allow_permits_and_restores() {
479        enter_no_alloc();
480        assert!(is_forbidden());
481        let v = allow(|| {
482            assert!(!is_forbidden());
483            vec![1u8, 2, 3]
484        });
485        assert_eq!(v.len(), 3);
486        assert!(is_forbidden(), "allow must restore the previous state");
487        exit_no_alloc();
488    }
489
490    #[test]
491    fn allow_nests() {
492        enter_no_alloc();
493        allow(|| {
494            allow(|| assert!(!is_forbidden()));
495            assert!(!is_forbidden());
496        });
497        assert!(is_forbidden());
498        exit_no_alloc();
499    }
500
501    #[test]
502    fn allow_restores_when_the_body_panics() {
503        enter_no_alloc();
504        let r = std::panic::catch_unwind(|| {
505            allow(|| panic!("boom"));
506        });
507        assert!(r.is_err());
508        assert!(
509            is_forbidden(),
510            "a panic inside allow must not leave the thread permitted"
511        );
512        exit_no_alloc();
513    }
514
515    /// The flag is per thread. A shard marking itself must not affect the
516    /// accept loop or a test harness thread.
517    #[test]
518    fn the_flag_does_not_cross_threads() {
519        enter_no_alloc();
520        let other = std::thread::spawn(is_forbidden).join().unwrap();
521        assert!(!other, "another thread saw this thread's flag");
522        exit_no_alloc();
523    }
524
525    /// The mode is one static for the whole process, so the tests that move it
526    /// take turns. Without this they would race with each other rather than with
527    /// anything real.
528    static MODE_TESTS: Mutex<()> = Mutex::new(());
529
530    fn one_at_a_time() -> std::sync::MutexGuard<'static, ()> {
531        MODE_TESTS.lock().unwrap_or_else(|e| e.into_inner())
532    }
533
534    #[test]
535    fn the_mode_starts_off_and_survives_a_round_trip() {
536        let _turn = one_at_a_time();
537        assert_eq!(Mode::default(), Mode::Off, "off is the default");
538        for m in [Mode::Report, Mode::Abort, Mode::Off] {
539            set_mode(m);
540            assert_eq!(mode(), m);
541        }
542    }
543
544    #[test]
545    fn the_env_variable_reads_three_words_and_refuses_the_rest() {
546        assert_eq!(parse_mode(None), Some(Mode::Off));
547        assert_eq!(parse_mode(Some("")), Some(Mode::Off));
548        assert_eq!(parse_mode(Some("off")), Some(Mode::Off));
549        assert_eq!(parse_mode(Some("report")), Some(Mode::Report));
550        assert_eq!(parse_mode(Some("abort")), Some(Mode::Abort));
551        // A typo has to be an error rather than a quiet off, because a quiet off
552        // is the check not running while somebody believes it is.
553        assert_eq!(parse_mode(Some("abrot")), None);
554        assert_eq!(parse_mode(Some("Report")), None);
555        assert_eq!(parse_mode(Some("1")), None);
556    }
557
558    /// Both halves of the guard, in a thread of its own so that setting the mode
559    /// cannot be seen by another test's assertion about the flag.
560    #[test]
561    fn the_guard_marks_only_when_the_mode_asks() {
562        let _turn = one_at_a_time();
563        std::thread::spawn(|| {
564            set_mode(Mode::Off);
565            {
566                let _g = guard();
567                assert!(!is_forbidden(), "off must not mark the thread at all");
568            }
569            for m in [Mode::Report, Mode::Abort] {
570                set_mode(m);
571                {
572                    let _g = guard();
573                    assert!(is_forbidden(), "{m:?} must mark it");
574                }
575                assert!(!is_forbidden(), "and the guard must undo it");
576            }
577            set_mode(Mode::Off);
578        })
579        .join()
580        .unwrap();
581    }
582
583    #[test]
584    fn the_guard_unmarks_when_the_body_panics() {
585        let _turn = one_at_a_time();
586        std::thread::spawn(|| {
587            set_mode(Mode::Report);
588            let r = std::panic::catch_unwind(|| {
589                let _g = guard();
590                assert!(is_forbidden());
591                panic!("boom");
592            });
593            assert!(r.is_err());
594            assert!(
595                !is_forbidden(),
596                "a panic inside the guard must not leave the thread marked forever"
597            );
598            set_mode(Mode::Off);
599        })
600        .join()
601        .unwrap();
602    }
603
604    /// Report mode has to survive the thing it is reporting on, because the
605    /// reporting itself allocates on a thread where allocating is what set it
606    /// off. It is driven directly here rather than through a real allocation,
607    /// since this crate's own test binary deliberately does not install the
608    /// allocator: several of the tests above spawn threads and panic while the
609    /// flag is up, which is exactly what an installed one would abort on.
610    #[test]
611    fn report_mode_records_instead_of_aborting() {
612        let _turn = one_at_a_time();
613        std::thread::spawn(|| {
614            let (sites_before, total_before) = seen();
615            set_mode(Mode::Report);
616            {
617                let _g = guard();
618                assert!(is_forbidden());
619                // Three from one line, so the total moves by three and the site
620                // is only recorded, and printed, once.
621                for size in [8usize, 64, 4096] {
622                    let layout = Layout::from_size_align(size, 8).unwrap();
623                    violation(layout, "alloc");
624                }
625                assert!(is_forbidden(), "reporting must put the mark back");
626            }
627            set_mode(Mode::Off);
628
629            let (sites, total) = seen();
630            assert_eq!(total - total_before, 3, "every violation is counted");
631            assert_eq!(sites - sites_before, 1, "one line is one site");
632        })
633        .join()
634        .unwrap();
635    }
636
637    #[test]
638    fn distinct_traces_hash_apart() {
639        assert_ne!(fnv1a(b"one"), fnv1a(b"two"));
640        assert_eq!(fnv1a(b"same"), fnv1a(b"same"));
641    }
642
643    #[test]
644    fn integers_render_without_allocating() {
645        let mut buf = [0u8; 32];
646        for (v, want) in [
647            (0usize, "0"),
648            (7, "7"),
649            (1024, "1024"),
650            (2097152, "2097152"),
651        ] {
652            let n = write_usize(&mut buf, v);
653            assert_eq!(std::str::from_utf8(&buf[..n]).unwrap(), want);
654        }
655    }
656}