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}