feat(signal,runtime,stack): SIGSEGV overflow diagnostics + 1 MiB guard default (RFC 019 §7)
- 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).
This commit is contained in:
+44
-2
@@ -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<AtomicBool>` 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<RuntimeInner>, try_only: bool) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn schedule_loop(inner: &Arc<RuntimeInner>, 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];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user