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