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}