makepad/libs/relax/compiler/tests/modules/unload_inside.rs

111 lines
4.9 KiB
Rust

// Module unload (MEMRECLAIM.REVIEW F2, user decision 2026-10-23): an unloaded module's memory is
// never used again. Its pages go back, its address range stays reserved and inaccessible, and its
// arena stays as a tombstone that refuses entry. A stale access into the range is never contained:
// the process ends with a report naming the module.
// - default: calls after the unload are refused; windows of later modules never land on a dead one
// (the dead windows stay counted, the live arena count does not grow with them).
// - SAFE2_UNLOAD_CRASH=parked: a thread parked inside the module (in a host call it made) when
// the module is unloaded resumes into its window -> the process ends (JIT code faulting).
// - SAFE2_UNLOAD_CRASH=trusted_call (trusted modules): a call of an unloaded module's fn through a
// kept fn pointer runs into its invalidated code -> parked / the process ends (INVALID).
// - SAFE2_UNLOAD_CRASH=host: host code keeps a pointer the module handed it and reads it after the
// unload -> the process ends (host code faulting).
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
pub static PARKED: AtomicBool = AtomicBool::new(false);
pub static RELEASE: AtomicBool = AtomicBool::new(false);
pub static KEPT: AtomicU64 = AtomicU64::new(0);
/// Part of the host's API the parker module calls: waits until the test lets it go.
pub fn host_park(x: u64) -> u64 {
PARKED.store(true, Ordering::SeqCst);
while !RELEASE.load(Ordering::SeqCst) {
std::thread::sleep(std::time::Duration::from_millis(1));
}
x
}
/// Host API that (wrongly) keeps the address of data the module passed in.
pub fn host_keep(v: &[u64]) -> u64 {
KEPT.store(v.as_ptr() as u64, Ordering::SeqCst);
v.len() as u64
}
const PARKER: &str = r#"
pub fn parked(x: u64) -> u64 {
// heap and shadow-stack state that lives across the host call
let v: Vec<u64> = (0..4096u64).map(|i| i ^ x).collect();
let mut local = [0u64; 64];
for i in 0..64 { local[i] = v[i * 7] + 1; }
let back = test_crate::host_park(x);
let mut s = back;
for i in 0..64 { s = s.wrapping_add(local[i]); }
for e in v.iter() { s = s.wrapping_add(*e); }
s
}
pub fn hand_over() -> u64 {
let v: Vec<u64> = (0..512u64).collect();
let n = test_crate::host_keep(&v);
std::mem::forget(v);
n
}
pub fn f(x: u64) -> u64 { let v = vec![x; 512]; v.iter().sum() }
"#;
#[test]
fn unload_inside() {
use std::relax::Module;
let mode = std::env::var("SAFE2_UNLOAD_CRASH").unwrap_or_default();
let parker = Module::load_source("parker", "parker.rs", PARKER, &["test_crate"]).unwrap();
if mode == "parked" {
let parked: fn(u64) -> u64 = parker.get("parked").unwrap();
let t = std::thread::spawn(move || std::relax::guard(0, || parked(41)));
while !PARKED.load(Ordering::SeqCst) {
std::thread::sleep(std::time::Duration::from_millis(1));
}
parker.unload();
RELEASE.store(true, Ordering::SeqCst);
// not reached: the parked call's next access into the dead window ends the process,
// even under a guard
let r = t.join();
println!("out parked call after unload was contained: {:?}", r.map(|g| g.is_ok()));
return;
}
if mode == "trusted_call" {
// MAKEPAD_RUN_MODULES_TRUSTED=1: the export is the module fn's own slot; after the unload its
// code is invalidated (invalid.rs) and a call through the kept fn pointer is a stale access
let f: fn(u64) -> u64 = parker.get("f").unwrap();
assert_eq!(f(2), 1024);
parker.unload();
// not reached past this call
let v = f(3);
println!("out called an unloaded module's code: {}", v);
return;
}
if mode == "host" {
let hand_over: fn() -> u64 = parker.get("hand_over").unwrap();
assert_eq!(hand_over(), 512);
parker.unload();
let p = KEPT.load(Ordering::SeqCst) as *const u64;
// not reached past this read
let v = unsafe { std::ptr::read_volatile(p.add(5)) };
println!("out host read a dead window: {}", v);
return;
}
let f: fn(u64) -> u64 = parker.get("f").unwrap();
assert_eq!(f(2), 1024);
let before = std::relax::sandbox_stats();
parker.unload();
// the tombstone refuses entry (the wrapper panics "unloaded")
let refused = std::relax::guard(0, || f(2)).is_err();
println!("out calls after unload are refused: {}", refused);
// load / unload cycles: every dead window stays (counted), live arenas do not pile up
for k in 0..40 {
let m = Module::load_source(&format!("cycle{}", k), "cycle.rs", "pub fn f(x: u64) -> u64 { let v = vec![x; 512]; v.iter().sum() }", &[]).unwrap();
let g: fn(u64) -> u64 = m.get("f").unwrap();
assert_eq!(g(k), k * 512);
m.unload();
}
let after = std::relax::sandbox_stats();
println!("out dead windows stay reserved: {}", after[4] == before[4] + 41 && after[3] == before[3] - 1);
}