From 1be514a7af6bdc705f2f06fb28645081bea81d37 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 26 Sep 2026 17:14:20 -0700 Subject: [PATCH 1/5] Bound and cache native crash sampling (#357) --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/crash/mod.rs | 121 +++++++++++++---- src/platform_linux/process_usage.rs | 197 ++++++++++++++++++++++++++++ src/snapshot/mod.rs | 36 ++++- src/snapshot/unwind.rs | 125 +++++++++++++++++- tests/diagnostics/crash_facade.rs | 28 +++- 7 files changed, 471 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d3a973fe..38d5ac71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2758,7 +2758,7 @@ dependencies = [ [[package]] name = "kernal-api" -version = "0.1.22" +version = "0.1.23" dependencies = [ "addr2line 0.24.2", "blake3", diff --git a/Cargo.toml b/Cargo.toml index 3584affe..ace67613 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kernal-api" -version = "0.1.22" +version = "0.1.23" build = "build.rs" edition = "2021" rust-version = "1.95.0" diff --git a/src/crash/mod.rs b/src/crash/mod.rs index 52877806..c0007a5e 100644 --- a/src/crash/mod.rs +++ b/src/crash/mod.rs @@ -79,6 +79,28 @@ pub enum CrashPolicy { /// Environment opt-out checked before any crash state is created. pub const NO_CRASH_HANDLER_ENV: &str = "KERNAL_API_NO_CRASH_HANDLER"; +/// Sampling cadence for native crash snapshots. The default starts at 100 ms +/// and backs off exponentially to 1 s while captures are unchanged. A changed +/// capture resets the cadence. The sampler can wait longer than +/// `max_interval` when needed to preserve its one-percent CPU duty budget. +#[derive(Clone, Copy, Debug)] +pub struct CrashSamplerConfig { + /// Time before the first capture and between captures after a change. + pub interval: Duration, + /// Longest adaptive delay between unchanged captures, before the CPU + /// duty-cycle floor is applied. + pub max_interval: Duration, +} + +impl Default for CrashSamplerConfig { + fn default() -> Self { + Self { + interval: Duration::from_millis(100), + max_interval: Duration::from_secs(1), + } + } +} + /// Local failure while arming crash capture. #[derive(Debug, thiserror::Error)] pub enum InstallError { @@ -91,6 +113,9 @@ pub enum InstallError { /// The all-thread sampler could not be started. #[error("cannot start crash snapshot sampler: {0}")] Sampler(#[source] io::Error), + /// The sampling intervals must be positive and ordered. + #[error("crash sampler intervals must be positive, with max_interval >= interval")] + InvalidSamplerConfig, /// The platform SIGABRT predecessor chain could not be installed. #[cfg(any(windows, target_os = "macos"))] #[error("cannot chain the platform abort handler: {0}")] @@ -284,9 +309,24 @@ impl Drop for TestSamplerDisabledGuard { /// Arm native crash capture unless policy or environment opts out. pub fn install(policy: CrashPolicy, metadata: CrashMetadata) -> Result { + install_with_sampler_config(policy, metadata, CrashSamplerConfig::default()) +} + +/// Arm native crash capture with a caller-selected sampling cadence. +/// +/// The first live registration sets the process-wide cadence until its final +/// guard is dropped. An opt-out policy does not validate or start a sampler. +pub fn install_with_sampler_config( + policy: CrashPolicy, + metadata: CrashMetadata, + config: CrashSamplerConfig, +) -> Result { if policy == CrashPolicy::Off || env_opted_out() { return Ok(CrashGuard::inert()); } + if config.interval.is_zero() || config.max_interval < config.interval { + return Err(InstallError::InvalidSamplerConfig); + } let pid = std::process::id(); match OWNER_PID.compare_exchange(0, pid, Ordering::AcqRel, Ordering::Acquire) { @@ -326,7 +366,7 @@ pub fn install(policy: CrashPolicy, metadata: CrashMetadata) -> Result Result<(Arc, u64), InstallError> { + fn new(metadata: CrashMetadata, config: CrashSamplerConfig) -> Result<(Arc, u64), InstallError> { let (file, path, template) = spool::create_sink(&metadata).map_err(InstallError::Spool)?; let shared = Arc::new(Shared::new(file, template)); @@ -376,7 +416,7 @@ impl Runtime { let sampler = if sampler_disabled { None } else { - Some(match start_sampler(&shared, SAMPLE_INTERVAL) { + Some(match start_sampler(&shared, config) { Ok(sampler) => sampler, Err(error) => { #[cfg(windows)] @@ -485,14 +525,11 @@ impl Runtime { } } -/// How often the sampler refreshes the bounded pre-crash snapshot. -const SAMPLE_INTERVAL: Duration = Duration::from_millis(50); - -fn start_sampler(shared: &Arc, cadence: Duration) -> io::Result> { +fn start_sampler(shared: &Arc, config: CrashSamplerConfig) -> io::Result> { let sampler_state = Arc::clone(shared); std::thread::Builder::new() .name("rp-crash-sampler".into()) - .spawn(move || sampler_loop(sampler_state, cadence)) + .spawn(move || sampler_loop(sampler_state, config)) } struct RegistrationState { @@ -1155,13 +1192,25 @@ fn with_platform_fields( }) } -fn sampler_loop(shared: Arc, cadence: Duration) { +fn sampler_loop(shared: Arc, config: CrashSamplerConfig) { + let mut resolver = crate::snapshot::SessionResolver::for_crash(&crate::snapshot::SnapshotConfig::default()); + let mut previous = None; + let mut cadence = config.interval; while !shared.stop.load(Ordering::Acquire) { + if shared.wait_for_stop(cadence) { + break; + } if !shared.reading.load(Ordering::Acquire) { - let sample = capture_sample(); + let tick_start = std::time::Instant::now(); + let sample = capture_sample(&mut resolver); // Recheck after the allocating capture: the callback may have // started while capture was in progress. if let Some(sample) = sample { + cadence = if previous.as_ref() == Some(&sample) { + cadence.saturating_mul(2).min(config.max_interval) + } else { + config.interval + }; if !shared.reading.load(Ordering::Acquire) { let _publish = match shared.publish.lock() { Ok(publish) => publish, @@ -1182,13 +1231,13 @@ fn sampler_loop(shared: Arc, cadence: Duration) { .sample_thread_count .store(sample.threads.len(), Ordering::Release); shared.sample_ready.store(true, Ordering::Release); + previous = Some(sample); } } - } - // Teardown signals the wait, so process exit never has to sit out a - // cadence tick it cannot interrupt. - if shared.wait_for_stop(cadence) { - break; + // Preserve a one-percent duty-cycle headroom even when snapshots + // differ on every tick. This bounds sampler CPU without dropping + // threads from the last complete pre-crash capture. + cadence = cadence.max(tick_start.elapsed().saturating_mul(100)); } } } @@ -1197,18 +1246,10 @@ fn sampler_loop(shared: Arc, cadence: Duration) { any(windows, target_os = "linux", target_os = "macos"), any(target_arch = "x86_64", target_arch = "aarch64") ))] -fn capture_sample() -> Option { - use crate::snapshot::attribute::attribute; - use crate::snapshot::modules::enumerate_modules; - use crate::snapshot::{capture_and_resolve, SnapshotConfig}; - - let Ok(snapshot) = capture_and_resolve(&SnapshotConfig::default()) else { +fn capture_sample(resolver: &mut crate::snapshot::SessionResolver) -> Option { + let Ok(attributed) = resolver.capture_attributed() else { return None; }; - let Ok(loaded) = enumerate_modules() else { - return None; - }; - let attributed = attribute(&snapshot, &loaded); Some(CrashSample { modules: attributed .modules @@ -1239,11 +1280,11 @@ fn capture_sample() -> Option { any(windows, target_os = "linux", target_os = "macos"), any(target_arch = "x86_64", target_arch = "aarch64") )))] -fn capture_sample() -> Option { +fn capture_sample(_resolver: &mut crate::snapshot::SessionResolver) -> Option { None } -#[derive(Default)] +#[derive(Default, PartialEq, Eq)] struct CrashSample { modules: Vec, threads: Vec, @@ -1591,7 +1632,10 @@ mod tests { // it out could not pass. shared.reading.store(true, Ordering::Release); let cadence = Duration::from_secs(30); - let sampler = start_sampler(&shared, cadence).unwrap(); + let sampler = start_sampler(&shared, CrashSamplerConfig { + interval: cadence, + max_interval: cadence, + }).unwrap(); // Not an assertion: this only gives the thread time to reach the wait // so an uninterruptible sleep reproduces as a failure rather than a // race against the loop's own stop check. @@ -1611,4 +1655,25 @@ mod tests { drop(shared); let _ = std::fs::remove_file(&path); } + + #[test] + fn sampler_preserves_thread_frames_and_module_indices() { + let _state = process_state(); + let mut resolver = crate::snapshot::SessionResolver::for_crash( + &crate::snapshot::SnapshotConfig::default(), + ); + let Some(sample) = capture_sample(&mut resolver) else { + return; // Unsupported host/architecture has no native sampler. + }; + assert!(!sample.threads.is_empty(), "sampler dropped every thread"); + assert!(sample.threads.iter().all(|thread| !thread.frames.is_empty())); + assert!(sample.modules.iter().all(|module| !module.identity.is_empty())); + for thread in &sample.threads { + for frame in &thread.frames { + assert!(frame.module_index.is_none_or(|index| { + usize::try_from(index).is_ok_and(|index| index < sample.modules.len()) + })); + } + } + } } diff --git a/src/platform_linux/process_usage.rs b/src/platform_linux/process_usage.rs index 93c65224..fc58ebac 100644 --- a/src/platform_linux/process_usage.rs +++ b/src/platform_linux/process_usage.rs @@ -61,3 +61,200 @@ pub fn tree_rss_bytes_for_pid(pid: u32) -> Option { } Some(total) } + +#[cfg(all(test, feature = "crash"))] +mod crash_sampler_budget_tests { + use std::sync::{Arc, Condvar, Mutex}; + use std::time::{Duration, Instant}; + + use crate::crash::{self, CrashMetadata, CrashPolicy}; + + const CHILD_ENV: &str = "KERNAL_API_CRASH_BUDGET_CHILD"; + const SHORT_CHILD_ENV: &str = "KERNAL_API_CRASH_SHORT_CHILD"; + const PARKED_THREADS: usize = 16; + const WINDOW: Duration = Duration::from_secs(3); + + fn sampler_cpu_ns() -> u64 { + let tasks = std::fs::read_dir("/proc/self/task").expect("task directory"); + for task in tasks.flatten() { + let name = std::fs::read_to_string(task.path().join("comm")).unwrap_or_default(); + if name.trim() == "rp-crash-sample" { + let stat = std::fs::read_to_string(task.path().join("schedstat")) + .expect("sampler schedstat"); + return stat + .split_whitespace() + .next() + .expect("CPU nanoseconds") + .parse() + .expect("numeric CPU nanoseconds"); + } + } + panic!("rp-crash-sampler thread not found"); + } + + fn measure_child() { + let parked = Arc::new((Mutex::new(false), Condvar::new())); + let threads: Vec<_> = (0..PARKED_THREADS) + .map(|_| { + let parked = Arc::clone(&parked); + std::thread::spawn(move || { + let (lock, wake) = &*parked; + let stopped = lock.lock().expect("park lock"); + drop(wake.wait_while(stopped, |stopped| !*stopped).expect("park wait")); + }) + }) + .collect(); + let guard = crash::install( + CrashPolicy::On, + CrashMetadata { + app_class: "test".into(), + app_name: "crash-sampler-cpu-budget".into(), + app_version: "0".into(), + instance_name: String::new(), + creation_time_ms: 0, + cwd: String::new(), + }, + ) + .expect("install native capture"); + let deadline = Instant::now() + Duration::from_secs(10); + while !guard.sample_ready() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert!(guard.sample_ready(), "sampler never produced a snapshot"); + assert!( + guard.sample_thread_count() >= PARKED_THREADS, + "pre-crash sample omitted parked threads" + ); + // Let startup work finish before measuring steady-state idle cost. + std::thread::sleep(Duration::from_millis(500)); + let before = sampler_cpu_ns(); + let started = Instant::now(); + std::thread::sleep(WINDOW); + let fraction = sampler_cpu_ns().saturating_sub(before) as f64 + / started.elapsed().as_nanos() as f64; + drop(guard); + let (lock, wake) = &*parked; + *lock.lock().expect("park lock") = true; + wake.notify_all(); + for thread in threads { + thread.join().expect("join parked thread"); + } + assert!( + fraction <= 0.02, + "idle sampler used {:.1}% of one core with {PARKED_THREADS} parked threads (budget 2%)", + fraction * 100.0 + ); + } + + /// #357: the native sampler must stay below two percent with parked threads. + #[test] + fn sampler_idle_cpu_under_two_percent() { + if std::env::var_os(CHILD_ENV).is_some() { + measure_child(); + return; + } + let spool = tempfile::tempdir().expect("private crash spool"); + let status = std::process::Command::new(std::env::current_exe().expect("test binary")) + .arg("sampler_idle_cpu_under_two_percent") + .arg("--nocapture") + .arg("--test-threads=1") + .env(CHILD_ENV, "1") + .env(crash::spool::SPOOL_DIR_ENV, spool.path().join("spool")) + .env_remove(crash::NO_CRASH_HANDLER_ENV) + .status() + .expect("run isolated sampler budget test"); + assert!(status.success(), "isolated sampler budget failed: {status}"); + } + + #[test] + fn short_lived_capture_adds_at_most_ten_milliseconds() { + if std::env::var_os(SHORT_CHILD_ENV).is_some() { + let started = Instant::now(); + let guard = crash::install( + CrashPolicy::On, + CrashMetadata { + app_class: "test".into(), + app_name: "crash-short-lived-budget".into(), + app_version: "0".into(), + instance_name: String::new(), + creation_time_ms: 0, + cwd: String::new(), + }, + ) + .expect("install native capture"); + std::thread::sleep(Duration::from_millis(50)); + drop(guard); + assert!( + started.elapsed() <= Duration::from_millis(60), + "install + 50 ms + teardown exceeded the 10 ms overhead budget: {:?}", + started.elapsed() + ); + return; + } + let spool = tempfile::tempdir().expect("private crash spool"); + let status = std::process::Command::new(std::env::current_exe().expect("test binary")) + .arg("short_lived_capture_adds_at_most_ten_milliseconds") + .arg("--nocapture") + .arg("--test-threads=1") + .env(SHORT_CHILD_ENV, "1") + .env(crash::spool::SPOOL_DIR_ENV, spool.path().join("spool")) + .env_remove(crash::NO_CRASH_HANDLER_ENV) + .status() + .expect("run isolated short-lived budget test"); + assert!(status.success(), "short-lived budget failed: {status}"); + } + + #[test] + fn newly_loaded_module_is_attributed_by_cached_session() { + let fixture = tempfile::tempdir().expect("isolated dlopen fixture"); + let source = fixture.path().join("new-image.c"); + let library = fixture.path().join("libnew-image.so"); + std::fs::write(&source, "void crash_sampler_new_image(const volatile int *stop) { while (!*stop) {} }\n") + .expect("write dlopen fixture"); + let built = std::process::Command::new("cc") + .args(["-shared", "-fPIC", "-Wl,--build-id=sha1", "-o"]) + .arg(&library) + .arg(&source) + .status() + .expect("build dlopen fixture with a GNU build ID"); + assert!(built.success(), "cannot build dlopen fixture: {built}"); + + let mut session = crate::snapshot::SessionResolver::new( + &crate::snapshot::SnapshotConfig::default(), + ); + session.capture().expect("capture before dlopen"); + let library_c = std::ffi::CString::new(library.to_string_lossy().as_bytes()) + .expect("fixture path has no NUL"); + let handle = unsafe { libc::dlopen(library_c.as_ptr(), libc::RTLD_NOW) }; + assert!(!handle.is_null(), "dlopen rejected the fixture"); + let symbol = unsafe { libc::dlsym(handle, c"crash_sampler_new_image".as_ptr()) }; + assert!(!symbol.is_null(), "new module has no test function"); + let function: unsafe extern "C" fn(*const i32) = unsafe { std::mem::transmute(symbol) }; + let stop = std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)); + let worker_stop = stop.clone(); + let worker = std::thread::spawn(move || unsafe { + function(worker_stop.as_ptr()); + }); + std::thread::sleep(Duration::from_millis(10)); + let snapshot = session.capture().expect("capture after dlopen"); + stop.store(1, std::sync::atomic::Ordering::Release); + worker.join().expect("join library worker"); + let module = session + .module_inventory() + .iter() + .find(|module| module.path.as_deref() == library.to_str()) + .expect("cached inventory includes the newly loaded library"); + let expected_id = module.debug_id.clone(); + let attributed = crate::snapshot::attribute::attribute( + &snapshot, + session.module_inventory(), + ); + let index = attributed.threads.iter().flat_map(|thread| &thread.frames) + .filter_map(|frame| frame.module_index) + .find(|&index| attributed.modules[index as usize].path.as_deref() == library.to_str()) + .expect("live frame in newly loaded image is attributed") as usize; + assert_eq!(attributed.modules[index].path.as_deref(), library.to_str()); + assert_eq!(attributed.modules[index].debug_id, expected_id); + unsafe { libc::dlclose(handle) }; + } +} diff --git a/src/snapshot/mod.rs b/src/snapshot/mod.rs index c6537b52..afe2b60e 100644 --- a/src/snapshot/mod.rs +++ b/src/snapshot/mod.rs @@ -247,6 +247,11 @@ pub struct SessionResolver { #[cfg(any(target_os = "linux", target_os = "macos"))] resolver: unwind::FrameResolver, config: SnapshotConfig, + modules: std::sync::Arc>, + // The Unix resolver consumes this; Windows still uses its existing + // one-shot unwind path and never reads the cap. + #[allow(dead_code)] + unwind_limit: Option, } impl SessionResolver { @@ -257,6 +262,17 @@ impl SessionResolver { #[cfg(any(target_os = "linux", target_os = "macos"))] resolver: unwind::FrameResolver::new(), config: *config, + modules: std::sync::Arc::new(Vec::new()), + unwind_limit: None, + } + } + + /// Crash snapshots bound changed-stack unwinds per tick; ordinary + /// snapshot sessions continue resolving every captured thread. + pub(crate) fn for_crash(config: &SnapshotConfig) -> Self { + Self { + unwind_limit: Some(16), + ..Self::new(config) } } @@ -265,15 +281,29 @@ impl SessionResolver { let mut snapshot = capture_all_threads(&self.config)?; #[cfg(windows)] { - let modules = modules::enumerate_modules()?; - unwind::resolve_frames(&mut snapshot, &modules); + self.modules = std::sync::Arc::new(modules::enumerate_modules()?); + unwind::resolve_frames(&mut snapshot, &self.modules); } #[cfg(any(target_os = "linux", target_os = "macos"))] - self.resolver.resolve(&mut snapshot)?; + { + self.resolver.resolve_with_limit(&mut snapshot, self.unwind_limit)?; + self.modules = self.resolver.modules(); + } #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] return Err(SnapshotError::Unsupported); Ok(snapshot) } + + /// Capture, resolve and attribute frames using one module inventory. + pub fn capture_attributed(&mut self) -> Result { + let snapshot = self.capture()?; + Ok(attribute::attribute(&snapshot, &self.modules)) + } + + #[cfg(test)] + pub(crate) fn module_inventory(&self) -> &[modules::LoadedModule] { + &self.modules + } } #[cfg(test)] diff --git a/src/snapshot/unwind.rs b/src/snapshot/unwind.rs index 0eb5faeb..dcf47bdc 100644 --- a/src/snapshot/unwind.rs +++ b/src/snapshot/unwind.rs @@ -558,8 +558,10 @@ pub struct FrameResolver { /// nothing and the first `refresh` builds unconditionally. built: bool, signature: u64, - modules: Vec, + modules: std::sync::Arc>, unwinder: ArchUnwinder>, + cached_threads: std::collections::BTreeMap, + next_unwind_index: usize, /// How many times the inventory has been rebuilt. Exists so a test can /// assert the cache's behaviour directly instead of inferring it from /// timing or from a module count that legitimately excludes images @@ -568,6 +570,46 @@ pub struct FrameResolver { rebuilds: u64, } +// The neutral cache shape is shared source even where a host does not build +// the Unix FrameResolver; selecting its OS in this file would violate the +// platform boundary. +#[allow(dead_code)] +struct CachedThread { + instruction_pointer: u64, + stack_pointer: u64, + frame_pointer: u64, + link_register: Option, + truncated: bool, + stack_bytes: Vec, + frames: Vec, + fresh: bool, +} + +#[allow(dead_code)] +impl CachedThread { + fn matches(&self, sample: &super::ThreadSample) -> bool { + self.instruction_pointer == sample.instruction_pointer + && self.stack_pointer == sample.stack_pointer + && self.frame_pointer == sample.frame_pointer + && self.link_register == sample.link_register + && self.truncated == sample.truncated + && self.stack_bytes == sample.stack_bytes + } + + fn from_sample(sample: &super::ThreadSample) -> Self { + Self { + instruction_pointer: sample.instruction_pointer, + stack_pointer: sample.stack_pointer, + frame_pointer: sample.frame_pointer, + link_register: sample.link_register, + truncated: sample.truncated, + stack_bytes: sample.stack_bytes.clone(), + frames: sample.frames.clone(), + fresh: true, + } + } +} + #[cfg(any(target_os = "linux", target_os = "macos"))] impl Default for FrameResolver { fn default() -> Self { @@ -588,8 +630,10 @@ impl FrameResolver { Self { built: false, signature: 0, - modules: Vec::new(), + modules: std::sync::Arc::new(Vec::new()), unwinder: ArchUnwinder::new(), + cached_threads: std::collections::BTreeMap::new(), + next_unwind_index: 0, #[cfg(test)] rebuilds: 0, } @@ -602,8 +646,10 @@ impl FrameResolver { return Ok(()); } self.built = true; - self.modules = super::modules::enumerate_modules()?; + self.modules = std::sync::Arc::new(super::modules::enumerate_modules()?); self.unwinder = build_unix_unwinder(&self.modules); + self.cached_threads.clear(); + self.next_unwind_index = 0; self.signature = signature; #[cfg(test)] { @@ -614,11 +660,44 @@ impl FrameResolver { /// Resolve every raw sample in `snapshot` against the current images. pub fn resolve(&mut self, snapshot: &mut Snapshot) -> std::io::Result<()> { + self.resolve_with_limit(snapshot, None) + } + + /// Bound changed-stack unwinds only for crash sampling. Unselected + /// changed threads outside the window report only their live IP. + pub(crate) fn resolve_with_limit( + &mut self, + snapshot: &mut Snapshot, + max_unwinds_per_tick: Option, + ) -> std::io::Result<()> { self.refresh()?; let mut cache = ArchCache::new(); - for sample in &mut snapshot.threads { - sample.frames = unwind_sample(&self.unwinder, &mut cache, sample, &self.modules); + let mut next = std::collections::BTreeMap::new(); + // The rotating window prevents busy low-TID threads from starving + // newer threads, while an uncached thread still contributes its IP. + let thread_count = snapshot.threads.len(); + let start = self.next_unwind_index % thread_count.max(1); + for (index, sample) in snapshot.threads.iter_mut().enumerate() { + let previous = self.cached_threads.get(&sample.os_tid); + let unchanged = previous.is_some_and(|cached| cached.fresh && cached.matches(sample)); + let in_window = max_unwinds_per_tick.is_none_or(|limit| { + (index + thread_count - start) % thread_count.max(1) < limit + }); + let fresh = unchanged || in_window; + sample.frames = if unchanged { + previous.expect("unchanged implies cached").frames.clone() + } else if in_window { + unwind_sample(&self.unwinder, &mut cache, sample, &self.modules) + } else { + vec![sample.instruction_pointer] + }; + let mut cached = CachedThread::from_sample(sample); + cached.fresh = fresh; + next.insert(sample.os_tid, cached); } + self.next_unwind_index = (start + max_unwinds_per_tick.unwrap_or(thread_count)) + % thread_count.max(1); + self.cached_threads = next; snapshot.frames_resolved = true; Ok(()) } @@ -636,6 +715,11 @@ impl FrameResolver { pub fn module_count(&self) -> usize { self.modules.len() } + + /// Reuse this resolver's inventory for frame attribution after capture. + pub(crate) fn modules(&self) -> std::sync::Arc> { + std::sync::Arc::clone(&self.modules) + } } #[cfg(all(test, windows))] @@ -743,7 +827,7 @@ mod tests { #[cfg(all(test, any(target_os = "linux", target_os = "macos")))] mod resolver_tests { use super::*; - use crate::snapshot::{capture_all_threads, SnapshotConfig}; + use crate::snapshot::{capture_all_threads, CaptureKind, SnapshotConfig}; fn capture() -> Snapshot { capture_all_threads(&SnapshotConfig::default()).expect("capture") @@ -794,6 +878,35 @@ mod resolver_tests { } } + #[test] + fn changed_thread_outside_unwind_window_reports_current_ip() { + let _serial = serial(); + let mut resolver = FrameResolver::new(); + let mut snapshot = Snapshot::default(); + for tid in 1..=17 { + snapshot.threads.push(ThreadSample { + os_tid: tid, + stack_pointer: 0, + instruction_pointer: 0x1000, + frame_pointer: 0, + link_register: None, + stack_bytes: Vec::new(), + truncated: false, + kind: CaptureKind::RawContext, + frames: Vec::new(), + }); + } + let mut stale = snapshot.threads[16].clone(); + stale.instruction_pointer = 0x2000; + stale.frames = vec![0x2000, 0x3000]; + resolver + .cached_threads + .insert(stale.os_tid, CachedThread::from_sample(&stale)); + resolver.resolve_with_limit(&mut snapshot, Some(16)).expect("resolve"); + assert_eq!(snapshot.threads[16].frames, vec![0x1000]); + assert!(!resolver.cached_threads[&17].fresh); + } + /// Once built, an unchanged image set is reused rather than rebuilt. #[test] fn reuses_the_inventory_when_no_module_changes() { diff --git a/tests/diagnostics/crash_facade.rs b/tests/diagnostics/crash_facade.rs index 1ae1ce41..4127e20c 100644 --- a/tests/diagnostics/crash_facade.rs +++ b/tests/diagnostics/crash_facade.rs @@ -9,7 +9,8 @@ #![cfg(feature = "crash")] -use kernal_api::crash::{CrashMetadata, CrashPolicy}; +use kernal_api::crash::{CrashMetadata, CrashPolicy, CrashSamplerConfig, InstallError}; +use std::time::Duration; /// `install`'s argument type is nameable from `install`'s own module. #[test] @@ -33,4 +34,29 @@ fn a_client_can_name_the_metadata_install_asks_for() { // Named here so the policy a client passes alongside it stays reachable // from the same place. assert_eq!(CrashPolicy::default(), CrashPolicy::On); + + let defaults = CrashSamplerConfig::default(); + assert_eq!(defaults.interval, Duration::from_millis(100)); + assert_eq!(defaults.max_interval, Duration::from_secs(1)); + let inert = kernal_api::crash::install_with_sampler_config( + CrashPolicy::Off, + metadata.clone(), + CrashSamplerConfig { + interval: Duration::from_millis(250), + max_interval: Duration::from_secs(2), + }, + ) + .expect("a client can select the cadence"); + assert!(!inert.is_armed()); + assert!(matches!( + kernal_api::crash::install_with_sampler_config( + CrashPolicy::On, + metadata, + CrashSamplerConfig { + interval: Duration::ZERO, + max_interval: Duration::from_secs(1), + }, + ), + Err(InstallError::InvalidSamplerConfig) + )); } From 2534109822708ea706540bdc6ef8d91c678b75e7 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 26 Sep 2026 17:21:49 -0700 Subject: [PATCH 2/5] Align nested consumers with kernal-api 0.1.23 --- benchmarks/wasm-sketch/compiler-guest/Cargo.lock | 2 +- benchmarks/wasm-sketch/compiler-guest/Cargo.toml | 2 +- benchmarks/wasm-sketch/component-guest/Cargo.lock | 2 +- benchmarks/wasm-sketch/component-guest/Cargo.toml | 2 +- benchmarks/wasm-sketch/component-tools/Cargo.lock | 2 +- benchmarks/wasm-sketch/component-tools/Cargo.toml | 2 +- examples/wasm-tauri-screenshot/guest/Cargo.lock | 2 +- examples/wasm-tauri-screenshot/guest/Cargo.toml | 2 +- guests/threaded-smoke/Cargo.lock | 2 +- guests/threaded-smoke/Cargo.toml | 2 +- tests/build-resources-consumer/Cargo.lock | 2 +- tests/daemon-registration-consumer/Cargo.lock | 2 +- tests/daemon-registration-v2-consumer/Cargo.lock | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/benchmarks/wasm-sketch/compiler-guest/Cargo.lock b/benchmarks/wasm-sketch/compiler-guest/Cargo.lock index 4be08fba..5430f2a2 100644 --- a/benchmarks/wasm-sketch/compiler-guest/Cargo.lock +++ b/benchmarks/wasm-sketch/compiler-guest/Cargo.lock @@ -148,7 +148,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "kernal-api" -version = "0.1.22" +version = "0.1.23" dependencies = [ "blake3", "libc", diff --git a/benchmarks/wasm-sketch/compiler-guest/Cargo.toml b/benchmarks/wasm-sketch/compiler-guest/Cargo.toml index a6d9956b..280a92ed 100644 --- a/benchmarks/wasm-sketch/compiler-guest/Cargo.toml +++ b/benchmarks/wasm-sketch/compiler-guest/Cargo.toml @@ -29,7 +29,7 @@ required-features = ["guest-proof"] # Independently runnable Core compiler/cache and public-facade hash proofs for #13. zccache-compiler = { git = "https://github.com/zackees/zccache", rev = "c6ddfa974a4920a127eac81773db6a5c56cd30a7", default-features = false } zccache-hash = { git = "https://github.com/zackees/zccache", rev = "2543136ea8b648b295d2f7115a19656ff0854531", default-features = false } -kernal-api = { version = "=0.1.22", path = "../../..", default-features = false } +kernal-api = { version = "=0.1.23", path = "../../..", default-features = false } base64 = "=0.22.1" serde = { version = "=1.0.229", features = ["derive"] } serde_json = "=1.0.151" diff --git a/benchmarks/wasm-sketch/component-guest/Cargo.lock b/benchmarks/wasm-sketch/component-guest/Cargo.lock index d8090ae6..fdd083f3 100644 --- a/benchmarks/wasm-sketch/component-guest/Cargo.lock +++ b/benchmarks/wasm-sketch/component-guest/Cargo.lock @@ -567,7 +567,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "kernal-api" -version = "0.1.22" +version = "0.1.23" dependencies = [ "blake3", "libc", diff --git a/benchmarks/wasm-sketch/component-guest/Cargo.toml b/benchmarks/wasm-sketch/component-guest/Cargo.toml index 12893701..d6aa1822 100644 --- a/benchmarks/wasm-sketch/component-guest/Cargo.toml +++ b/benchmarks/wasm-sketch/component-guest/Cargo.toml @@ -25,7 +25,7 @@ compiler-artifact-output = [] zccache-compiler = { git = "https://github.com/zackees/zccache", rev = "c6ddfa974a4920a127eac81773db6a5c56cd30a7", default-features = false } # Exact source-only migration fixture; not published-pin acceptance. zccache-hash = { git = "https://github.com/zackees/zccache", rev = "2543136ea8b648b295d2f7115a19656ff0854531", default-features = false } -kernal-api = { version = "=0.1.22", path = "../../..", default-features = false, features = ["wasm-component-hash-experiment"] } +kernal-api = { version = "=0.1.23", path = "../../..", default-features = false, features = ["wasm-component-hash-experiment"] } wit-bindgen = { version = "=0.58.0", default-features = false, features = ["macros", "realloc", "async", "std"] } [profile.release] diff --git a/benchmarks/wasm-sketch/component-tools/Cargo.lock b/benchmarks/wasm-sketch/component-tools/Cargo.lock index 946b7a91..affd8eb5 100644 --- a/benchmarks/wasm-sketch/component-tools/Cargo.lock +++ b/benchmarks/wasm-sketch/component-tools/Cargo.lock @@ -567,7 +567,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "kernal-api" -version = "0.1.22" +version = "0.1.23" dependencies = [ "blake3", "libc", diff --git a/benchmarks/wasm-sketch/component-tools/Cargo.toml b/benchmarks/wasm-sketch/component-tools/Cargo.toml index 097a9c60..ae5ea3de 100644 --- a/benchmarks/wasm-sketch/component-tools/Cargo.toml +++ b/benchmarks/wasm-sketch/component-tools/Cargo.toml @@ -20,7 +20,7 @@ execution-probe = ["engine-probe", "dep:kernal-api"] anyhow = "=1.0.104" wit-component = "=0.251.0" wasmparser = "=0.251.0" -kernal-api = { version = "=0.1.22", path = "../../..", default-features = false, optional = true } +kernal-api = { version = "=0.1.23", path = "../../..", default-features = false, optional = true } wasmtime = { version = "=45.0.0", optional = true, default-features = false, features = ["cranelift", "runtime", "component-model", "component-model-async"] } [dev-dependencies] diff --git a/examples/wasm-tauri-screenshot/guest/Cargo.lock b/examples/wasm-tauri-screenshot/guest/Cargo.lock index e4d8d01d..4d31380d 100644 --- a/examples/wasm-tauri-screenshot/guest/Cargo.lock +++ b/examples/wasm-tauri-screenshot/guest/Cargo.lock @@ -171,7 +171,7 @@ dependencies = [ [[package]] name = "kernal-api" -version = "0.1.22" +version = "0.1.23" dependencies = [ "blake3", "libc", diff --git a/examples/wasm-tauri-screenshot/guest/Cargo.toml b/examples/wasm-tauri-screenshot/guest/Cargo.toml index 7cffcfb8..d56a369b 100644 --- a/examples/wasm-tauri-screenshot/guest/Cargo.toml +++ b/examples/wasm-tauri-screenshot/guest/Cargo.toml @@ -19,6 +19,6 @@ proof-block-after-capture = [] [dependencies] # Migration-only source fixture. Replace the path with the exact release pin # after the guest-capable facade is published; this is not release acceptance. -kernal-api = { version = "=0.1.22", path = "../../..", default-features = false } +kernal-api = { version = "=0.1.23", path = "../../..", default-features = false } dashmap = "6.1.0" crossbeam-channel = "0.5.15" diff --git a/guests/threaded-smoke/Cargo.lock b/guests/threaded-smoke/Cargo.lock index db2bbfad..fea42a56 100644 --- a/guests/threaded-smoke/Cargo.lock +++ b/guests/threaded-smoke/Cargo.lock @@ -162,7 +162,7 @@ dependencies = [ [[package]] name = "kernal-api" -version = "0.1.22" +version = "0.1.23" dependencies = [ "blake3", "libc", diff --git a/guests/threaded-smoke/Cargo.toml b/guests/threaded-smoke/Cargo.toml index 2304112a..2ab82820 100644 --- a/guests/threaded-smoke/Cargo.toml +++ b/guests/threaded-smoke/Cargo.toml @@ -17,6 +17,6 @@ warnings = "deny" [dependencies] # Migration-only source pin for the public streaming proof. The generated # dependency below remains solely for deliberate low-level ABI probes. -kernal-api = { version = "=0.1.22", path = "../..", default-features = false } +kernal-api = { version = "=0.1.23", path = "../..", default-features = false } kernal-api-v1-bindings = { path = "../../src/wasm/generated/v1/guest" } dashmap = "=6.1.0" diff --git a/tests/build-resources-consumer/Cargo.lock b/tests/build-resources-consumer/Cargo.lock index b262d567..7befcd12 100644 --- a/tests/build-resources-consumer/Cargo.lock +++ b/tests/build-resources-consumer/Cargo.lock @@ -206,7 +206,7 @@ dependencies = [ [[package]] name = "kernal-api" -version = "0.1.22" +version = "0.1.23" dependencies = [ "blake3", "embed-resource", diff --git a/tests/daemon-registration-consumer/Cargo.lock b/tests/daemon-registration-consumer/Cargo.lock index 54da6411..140a156a 100644 --- a/tests/daemon-registration-consumer/Cargo.lock +++ b/tests/daemon-registration-consumer/Cargo.lock @@ -291,7 +291,7 @@ dependencies = [ [[package]] name = "kernal-api" -version = "0.1.22" +version = "0.1.23" dependencies = [ "blake3", "libc", diff --git a/tests/daemon-registration-v2-consumer/Cargo.lock b/tests/daemon-registration-v2-consumer/Cargo.lock index 1be32401..7b3ddc11 100644 --- a/tests/daemon-registration-v2-consumer/Cargo.lock +++ b/tests/daemon-registration-v2-consumer/Cargo.lock @@ -243,7 +243,7 @@ dependencies = [ [[package]] name = "kernal-api" -version = "0.1.22" +version = "0.1.23" dependencies = [ "blake3", "libc", From 6ef6182430e55290d95680bf85fd03a7bb4f6ae7 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 26 Sep 2026 17:36:49 -0700 Subject: [PATCH 3/5] Gate crash-only resolver constructor on crash feature --- src/snapshot/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/snapshot/mod.rs b/src/snapshot/mod.rs index afe2b60e..69e8c19d 100644 --- a/src/snapshot/mod.rs +++ b/src/snapshot/mod.rs @@ -269,6 +269,7 @@ impl SessionResolver { /// Crash snapshots bound changed-stack unwinds per tick; ordinary /// snapshot sessions continue resolving every captured thread. + #[cfg(feature = "crash")] pub(crate) fn for_crash(config: &SnapshotConfig) -> Self { Self { unwind_limit: Some(16), From cd912397163e2d5f6fd82049d658ab06cfe292d4 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 26 Sep 2026 18:16:10 -0700 Subject: [PATCH 4/5] Align Python companion and docs with 0.1.23 release --- COMPATIBILITY.md | 4 ++-- README.md | 4 ++-- examples/wasm-tauri-screenshot/README.md | 2 +- pyproject.toml | 2 +- python/kernal_api/__init__.py | 2 +- python/tests/test_compatibility.py | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index e84ff617..3506a49f 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -98,7 +98,7 @@ descendant teardown, or fresh evidence on the other five targets. Until 1.0, the four first-party clients use an exact Cargo requirement: ```toml -kernal-api = { version = "=0.1.22", features = ["..."] } +kernal-api = { version = "=0.1.23", features = ["..."] } [profile.dev.package.kernal-api] codegen-units = 1 @@ -107,7 +107,7 @@ codegen-units = 1 codegen-units = 1 ``` -The Python companion is likewise pinned with `kernal-api==0.1.22` when used by +The Python companion is likewise pinned with `kernal-api==0.1.23` when used by first-party Python tooling. A source checkout may temporarily use a path patch only on an explicit migration branch; release branches must resolve the exact registry version. There is no `optional = true` legacy implementation behind diff --git a/README.md b/README.md index b5461de3..69434fc5 100644 --- a/README.md +++ b/README.md @@ -124,10 +124,10 @@ adds `kernal-api` a second time, as a build-dependency with only this feature: ```toml [dependencies] -kernal-api = { version = "=0.1.22", features = ["window-icon"] } +kernal-api = { version = "=0.1.23", features = ["window-icon"] } [build-dependencies] -kernal-api = { version = "=0.1.22", default-features = false, features = ["build-resources"] } +kernal-api = { version = "=0.1.23", default-features = false, features = ["build-resources"] } ``` Features enabled in the `[dependencies]` entry never reach the build script: diff --git a/examples/wasm-tauri-screenshot/README.md b/examples/wasm-tauri-screenshot/README.md index f87c719f..bb738ec6 100644 --- a/examples/wasm-tauri-screenshot/README.md +++ b/examples/wasm-tauri-screenshot/README.md @@ -20,7 +20,7 @@ the screenshot lifecycle starts. The map uses a deterministic standard hasher and the channel reads are non-blocking after join: the closed profile therefore adds neither ambient `random_get` nor `poll_oneoff` imports. -The source fixture uses an exact `=0.1.22` version plus a **migration-only local +The source fixture uses an exact `=0.1.23` version plus a **migration-only local path**; it must switch to an actually published guest-capable release before release acceptance. The packaged facade has separately passed a Wasm check, but this is not evidence that the guest-capable package has been published. diff --git a/pyproject.toml b/pyproject.toml index 626d311e..5987fcb0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "kernal-api" -version = "0.1.22" +version = "0.1.23" description = "Async OS HAL, profiling, symbolization, and allocator instrumentation" readme = "README.md" requires-python = ">=3.10" diff --git a/python/kernal_api/__init__.py b/python/kernal_api/__init__.py index 512748dc..65aa23f0 100644 --- a/python/kernal_api/__init__.py +++ b/python/kernal_api/__init__.py @@ -11,7 +11,7 @@ import sys from dataclasses import dataclass -__version__ = "0.1.22" +__version__ = "0.1.23" RUST_MSRV = "1.95.0" SUPPORTED_SYSTEMS = frozenset({"Linux", "Darwin", "Windows"}) SUPPORTED_MACHINES = frozenset({"x86_64", "amd64", "aarch64", "arm64"}) diff --git a/python/tests/test_compatibility.py b/python/tests/test_compatibility.py index 4fb39296..046cc043 100644 --- a/python/tests/test_compatibility.py +++ b/python/tests/test_compatibility.py @@ -4,7 +4,7 @@ def test_python_and_rust_versions_are_explicit() -> None: - assert kernal_api.__version__ == "0.1.22" + assert kernal_api.__version__ == "0.1.23" assert kernal_api.RUST_MSRV == "1.95.0" From b9cdbb25726556a8504e879cb725b6e1d1ab68bc Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 26 Sep 2026 18:58:14 -0700 Subject: [PATCH 5/5] Allow Linux-only test accessor on other targets --- src/snapshot/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/snapshot/mod.rs b/src/snapshot/mod.rs index 69e8c19d..a71368c1 100644 --- a/src/snapshot/mod.rs +++ b/src/snapshot/mod.rs @@ -302,6 +302,7 @@ impl SessionResolver { } #[cfg(test)] + #[allow(dead_code)] // The inventory attribution test runs only on Linux. pub(crate) fn module_inventory(&self) -> &[modules::LoadedModule] { &self.modules }