Skip to main content

monty_alloc/
lib.rs

1#![doc = include_str!("../README.md")]
2#![expect(unsafe_code, reason = "Custom allocator is unsafe, the logic we add is all safe")]
3
4use std::{
5    alloc::{GlobalAlloc, Layout, System},
6    fmt,
7    io::{self, Write},
8    process,
9    sync::atomic::{AtomicBool, AtomicUsize, Ordering},
10};
11
12#[cfg(feature = "exit-code")]
13use monty_types::OOM_EXIT_CODE;
14use monty_types::{BASELINE_MEMORY, LIVE_MEMORY};
15
16/// The absolute ceiling for allocator-backed live bytes.
17/// Counting starts with the process: a counter armed later would see `dealloc`s
18/// it never charged and underflow.
19static HARD_LIMIT: AtomicUsize = AtomicUsize::new(usize::MAX);
20
21/// Applies a worker's hard memory budget relative to its baseline.
22///
23/// Callers choose any headroom above the interpreter's soft limit before
24/// calling this function. Re-apply it after every request, since a session can
25/// also arrive through a restored dump or end through a reset.
26///
27/// On a 32-bit target (wasm) a budget near 4 GiB saturates the arithmetic and
28/// leaves the worker uncapped — there is no cap to express.
29pub fn set_hard_limit(memory_budget: Option<usize>) -> Result<(), &'static str> {
30    let live = LIVE_MEMORY.load(Ordering::Relaxed);
31    if live == 0 {
32        return Err("monty-alloc is not installed as the global allocator");
33    }
34    // `fetch_min` both reads and lowers the baseline: the first arming, on a
35    // pristine worker, sets it, and a later leaner moment can only improve it.
36    let baseline = BASELINE_MEMORY.fetch_min(live, Ordering::Relaxed).min(live);
37    let hard_limit = memory_budget.map_or(usize::MAX, |bytes| baseline.saturating_add(bytes));
38    HARD_LIMIT.store(hard_limit, Ordering::Relaxed);
39    Ok(())
40}
41
42/// The system allocator, plus the live-byte count that enforces the memory
43/// limit and a null check that ends the process deliberately.
44pub struct LimitedAllocator;
45
46// SAFETY: every method forwards its arguments unchanged to `System` and returns
47// what `System` returned (or diverges). No pointer is fabricated, aliased or
48// freed here, so this upholds exactly the invariants `System` upholds.
49unsafe impl GlobalAlloc for LimitedAllocator {
50    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
51        charge(layout.size());
52        // SAFETY: `layout` comes from the caller and is forwarded unchanged.
53        let ptr = unsafe { System.alloc(layout) };
54        if ptr.is_null() {
55            out_of_memory(format_args!(
56                "monty worker: allocation of {} bytes failed",
57                layout.size()
58            ));
59        }
60        ptr
61    }
62
63    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
64        refund(layout.size());
65        // SAFETY: `ptr` came from our `alloc`/`realloc` with this same `layout`.
66        unsafe { System.dealloc(ptr, layout) };
67    }
68
69    // Overridden rather than left to the default (which routes through `alloc`)
70    // so `System` keeps using calloc's pre-zeroed pages.
71    unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
72        charge(layout.size());
73        // SAFETY: `layout` comes from the caller and is forwarded unchanged.
74        let ptr = unsafe { System.alloc_zeroed(layout) };
75        if ptr.is_null() {
76            out_of_memory(format_args!(
77                "monty worker: allocation of {} bytes failed",
78                layout.size()
79            ));
80        }
81        ptr
82    }
83
84    // Overridden for the same reason: the default reallocates and copies, while
85    // `System` can often grow a block in place.
86    unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
87        if let Some(size_change) = new_size.checked_sub(layout.size()) {
88            charge(size_change);
89        } else {
90            refund(layout.size() - new_size);
91        }
92        // SAFETY: `ptr`/`layout` describe a live block from this allocator, and
93        // `new_size` is the caller's — all forwarded unchanged.
94        let new_ptr = unsafe { System.realloc(ptr, layout, new_size) };
95        if new_ptr.is_null() {
96            out_of_memory(format_args!("monty worker: allocation of {new_size} bytes failed"));
97        }
98        new_ptr
99    }
100}
101
102/// Adds `size` to the live total, exiting past the hard limit.
103#[inline]
104fn charge(size: usize) {
105    let live = LIVE_MEMORY.fetch_add(size, Ordering::Relaxed).saturating_add(size);
106    if live > HARD_LIMIT.load(Ordering::Relaxed) {
107        out_of_memory(format_args!(
108            "monty worker: allocation of {size} bytes exceeds the memory limit"
109        ));
110    }
111}
112
113/// Returns `size` to the live total. `Relaxed` throughout: the count only has to
114/// be eventually right, and no other memory is published through it.
115#[inline]
116fn refund(size: usize) {
117    LIVE_MEMORY.fetch_sub(size, Ordering::Relaxed);
118}
119
120/// Reports why memory ran out and ends the process — never by panicking, whose
121/// machinery allocates. How it ends is the `exit-code` feature's choice; see the
122/// crate docs. Skipping destructors can leave a partial frame on the transport,
123/// which the host already treats as a dead worker.
124#[cold]
125#[inline(never)]
126fn out_of_memory(reason: fmt::Arguments<'_>) -> ! {
127    // A genuinely exhausted host can fail the write and re-enter: let the first
128    // caller write and send any re-entrant one straight to the end.
129    static REPORTING: AtomicBool = AtomicBool::new(false);
130    // Lift the limit first — writing to stderr allocates (the handle's lock),
131    // which under an exceeded limit would re-enter and be silenced below,
132    // losing the message. Safe because this path never returns.
133    HARD_LIMIT.store(usize::MAX, Ordering::Relaxed);
134    if !REPORTING.swap(true, Ordering::Relaxed) {
135        let _ = writeln!(io::stderr(), "{reason}");
136    }
137    #[cfg(feature = "exit-code")]
138    process::exit(OOM_EXIT_CODE);
139    #[cfg(not(feature = "exit-code"))]
140    process::abort();
141}