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
12use monty_types::{BASELINE_MEMORY, LIVE_MEMORY, OOM_EXIT_CODE};
13
14/// The session's soft and hard ceilings.
15/// Counting starts with the process: a counter armed later would see `dealloc`s
16/// it never charged and underflow.
17static SOFT_LIMIT: AtomicUsize = AtomicUsize::new(usize::MAX);
18static HARD_LIMIT: AtomicUsize = AtomicUsize::new(usize::MAX);
19
20/// Room between the soft and hard limits for exception machinery and work
21/// between interpreter checkpoints. Type checking gets a larger gap because
22/// its stubs and caches are allocated outside Python execution.
23const BASE_HEADROOM: usize = 4 * 1024 * 1024;
24const TYPE_CHECK_HEADROOM: usize = 32 * 1024 * 1024;
25
26/// Applies a session's soft memory limit and the hard ceiling above it.
27/// Call it after every request, since a session can also arrive (or end)
28/// through a restored dump or a reset; re-applying mid-session is a no-op.
29///
30/// On a 32-bit target (wasm) a limit near 4 GiB saturates the arithmetic and
31/// leaves the worker uncapped — there is no cap to express.
32pub fn set_limit(max_memory: Option<usize>, type_check: bool) -> Result<(), &'static str> {
33    let live = LIVE_MEMORY.load(Ordering::Relaxed);
34    if live == 0 {
35        return Err("monty-alloc is not installed as the global allocator");
36    }
37    // `fetch_min` both reads and lowers the baseline: the first arming, on a
38    // pristine worker, sets it, and a later leaner moment can only improve it.
39    let baseline = BASELINE_MEMORY.fetch_min(live, Ordering::Relaxed).min(live);
40    let (soft, hard) = match max_memory {
41        Some(bytes) => {
42            let soft = baseline.saturating_add(bytes);
43            let headroom = if type_check { TYPE_CHECK_HEADROOM } else { BASE_HEADROOM };
44            (soft, soft.saturating_add(headroom))
45        }
46        None => (usize::MAX, usize::MAX),
47    };
48    // Publish the protective ceiling before lowering the soft checkpoint.
49    HARD_LIMIT.store(hard, Ordering::Relaxed);
50    SOFT_LIMIT.store(soft, Ordering::Relaxed);
51    Ok(())
52}
53
54/// The system allocator, plus the live-byte count that enforces the memory
55/// limit and a null check that ends the process deliberately.
56pub struct LimitedAllocator;
57
58// SAFETY: every method forwards its arguments unchanged to `System` and returns
59// what `System` returned (or diverges). No pointer is fabricated, aliased or
60// freed here, so this upholds exactly the invariants `System` upholds.
61unsafe impl GlobalAlloc for LimitedAllocator {
62    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
63        charge(layout.size());
64        // SAFETY: `layout` comes from the caller and is forwarded unchanged.
65        let ptr = unsafe { System.alloc(layout) };
66        if ptr.is_null() {
67            out_of_memory(format_args!(
68                "monty worker: allocation of {} bytes failed",
69                layout.size()
70            ));
71        }
72        ptr
73    }
74
75    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
76        refund(layout.size());
77        // SAFETY: `ptr` came from our `alloc`/`realloc` with this same `layout`.
78        unsafe { System.dealloc(ptr, layout) };
79    }
80
81    // Overridden rather than left to the default (which routes through `alloc`)
82    // so `System` keeps using calloc's pre-zeroed pages.
83    unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
84        charge(layout.size());
85        // SAFETY: `layout` comes from the caller and is forwarded unchanged.
86        let ptr = unsafe { System.alloc_zeroed(layout) };
87        if ptr.is_null() {
88            out_of_memory(format_args!(
89                "monty worker: allocation of {} bytes failed",
90                layout.size()
91            ));
92        }
93        ptr
94    }
95
96    // Overridden for the same reason: the default reallocates and copies, while
97    // `System` can often grow a block in place.
98    unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
99        if let Some(size_change) = new_size.checked_sub(layout.size()) {
100            charge(size_change);
101        } else {
102            refund(layout.size() - new_size);
103        }
104        // SAFETY: `ptr`/`layout` describe a live block from this allocator, and
105        // `new_size` is the caller's — all forwarded unchanged.
106        let new_ptr = unsafe { System.realloc(ptr, layout, new_size) };
107        if new_ptr.is_null() {
108            out_of_memory(format_args!("monty worker: allocation of {new_size} bytes failed"));
109        }
110        new_ptr
111    }
112}
113
114/// Adds `size` to the live total, exiting only past the hard limit.
115///
116/// The common path compares against the soft limit first. Once past it, the
117/// interpreter will raise at its next checkpoint unless this burst also crosses
118/// the hard ceiling, in which case allocation must stop immediately.
119#[inline]
120fn charge(size: usize) {
121    let live = LIVE_MEMORY.fetch_add(size, Ordering::Relaxed).saturating_add(size);
122    if live > SOFT_LIMIT.load(Ordering::Relaxed) && live > HARD_LIMIT.load(Ordering::Relaxed) {
123        out_of_memory(format_args!(
124            "monty worker: allocation of {size} bytes exceeds the memory limit"
125        ));
126    }
127}
128
129/// Returns `size` to the live total. `Relaxed` throughout: the count only has to
130/// be eventually right, and no other memory is published through it.
131#[inline]
132fn refund(size: usize) {
133    LIVE_MEMORY.fetch_sub(size, Ordering::Relaxed);
134}
135
136/// Reports why memory ran out and ends the process — never by panicking, whose
137/// machinery allocates. How it ends is the `exit-code` feature's choice; see the
138/// crate docs. Skipping destructors can leave a partial frame on the transport,
139/// which the host already treats as a dead worker.
140#[cold]
141#[inline(never)]
142fn out_of_memory(reason: fmt::Arguments<'_>) -> ! {
143    // A genuinely exhausted host can fail the write and re-enter: let the first
144    // caller write and send any re-entrant one straight to the end.
145    static REPORTING: AtomicBool = AtomicBool::new(false);
146    // Lift the limit first — writing to stderr allocates (the handle's lock),
147    // which under an exceeded limit would re-enter and be silenced below,
148    // losing the message. Safe because this path never returns.
149    SOFT_LIMIT.store(usize::MAX, Ordering::Relaxed);
150    HARD_LIMIT.store(usize::MAX, Ordering::Relaxed);
151    if !REPORTING.swap(true, Ordering::Relaxed) {
152        let _ = writeln!(io::stderr(), "{reason}");
153    }
154    #[cfg(feature = "exit-code")]
155    process::exit(OOM_EXIT_CODE);
156    #[cfg(not(feature = "exit-code"))]
157    process::abort();
158}