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:
@@ -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}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user