From 5fd8aecf55665a59160336286d4b0ba103c67500 Mon Sep 17 00:00:00 2001 From: "Claude (sandbox)" Date: Sat, 8 Aug 2026 18:58:30 +0000 Subject: [PATCH] =?UTF-8?q?feat(signal,runtime,stack):=20SIGSEGV=20overflo?= =?UTF-8?q?w=20diagnostics=20+=201=20MiB=20guard=20default=20(RFC=20019=20?= =?UTF-8?q?=C2=A77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/signal.rs: process-global SA_SIGINFO|SA_ONSTACK handler installed once at runtime::init (before any scheduler thread -> unracing PRIOR save); per-scheduler-thread 64 KiB sigaltstack registered at schedule_loop entry (a guard hit leaves no stack to handle on). Async-signal-safe throughout: classification is plain loads (const-init TLS Cell + slot atomics), print is fixed-buffer itoa + one write(2), death is SIG_DFL + refault at the same instruction (core-dumpable, correct wait status). - Two-tier classification (agreed): in-guard = definitive; OVERSHOOT window below the guard = 'unprobed (FFI?) frame stepped over it' probable attribution -- the RFC's motivating incident (cargo-vendored gz, not SQLite as the RFC text says) faults there under a small guard. Pure classify() fn, 5 adversarial units incl. saturation at low addresses. - DEFAULT_STACK_GUARD 64 KiB -> 1 MiB (agreed): kernel stack_guard_gap anchor post-Stack-Clash; PROT_NONE is VA-only (no RSS, no page tables, no overcommit charge) so width is free at any actor count. - Unclassified faults reinstate the PRIOR sigaction and refault (agreed): std's own OS-thread overflow diagnostics survive our presence. - Slot: diag_{stack_top,stack_reserve,stack_guard,pid} atomics written in install_actor pre-publish; readable without the cold lock (Stack lives under it); only consulted while CURRENT_SLOT points at the slot, so never stale where read. preempt::current_slot_ptr ungated from smarm-causal (now also the classifier's anchor). - build.rs + cc (agreed Q3): canary/canary.c, 96 KiB local touched low-end first, -fno-stack-clash-protection pinned so hardened toolchains don't probe the canary into uselessness. - tests/stack_diag.rs: subprocess x4 -- Rust recursion tier-1; FFI canary tier-1 at defaults (1 MiB guard catches the jump); tier-2 at guard=4 KiB ('stepped over', reproduces the incident); clean at reserve=256 KiB (the §1 knob is the fix, same frame). FLAGGED (Claude-solo calls): - OVERSHOOT_SLOP = 1 MiB (matches guard default/kernel gap; beyond it attribution would be dishonest). - Altstack 64 KiB, mmap'd once per OS thread, never freed (bounded by thread count; reused across run()s via TLS flag). - Foreign-fault reinstate permanently deregisters our handler; accepted -- the process is dying either way. - Diag geometry as 4 slot atomics (install-time cost only) over a per-switch TLS snapshot (hot-path stores). --- Cargo.toml | 3 + build.rs | 11 ++ canary/canary.c | 14 ++ src/lib.rs | 1 + src/preempt.rs | 11 +- src/runtime.rs | 46 ++++++- src/signal.rs | 321 ++++++++++++++++++++++++++++++++++++++++++++ tests/stack_diag.rs | 141 +++++++++++++++++++ 8 files changed, 542 insertions(+), 6 deletions(-) create mode 100644 build.rs create mode 100644 canary/canary.c create mode 100644 src/signal.rs create mode 100644 tests/stack_diag.rs diff --git a/Cargo.toml b/Cargo.toml index 25c387f..c7e0f70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,9 @@ rq-mutex = [] rq-mpmc = [] rq-striped = [] +[build-dependencies] +cc = "1" + [dependencies] libc = "0.2" diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..f00ab7f --- /dev/null +++ b/build.rs @@ -0,0 +1,11 @@ +fn main() { + // RFC 019 §7 test canary (agreed Q3): compiled without stack-clash + // protection so its 96 KiB local is a genuine one-displacement guard + // jumper; distro-hardened compilers would otherwise probe it page-wise + // and defeat the test's purpose. + cc::Build::new() + .file("canary/canary.c") + .flag_if_supported("-fno-stack-clash-protection") + .compile("smarm_canary"); + println!("cargo:rerun-if-changed=canary/canary.c"); +} diff --git a/canary/canary.c b/canary/canary.c new file mode 100644 index 0000000..bf12e50 --- /dev/null +++ b/canary/canary.c @@ -0,0 +1,14 @@ +/* RFC 019 §7 FFI canary: an honest unprobed C frame with a 96 KiB local, + * touched from its LOW end first — the exact "one sub rsp steps over a small + * guard" pattern the RFC's motivating incident hit (a cargo-vendored gz + * build; cc-invoked builds do not enable -fstack-clash-protection, and this + * file pins that off explicitly so the canary stays a canary even on + * hardened-default toolchains). */ +void smarm_canary_burn(void) { + volatile char buf[96 * 1024]; + buf[0] = 1; /* deepest address first */ + for (unsigned i = 0; i < sizeof buf; i += 4096) { + buf[i] = (char)i; + } + buf[sizeof buf - 1] = 1; +} diff --git a/src/lib.rs b/src/lib.rs index d04f59e..9ad62e5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ //! See `LOOM.md` for the design intent and the deferred-for-later list. pub mod stack; +pub(crate) mod signal; pub mod context; pub mod preempt; pub mod pid; diff --git a/src/preempt.rs b/src/preempt.rs index 221119a..f7d2796 100644 --- a/src/preempt.rs +++ b/src/preempt.rs @@ -98,10 +98,13 @@ pub(crate) fn clear_current_slot() { CURRENT_SLOT.with(|c| c.set(std::ptr::null())); } -/// RFC 007 (`smarm-causal`) — raw pointer to the on-CPU actor's slot, null on -/// the scheduler's own stack. Same lifetime argument as `note_overrun`: the -/// slot is never reclaimed while its actor is on-CPU. -#[cfg(feature = "smarm-causal")] +/// Raw pointer to the on-CPU actor's slot, null on the scheduler's own +/// stack. Same lifetime argument as `note_overrun`: the slot is never +/// reclaimed while its actor is on-CPU. Consumers: the `smarm-causal` +/// profiler (RFC 007) and — unconditionally — the SIGSEGV classifier +/// (RFC 019 §7), which additionally relies on this being a plain load of a +/// const-initialized TLS Cell (no lazy init, no allocation, no dtor): safe +/// from a signal handler. #[inline] pub(crate) fn current_slot_ptr() -> *const crate::runtime::Slot { CURRENT_SLOT.with(|c| c.get()) diff --git a/src/runtime.rs b/src/runtime.rs index 0d6ba2a..15a7810 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -251,7 +251,8 @@ impl Config { /// (probestack touches pages in order); the wide default exists for /// unprobed FFI frames, which can step over a small guard in one /// `sub rsp`. Per-actor override: `SpawnOpts::guard_size`. - /// Default: [`DEFAULT_STACK_GUARD`] (64 KiB). + /// Default: [`DEFAULT_STACK_GUARD`] (1 MiB — the kernel's + /// `stack_guard_gap` convention; see its doc for why width is free). pub fn stack_guard(mut self, n: usize) -> Self { assert!(n > 0, "stack_guard must be non-zero"); self.stack_guard = n; @@ -422,7 +423,17 @@ pub const DEFAULT_STACK_RESERVE: usize = 64 * 1024; /// Default PROT_NONE guard below each actor stack (RFC 019). Raised from one /// page so unprobed C frames cannot leap it. See [`Config::stack_guard`]. -pub const DEFAULT_STACK_GUARD: usize = 64 * 1024; +/// +/// 1 MiB, following the kernel's own answer to the same problem: after Stack +/// Clash (2017) the main-thread guard gap became `stack_guard_gap` = 256 +/// pages, because 4 KiB was jumpable by one honest `sub rsp` and no small +/// constant was defensible. Guard pages are PROT_NONE: virtual address space +/// only — zero RSS, zero page-table entries, no overcommit charge — so the +/// wide default is free at any actor count (1 M actors ≈ 1 TiB of VA against +/// a 128 TiB budget). A frame that jumps even this lands in the tier-2 +/// overshoot window of the SIGSEGV diagnostic (`signal.rs`) instead of +/// silence. +pub const DEFAULT_STACK_GUARD: usize = 1024 * 1024; /// RFC 019 §3: minimum releasable span (`sp − hwm` at park) before the /// park-path shrink spends a syscall. A constant, not a `Config` field @@ -507,6 +518,19 @@ pub(crate) struct Slot { /// with the RFC's introspect surface; the counter exists from birth so /// tests can rely on install resetting it). Single-writer Relaxed. shrink_count: AtomicU32, + /// RFC 019 §7 — stack geometry for the SIGSEGV classifier, readable + /// without the cold lock (the `Stack` itself lives under it). Written in + /// `install_actor` before the Release publish; consulted by the handler + /// only while `preempt::CURRENT_SLOT` points here, i.e. while this actor + /// is on-CPU, so the values are never stale where they are read. 0 = + /// never installed. Usable top of the stack. + pub(crate) diag_stack_top: AtomicUsize, + /// See `diag_stack_top`: the reserve (usable) size. + pub(crate) diag_stack_reserve: AtomicUsize, + /// See `diag_stack_top`: the guard size. + pub(crate) diag_stack_guard: AtomicUsize, + /// See `diag_stack_top`: `(idx << 32) | generation`, for the message. + pub(crate) diag_pid: AtomicU64, /// Pointer into the actor's `Arc` stop flag. Set at spawn, /// nulled at finalize. The box outlives every read: it is only ever read /// on the resume path while the actor cannot be finalized (it is on-CPU). @@ -581,6 +605,10 @@ impl Slot { hwm: AtomicUsize::new(0), parks_since_shrink: AtomicU32::new(0), shrink_count: AtomicU32::new(0), + diag_stack_top: AtomicUsize::new(0), + diag_stack_reserve: AtomicUsize::new(0), + diag_stack_guard: AtomicUsize::new(0), + diag_pid: AtomicU64::new(0), stop_ptr: AtomicPtr::new(std::ptr::null_mut()), closure: AtomicPtr::new(std::ptr::null_mut()), overruns: AtomicU64::new(0), @@ -1137,6 +1165,9 @@ pub struct Runtime { /// Initialise the runtime with the given config. Returns a reusable handle. pub fn init(config: Config) -> Runtime { + // RFC 019 §7: one process-global SIGSEGV handler, installed before any + // scheduler thread (and so before any classifiable fault) can exist. + crate::signal::install_once(); let n = config.resolved_thread_count(); Runtime { inner: RuntimeInner::new( @@ -1513,6 +1544,11 @@ pub(crate) fn install_actor( let pid = Pid::new(idx, gen); let stop = Arc::new(AtomicBool::new(false)); + // RFC 019 §7: geometry for the SIGSEGV classifier, captured before the + // Stack moves under the cold lock. Ordered before readers by the + // publish below. + let (diag_reserve, diag_guard) = stack.shape(); + let diag_top = stack.top() as usize; slot.stop_ptr.store(Arc::as_ptr(&stop) as *mut _, Ordering::Release); { let mut cold = slot.cold.lock(); @@ -1529,6 +1565,10 @@ pub(crate) fn install_actor( slot.hwm.store(sp, Ordering::Relaxed); slot.parks_since_shrink.store(0, Ordering::Relaxed); slot.shrink_count.store(0, Ordering::Relaxed); + slot.diag_stack_top.store(diag_top, Ordering::Relaxed); + slot.diag_stack_reserve.store(diag_reserve, Ordering::Relaxed); + slot.diag_stack_guard.store(diag_guard, Ordering::Relaxed); + slot.diag_pid.store(((idx as u64) << 32) | gen as u64, Ordering::Relaxed); slot.store_closure(closure); slot.reset_counters(); inner.live_actors.fetch_add(1, Ordering::Relaxed); @@ -1780,6 +1820,8 @@ fn fire_due_timers(inner: &Arc, try_only: bool) { // --------------------------------------------------------------------------- fn schedule_loop(inner: &Arc, slot_idx: usize) { + // RFC 019 §7: a guard hit leaves no stack to handle the signal on. + crate::signal::register_altstack(); crate::preempt::configure_preempt(inner.alloc_interval, inner.timeslice_cycles); let stats = &inner.stats[slot_idx]; diff --git a/src/signal.rs b/src/signal.rs new file mode 100644 index 0000000..be35144 --- /dev/null +++ b/src/signal.rs @@ -0,0 +1,321 @@ +//! RFC 019 §7 — overflow diagnostics. +//! +//! One process-global SIGSEGV handler, installed once at [`crate::runtime::init`] +//! (before any scheduler thread exists, so the PRIOR save is unracing), plus a +//! per-scheduler-thread `sigaltstack` registered at `schedule_loop` entry — a +//! guard hit means the faulting stack has no room to run anything, so the +//! altstack is not optional. +//! +//! The handler classifies `si_addr` against the *current* actor only, reached +//! through `preempt::CURRENT_SLOT` — a const-initialized `Cell<*const Slot>` +//! whose access is a plain TLS load (no lazy init, no allocation, no dtor +//! registration), and which every scheduler thread has materialized before an +//! actor can run on it. The slot's diag atomics (`diag_stack_top` & co) are +//! written in `install_actor` before the Release publish and are only consulted +//! here while the actor is on-CPU, so they cannot be stale. +//! +//! Two classification tiers: +//! - **In-guard**: definitive. Rust frames probe pages in order +//! (`__rust_probestack`), so Rust overflow always lands here; so does any C +//! built with `-fstack-clash-protection` (distro-packaged libraries), and — +//! with the 1 MiB default guard — nearly every unprobed frame too. +//! - **Overshoot**: within [`OVERSHOOT_SLOP`] *below* the guard. An unprobed +//! frame (cargo-built C via `cc` almost never enables clash protection) +//! large enough to step over the guard in one `sub rsp`. Attribution is +//! "probable": the address is in unmapped VA that nothing else owns, an +//! actor was on-CPU, and the distance fits a frame — the diagnostic says so. +//! +//! Classified faults print one line (async-signal-safe: stack buffer + +//! `write(2)`, no fmt, no alloc, no locks) and re-raise with default +//! disposition — no unwind, no resume, no fail-soft (jarred; UB-adjacent from +//! a handler). Unclassified faults reinstate the PRIOR handler and refault, so +//! std's own "thread ... has overflowed its stack" diagnostics for OS-thread +//! stacks survive our presence. Reinstating deregisters us for good, which is +//! fine: the process is dying either way. + +use std::cell::Cell; +use std::mem::MaybeUninit; +use std::sync::atomic::Ordering; +use std::sync::Once; + +/// Tier-2 window below the guard. Matches the guard default (and the kernel's +/// `stack_guard_gap`): a frame that out-jumps both the guard and this window +/// in one displacement is past what a diagnostic can honestly attribute. +pub(crate) const OVERSHOOT_SLOP: usize = 1024 * 1024; + +/// Per-scheduler-thread signal stack. MINSIGSTKSZ is ~11 KiB on AVX-512 +/// hardware; 64 KiB leaves the formatter room without mattering to anyone. +/// One per OS thread, never freed: scheduler threads live for the process in +/// practice, and repeated `run()`s on reused threads re-use the registration +/// (the TLS flag), so the leak is bounded by the OS thread count. +const ALTSTACK_SIZE: usize = 64 * 1024; + +static INSTALL: Once = Once::new(); +/// The handler that was installed before ours (std's, typically). Written +/// exactly once inside INSTALL — which completes in `runtime::init` before +/// any scheduler thread (and thus any classifiable fault) can exist — and +/// only read from the handler afterwards. +static mut PRIOR: MaybeUninit = MaybeUninit::uninit(); + +thread_local! { + /// Whether this OS thread has registered its altstack. + static ALTSTACK_SET: Cell = const { Cell::new(false) }; +} + +/// Where a fault landed relative to the current actor's stack. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum FaultClass { + /// Inside `[top − reserve − guard, top − reserve)`: the guard region. + Guard, + /// Within `OVERSHOOT_SLOP` below the guard: stepped over it. Payload is + /// the distance below `guard_lo`. + Overshoot(usize), + /// Not ours to explain. + Foreign, +} + +/// Pure classifier — all edges unit-tested below. `top` is the stack's usable +/// top, `reserve`/`guard` its shape; both page-rounded by `Stack::new`. +pub(crate) fn classify(addr: usize, top: usize, reserve: usize, guard: usize) -> FaultClass { + let guard_hi = top.wrapping_sub(reserve); + let guard_lo = guard_hi.wrapping_sub(guard); + if addr >= guard_lo && addr < guard_hi { + FaultClass::Guard + } else if addr < guard_lo && addr >= guard_lo.saturating_sub(OVERSHOOT_SLOP) { + FaultClass::Overshoot(guard_lo - addr) + } else { + FaultClass::Foreign + } +} + +/// Install the process-global handler. Idempotent; called from +/// `runtime::init`. +pub(crate) fn install_once() { + INSTALL.call_once(|| unsafe { + let mut sa: libc::sigaction = std::mem::zeroed(); + sa.sa_sigaction = handler as *const () as usize; + sa.sa_flags = libc::SA_SIGINFO | libc::SA_ONSTACK; + libc::sigemptyset(&mut sa.sa_mask); + let prior = &mut *std::ptr::addr_of_mut!(PRIOR); + libc::sigaction(libc::SIGSEGV, &sa, prior.as_mut_ptr()); + }); +} + +/// Register this OS thread's altstack (idempotent per thread). Called at +/// `schedule_loop` entry, so every thread that can run an actor has one. +pub(crate) fn register_altstack() { + ALTSTACK_SET.with(|set| { + if set.get() { + return; + } + unsafe { + let sp = libc::mmap( + std::ptr::null_mut(), + ALTSTACK_SIZE, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_PRIVATE | libc::MAP_ANONYMOUS, + -1, + 0, + ); + if sp == libc::MAP_FAILED { + // Degrade: no altstack means a guard hit dies without the + // message (handler can't run) — the pre-RFC behavior, never + // incorrectness. + return; + } + let ss = libc::stack_t { + ss_sp: sp, + ss_flags: 0, + ss_size: ALTSTACK_SIZE, + }; + libc::sigaltstack(&ss, std::ptr::null_mut()); + } + set.set(true); + }); +} + +// --------------------------------------------------------------------------- +// The handler +// --------------------------------------------------------------------------- + +unsafe extern "C" fn handler( + _sig: libc::c_int, + info: *mut libc::siginfo_t, + _ctx: *mut libc::c_void, +) { + let slot_ptr = crate::preempt::current_slot_ptr(); + if !slot_ptr.is_null() { + let slot = &*slot_ptr; + let top = slot.diag_stack_top.load(Ordering::Relaxed); + if top != 0 { + let reserve = slot.diag_stack_reserve.load(Ordering::Relaxed); + let guard = slot.diag_stack_guard.load(Ordering::Relaxed); + let pid = slot.diag_pid.load(Ordering::Relaxed); + let addr = (*info).si_addr() as usize; + match classify(addr, top, reserve, guard) { + FaultClass::Guard => { + let mut b = Buf::new(); + b.s("smarm: actor "); + b.pid(pid); + b.s(" overflowed its stack: fault in the guard region, depth-at-fault="); + b.u(top - addr); + b.s(" bytes (reserve="); + b.u(reserve); + b.s(", guard="); + b.u(guard); + b.s("). Raise stack_reserve (SpawnOpts or Config).\n"); + b.emit(); + die_by_default(); + return; + } + FaultClass::Overshoot(below) => { + let mut b = Buf::new(); + b.s("smarm: actor "); + b.pid(pid); + b.s(" probably overflowed its stack: fault "); + b.u(below); + b.s(" bytes below the guard - an unprobed (FFI?) frame stepped over it (reserve="); + b.u(reserve); + b.s(", guard="); + b.u(guard); + b.s("). Raise stack_guard or stack_reserve.\n"); + b.emit(); + die_by_default(); + return; + } + FaultClass::Foreign => {} + } + } + } + // Not ours: put back whoever was there before us and refault into them. + let prior = &*std::ptr::addr_of!(PRIOR); + libc::sigaction(libc::SIGSEGV, prior.as_ptr(), std::ptr::null_mut()); +} + +/// Reset SIGSEGV to default disposition; returning from the handler then +/// refaults at the same instruction and the process dies the normal death +/// (core-dumpable, correct wait status), exactly as if we were never here — +/// but with the message already on stderr. +unsafe fn die_by_default() { + let mut dfl: libc::sigaction = std::mem::zeroed(); + dfl.sa_sigaction = libc::SIG_DFL; + libc::sigemptyset(&mut dfl.sa_mask); + libc::sigaction(libc::SIGSEGV, &dfl, std::ptr::null_mut()); +} + +// --------------------------------------------------------------------------- +// Async-signal-safe formatting: fixed buffer, decimal itoa, one write(2). +// --------------------------------------------------------------------------- + +struct Buf { + b: [u8; 320], + len: usize, +} + +impl Buf { + fn new() -> Self { + Buf { b: [0; 320], len: 0 } + } + fn s(&mut self, s: &str) { + for &c in s.as_bytes() { + if self.len < self.b.len() { + self.b[self.len] = c; + self.len += 1; + } + } + } + fn u(&mut self, mut n: usize) { + let mut tmp = [0u8; 20]; + let mut i = tmp.len(); + loop { + i -= 1; + tmp[i] = b'0' + (n % 10) as u8; + n /= 10; + if n == 0 { + break; + } + } + for &c in &tmp[i..] { + if self.len < self.b.len() { + self.b[self.len] = c; + self.len += 1; + } + } + } + /// `idx.gen`, unpacked from the install-time packing. + fn pid(&mut self, packed: u64) { + self.u((packed >> 32) as usize); + self.s("."); + self.u((packed & 0xffff_ffff) as usize); + } + fn emit(&self) { + unsafe { + libc::write(2, self.b.as_ptr() as *const libc::c_void, self.len); + } + } +} + +// --------------------------------------------------------------------------- +// Classifier units — the arithmetic edges, before anything integrates. +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::{classify, FaultClass, OVERSHOOT_SLOP}; + + const PG: usize = 4096; + // A synthetic stack far from address-space edges: top at 1 GiB. + const TOP: usize = 1 << 30; + const RESERVE: usize = 16 * PG; + const GUARD: usize = 4 * PG; + const GUARD_HI: usize = TOP - RESERVE; + const GUARD_LO: usize = GUARD_HI - GUARD; + + #[test] + fn inside_guard_both_edges() { + assert_eq!(classify(GUARD_LO, TOP, RESERVE, GUARD), FaultClass::Guard); + assert_eq!(classify(GUARD_HI - 1, TOP, RESERVE, GUARD), FaultClass::Guard); + assert_eq!(classify(GUARD_LO + GUARD / 2, TOP, RESERVE, GUARD), FaultClass::Guard); + } + + #[test] + fn usable_region_is_foreign() { + // A fault inside the RW stack itself isn't a guard hit and must not + // be explained as one. + assert_eq!(classify(GUARD_HI, TOP, RESERVE, GUARD), FaultClass::Foreign); + assert_eq!(classify(TOP - 1, TOP, RESERVE, GUARD), FaultClass::Foreign); + } + + #[test] + fn above_top_is_foreign() { + assert_eq!(classify(TOP, TOP, RESERVE, GUARD), FaultClass::Foreign); + assert_eq!(classify(TOP + PG, TOP, RESERVE, GUARD), FaultClass::Foreign); + } + + #[test] + fn overshoot_window_edges() { + assert_eq!( + classify(GUARD_LO - 1, TOP, RESERVE, GUARD), + FaultClass::Overshoot(1) + ); + assert_eq!( + classify(GUARD_LO - OVERSHOOT_SLOP, TOP, RESERVE, GUARD), + FaultClass::Overshoot(OVERSHOOT_SLOP) + ); + assert_eq!( + classify(GUARD_LO - OVERSHOOT_SLOP - 1, TOP, RESERVE, GUARD), + FaultClass::Foreign + ); + } + + #[test] + fn low_address_stack_saturates_not_wraps() { + // A stack mapped so low that the slop window would underflow: the + // window clips to 0 instead of wrapping around the address space. + let top = RESERVE + GUARD + PG; // guard_lo == PG + assert_eq!(classify(0, top, RESERVE, GUARD), FaultClass::Overshoot(PG)); + // Null-page fault still classified only because it IS within slop + // here; with a normal-height stack it is Foreign (covered above by + // the window-edge test at realistic addresses). + } +} diff --git a/tests/stack_diag.rs b/tests/stack_diag.rs new file mode 100644 index 0000000..eca3bbb --- /dev/null +++ b/tests/stack_diag.rs @@ -0,0 +1,141 @@ +//! RFC 019 §7 — overflow diagnostics, observed from outside via subprocess +//! (mirrors tests/stack.rs's harness, plus stderr capture). +//! +//! Four cases: +//! - Rust recursion at defaults: probed frames walk into the guard → +//! tier-1 definitive message, death by SIGSEGV. +//! - FFI canary (96 KiB unprobed C local) at defaults: first touch lands +//! inside the 1 MiB guard → tier-1 message. +//! - FFI canary with the guard shrunk to 4 KiB: the frame steps over it +//! into unmapped VA below → tier-2 "stepped over" message. This is the +//! RFC's motivating incident (cargo-vendored gz build) reproduced. +//! - FFI canary with reserve raised to 256 KiB: fits, runs clean, exits 0 — +//! the §1 knob is the fix, proven by the same frame. + +use std::env; +use std::process::Command; + +unsafe extern "C" { + fn smarm_canary_burn(); +} + +/// Unbounded probed recursion; each frame dirties 4 KiB. black_box defeats +/// tail-call elision so the walk is real. +#[inline(never)] +#[allow(unconditional_recursion)] +fn recurse_forever(depth: u64) -> u64 { + let mut local = [0u8; 4096]; + local[0] = depth as u8; + std::hint::black_box(&mut local); + recurse_forever(depth + 1).wrapping_add(local[0] as u64) +} + +fn run_as_child_if_requested() { + let mode = match env::var("SMARM_DIAG_SUBTEST") { + Ok(m) => m, + Err(_) => return, + }; + use smarm::runtime::Config; + use smarm::{spawn_with, SpawnOpts}; + let rt = smarm::runtime::init(Config::exact(1)); + rt.run(move || { + let opts = match mode.as_str() { + "rust_overflow" | "ffi_tier1" => SpawnOpts::default(), + // Small guard: the canary's 96 KiB displacement clears it. + "ffi_tier2" => SpawnOpts { guard_size: Some(4096), ..SpawnOpts::default() }, + // Enough reserve: the same frame simply fits. + "ffi_clean" => SpawnOpts { stack_reserve: Some(256 * 1024), ..SpawnOpts::default() }, + other => panic!("unknown subtest {other}"), + }; + let is_rust = mode == "rust_overflow"; + spawn_with(opts, move || { + if is_rust { + std::hint::black_box(recurse_forever(0)); + } else { + unsafe { smarm_canary_burn() }; + } + }) + .join() + .unwrap(); + }); + std::process::exit(0); +} + +fn spawn_subtest(name: &str) -> std::process::Output { + let exe = env::current_exe().unwrap(); + Command::new(exe) + .env("SMARM_DIAG_SUBTEST", name) + .args(["--test-threads=1", "--quiet"]) + .output() + .expect("failed to spawn subprocess") +} + +#[cfg(unix)] +fn assert_died_sigsegv(out: &std::process::Output) { + use std::os::unix::process::ExitStatusExt; + assert_eq!( + out.status.signal(), + Some(11), + "expected death by SIGSEGV, got {:?}; stderr:\n{}", + out.status, + String::from_utf8_lossy(&out.stderr) + ); +} + +#[test] +fn rust_overflow_dies_with_tier1_message() { + run_as_child_if_requested(); + let out = spawn_subtest("rust_overflow"); + assert_died_sigsegv(&out); + let err = String::from_utf8_lossy(&out.stderr); + assert!( + err.contains("overflowed its stack") && err.contains("in the guard region"), + "missing tier-1 diagnostic; stderr:\n{err}" + ); + assert!(err.contains("reserve=65536"), "wrong reserve in message:\n{err}"); + assert!(err.contains("guard=1048576"), "wrong guard in message:\n{err}"); +} + +#[test] +fn ffi_canary_at_defaults_dies_with_tier1_message() { + run_as_child_if_requested(); + let out = spawn_subtest("ffi_tier1"); + assert_died_sigsegv(&out); + let err = String::from_utf8_lossy(&out.stderr); + // 96 KiB displacement from a 64 KiB reserve lands ~32 KiB into the + // 1 MiB guard: definitively classified. + assert!( + err.contains("in the guard region"), + "wide guard should catch the unprobed frame in tier 1; stderr:\n{err}" + ); +} + +#[test] +fn ffi_canary_over_small_guard_dies_with_tier2_message() { + run_as_child_if_requested(); + let out = spawn_subtest("ffi_tier2"); + assert_died_sigsegv(&out); + let err = String::from_utf8_lossy(&out.stderr); + assert!( + err.contains("stepped over it") && err.contains("below the guard"), + "expected tier-2 overshoot attribution; stderr:\n{err}" + ); + assert!(err.contains("guard=4096"), "wrong guard in message:\n{err}"); +} + +#[test] +fn ffi_canary_with_enough_reserve_runs_clean() { + run_as_child_if_requested(); + let out = spawn_subtest("ffi_clean"); + assert!( + out.status.success(), + "canary should fit in 256 KiB reserve, got {:?}; stderr:\n{}", + out.status, + String::from_utf8_lossy(&out.stderr) + ); + let err = String::from_utf8_lossy(&out.stderr); + assert!( + !err.contains("smarm: actor"), + "no diagnostic expected on the clean path; stderr:\n{err}" + ); +}