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}