From c70d8f46a98b6e9f2f60a5bb575fab30422e4eac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 6 Nov 2026 18:20:47 +0000 Subject: [PATCH 1/9] chore: trace snapshot inputs in instrumented builds --- crates/perry-runtime/src/calendar.rs | 34 ++ crates/perry-runtime/src/clock.rs | 22 ++ .../perry-runtime/src/error_stack_frames.rs | 62 ++++ crates/perry-runtime/src/i18n.rs | 59 ++++ crates/perry-runtime/src/math.rs | 9 + crates/perry-runtime/src/navigator.rs | 14 + crates/perry-runtime/src/os.rs | 194 +++++++++++ crates/perry-runtime/src/perf_clock.rs | 19 ++ .../perry-runtime/src/process/credentials.rs | 60 ++++ crates/perry-runtime/src/process/env_misc.rs | 4 + crates/perry-runtime/src/snap_census.rs | 37 +++ .../src/snap_census/input_record.rs | 302 ++++++++++++++++++ .../perry-runtime/src/snap_census/runner.rs | 260 ++++++++++++++- crates/perry-runtime/src/tty.rs | 68 ++++ crates/perry-stdlib/build.rs | 9 + crates/perry-stdlib/src/crypto/random.rs | 138 ++++++++ scripts/snap_inputs/trace.py | 209 ++++++++++++ 17 files changed, 1494 insertions(+), 6 deletions(-) create mode 100644 crates/perry-runtime/src/snap_census/input_record.rs create mode 100644 crates/perry-stdlib/build.rs create mode 100644 scripts/snap_inputs/trace.py diff --git a/crates/perry-runtime/src/calendar.rs b/crates/perry-runtime/src/calendar.rs index 6951a5616df..0ed1c01d77b 100644 --- a/crates/perry-runtime/src/calendar.rs +++ b/crates/perry-runtime/src/calendar.rs @@ -76,6 +76,40 @@ pub(crate) fn timestamp_to_local_components(secs: i64) -> (i32, u32, u32, u32, u /// via the `/etc/localtime` symlink; falls back to `"UTC"`. Cached for the /// process lifetime (the host zone doesn't change under us). pub fn host_time_zone_name() -> &'static str { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("locale.time_zone", &format!("{:?}", ()), || { + use std::sync::OnceLock; + static TZ: OnceLock = OnceLock::new(); + crate::once_init::get_or_init(&TZ, || { + if let Some(raw) = std::env::var_os("TZ") { + if let Some(s) = raw.to_str() { + // A `TZ` of `:Europe/Berlin` / `:/path/zoneinfo/Europe/Berlin` + // (leading colon) or a bare IANA id. Empty `TZ` means UTC. + let s = s.trim().strip_prefix(':').unwrap_or_else(|| s.trim()); + if s.is_empty() { + return "UTC".to_string(); + } + if let Some(name) = iana_id_from_zoneinfo_path(s) { + return name; + } + return s.to_string(); + } + } + #[cfg(unix)] + { + if let Ok(target) = std::fs::read_link("/etc/localtime") { + if let Some(name) = iana_id_from_zoneinfo_path(&target.to_string_lossy()) { + return name; + } + } + } + "UTC".to_string() + }) + .as_str() + }); + } + use std::sync::OnceLock; static TZ: OnceLock = OnceLock::new(); crate::once_init::get_or_init(&TZ, || { diff --git a/crates/perry-runtime/src/clock.rs b/crates/perry-runtime/src/clock.rs index 998c76e828d..3090a2f8b0f 100644 --- a/crates/perry-runtime/src/clock.rs +++ b/crates/perry-runtime/src/clock.rs @@ -1,6 +1,20 @@ //! Numeric wall and performance clocks. use std::time::{SystemTime, UNIX_EPOCH}; pub(crate) fn wall_millis() -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("clock.wall_millis", &format!("{:?}", ()), || { + crate::effect!(host_read, clock); + if let Some(now) = crate::timer::js_mock_timers_date_now() { + return now; + } + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as f64) + .unwrap_or(0.0) + }); + } + crate::effect!(host_read, clock); if let Some(now) = crate::timer::js_mock_timers_date_now() { return now; @@ -15,6 +29,14 @@ pub(crate) fn wall_millis() -> f64 { /// Returns ms since performance.timeOrigin as f64. #[no_mangle] pub extern "C" fn js_performance_now() -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("clock.performance", &format!("{:?}", ()), || { + crate::effect!(host_read, clock); + crate::perf_clock::performance_now_ms() + }); + } + crate::effect!(host_read, clock); crate::perf_clock::performance_now_ms() } diff --git a/crates/perry-runtime/src/error_stack_frames.rs b/crates/perry-runtime/src/error_stack_frames.rs index aecf3bd412a..44d4aa1416d 100644 --- a/crates/perry-runtime/src/error_stack_frames.rs +++ b/crates/perry-runtime/src/error_stack_frames.rs @@ -448,6 +448,28 @@ fn capture_encoded( origin: walk::Origin, want: usize, ) -> ([u8; MAX_CAPTURED_FRAMES * PC_CHARS], usize) { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("stack.capture", &format!("{:?}", (want,)), || { + let mut pcs = [0usize; MAX_CAPTURED_FRAMES]; + let n = walk::capture_js(origin, &mut pcs, want); + if diag_enabled() { + eprintln!( + "[stackdiag] capture: frames={n} want={want} top={:#x} fp0={:#x} pcs={:x?}", + walk::diag_stack_top(), + walk::diag_frame_pointer(), + &pcs[..n] + ); + } + let mut blob = [0u8; MAX_CAPTURED_FRAMES * PC_CHARS]; + if n == 0 { + return (blob, 0); + } + let len = encode_pcs(&pcs[..n], &mut blob); + (blob, len) + }); + } + let mut pcs = [0usize; MAX_CAPTURED_FRAMES]; let n = walk::capture_js(origin, &mut pcs, want); if diag_enabled() { @@ -901,6 +923,14 @@ fn stack_trace_limit() -> usize { /// it (see [`walk::origin`]). #[inline(always)] pub(crate) fn capture_frames_payload() -> Vec { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("stack.payload", &format!("{:?}", ()), || { + let origin = walk::origin(); + capture_frames_payload_from(origin) + }); + } + let origin = walk::origin(); capture_frames_payload_from(origin) } @@ -946,6 +976,38 @@ pub(crate) fn join_stack(head: &str, lines: &str) -> String { /// ran under (an [`ALPHABET`] index); the frame lines stop there. An empty /// payload is a capture that found nothing. pub(crate) fn frames_payload_to_lines(payload: &[u8]) -> String { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call( + "stack.rendered", + &format!("{:?}", (payload,)), + || { + let (limit, payload) = match payload.split_first() { + Some((c, rest)) => match decode_char(*c) { + Some(limit) => (limit as usize, rest), + None => (RENDER_LIMIT, payload), + }, + None => (RENDER_LIMIT, payload), + }; + if limit == 0 { + return String::new(); + } + let (blob, recorded) = match payload.iter().position(|b| *b == b'\n') { + Some(at) => (&payload[..at], std::str::from_utf8(&payload[at + 1..]).ok()), + None => (payload, None), + }; + // The recorded #5247 line is the innermost frame and counts toward it. + let resolved = render_frames(blob, limit - usize::from(recorded.is_some())); + match (recorded, resolved) { + (Some(line), Some(frames)) => format!("{line}\n{frames}"), + (Some(line), None) => line.to_string(), + (None, Some(frames)) => frames, + (None, None) => " at ".to_string(), + } + }, + ); + } + let (limit, payload) = match payload.split_first() { Some((c, rest)) => match decode_char(*c) { Some(limit) => (limit as usize, rest), diff --git a/crates/perry-runtime/src/i18n.rs b/crates/perry-runtime/src/i18n.rs index 0cce7d6e717..c261d0fba28 100644 --- a/crates/perry-runtime/src/i18n.rs +++ b/crates/perry-runtime/src/i18n.rs @@ -175,6 +175,65 @@ pub extern "C" fn perry_i18n_init( /// Detect the system locale from environment or platform APIs. fn detect_system_locale() -> Option { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("locale.system", &format!("{:?}", ()), || { + crate::effect!(host_read, locale); + // 1. Explicit POSIX env overrides first: LANGUAGE (GNU extension, + // colon-separated list), then LC_ALL > LC_MESSAGES > LANG. A user who + // runs `LANG=de_DE.UTF-8 ./app` has stated a preference; platform + // APIs like CFLocaleCopyCurrent would otherwise mask it on macOS, + // which pinned every console binary to the Mac's system language no + // matter what the environment said. GUI launches (Finder, + // SpringBoard, …) don't set these vars, so apps still fall through + // to the platform-native detection below. + if let Ok(lang) = std::env::var("LANGUAGE") { + if let Some(first) = lang.split(':').next() { + if !first.is_empty() && first != "C" && first != "POSIX" { + return Some(first.to_string()); + } + } + } + for var in &["LC_ALL", "LC_MESSAGES", "LANG"] { + if let Ok(val) = std::env::var(var) { + if !val.is_empty() && val != "C" && val != "POSIX" { + return Some(val); + } + } + } + + // 2. Platform-native APIs (GUI apps and env-less environments). + #[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "watchos", + target_os = "tvos", + target_os = "visionos" + ))] + { + if let Some(locale) = detect_apple_locale() { + return Some(locale); + } + } + + #[cfg(target_os = "windows")] + { + if let Some(locale) = detect_windows_locale() { + return Some(locale); + } + } + + #[cfg(target_os = "android")] + { + if let Some(locale) = detect_android_locale() { + return Some(locale); + } + } + + None + }); + } + crate::effect!(host_read, locale); // 1. Explicit POSIX env overrides first: LANGUAGE (GNU extension, // colon-separated list), then LC_ALL > LC_MESSAGES > LANG. A user who diff --git a/crates/perry-runtime/src/math.rs b/crates/perry-runtime/src/math.rs index f1d696cb187..d687cf51da2 100644 --- a/crates/perry-runtime/src/math.rs +++ b/crates/perry-runtime/src/math.rs @@ -285,6 +285,15 @@ pub extern "C" fn js_math_hypot(a: f64, b: f64) -> f64 { /// Math.random() -> number (0 <= x < 1) #[no_mangle] pub extern "C" fn js_math_random() -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("random.Math", &format!("{:?}", ()), || { + crate::effect!(host_read, random); + let mut rng = rand::rng(); + rng.random::() + }); + } + crate::effect!(host_read, random); let mut rng = rand::rng(); rng.random::() diff --git a/crates/perry-runtime/src/navigator.rs b/crates/perry-runtime/src/navigator.rs index 38fbfb51170..c82f470f7a2 100644 --- a/crates/perry-runtime/src/navigator.rs +++ b/crates/perry-runtime/src/navigator.rs @@ -81,6 +81,20 @@ fn normalize_navigator_locale(locale: &str) -> Option { } fn navigator_language() -> String { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("locale.navigator", &format!("{:?}", ()), || { + crate::effect!(host_read, locale); + std::env::var("LC_ALL") + .ok() + .filter(|locale| !locale.is_empty() && locale != "C" && locale != "POSIX") + .or_else(|| std::env::var("LANG").ok()) + .as_deref() + .and_then(normalize_navigator_locale) + .unwrap_or_else(|| "en-US".to_string()) + }); + } + crate::effect!(host_read, locale); std::env::var("LC_ALL") .ok() diff --git a/crates/perry-runtime/src/os.rs b/crates/perry-runtime/src/os.rs index 4fc6fd2a165..4a8acd7657e 100644 --- a/crates/perry-runtime/src/os.rs +++ b/crates/perry-runtime/src/os.rs @@ -555,6 +555,18 @@ pub extern "C" fn js_process_uptime() -> f64 { /// Get the current working directory #[no_mangle] pub extern "C" fn js_process_cwd() -> *mut StringHeader { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("cwd", &format!("{:?}", ()), || { + crate::effect!(host_read, cwd); + let cwd = std::env::current_dir() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| String::new()); + let bytes = cwd.as_bytes(); + js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32) + }); + } + crate::effect!(host_read, cwd); let cwd = std::env::current_dir() .map(|p| p.to_string_lossy().into_owned()) @@ -580,6 +592,21 @@ pub unsafe extern "C" fn js_set_process_entry_path(ptr: *const u8, len: u32) { #[no_mangle] pub extern "C" fn js_process_argv_value() -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("argv.value", &format!("{:?}", ()), || { + crate::effect!(host_read, argv); + if crate::process::worker_restrictions::in_node_worker() { + if let Some(value) = + crate::object::native_namespace_prop_override_get("process", "argv") + { + return value; + } + } + crate::value::js_nanbox_pointer(js_process_argv() as i64) + }); + } + crate::effect!(host_read, argv); if crate::process::worker_restrictions::in_node_worker() { if let Some(value) = crate::object::native_namespace_prop_override_get("process", "argv") { @@ -600,6 +627,53 @@ pub(crate) fn allocate_process_argv(capacity: u32) -> *mut ArrayHeader { /// Returns: string[] (array of NaN-boxed string pointers) #[no_mangle] pub extern "C" fn js_process_argv() -> *mut ArrayHeader { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("argv", &format!("{:?}", ()), || { + crate::effect!(host_read, argv); + use crate::array::js_array_push_f64; + use crate::value::js_nanbox_string; + + // #9401: never `std::env::args()` — it panics on a non-UTF-8 argument. + let args: Vec = crate::process::process_args_lossy().collect(); + // Match Node.js behavior: argv[0] = binary path (like node path), + // argv[1] = source entry path (like script path), argv[2+] = user args. + // Node.js: ["/usr/bin/node", "/path/to/script.js", ...user_args] + // Compiled: ["/path/to/binary", "/path/to/entry.ts", ...user_args] + // If an older/foreign codegen does not seed the entry path, retain the + // historical binary-path fallback while keeping user args at index 2+. + let arr = allocate_process_argv((args.len() + 1) as u32); + + let mut result = arr; + if let Some(binary_path) = args.first() { + // Node exposes execPath here; argv0 separately retains the launch name. + let exec_path = crate::process::process_exec_path_string(); + let bytes = exec_path.as_bytes(); + let str_ptr = js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32); + let nanboxed = js_nanbox_string(str_ptr as i64); + result = js_array_push_f64(result, nanboxed); + // argv[1]: compiler-seeded source entry path. + let entry_path = PROCESS_ENTRY_PATH + .get() + .map(String::as_str) + .unwrap_or(binary_path); + let entry_bytes = entry_path.as_bytes(); + let str_ptr2 = js_string_from_bytes(entry_bytes.as_ptr(), entry_bytes.len() as u32); + let nanboxed2 = js_nanbox_string(str_ptr2 as i64); + result = js_array_push_f64(result, nanboxed2); + } + // argv[2+]: user arguments + for arg in args.iter().skip(1) { + let bytes = arg.as_bytes(); + let str_ptr = js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32); + let nanboxed = js_nanbox_string(str_ptr as i64); + result = js_array_push_f64(result, nanboxed); + } + + result + }); + } + crate::effect!(host_read, argv); use crate::array::js_array_push_f64; use crate::value::js_nanbox_string; @@ -659,6 +733,27 @@ pub(crate) fn current_process_id() -> u32 { /// Get the current process ID (process.pid) #[no_mangle] pub extern "C" fn js_process_pid() -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("ids.pid", &format!("{:?}", ()), || { + #[cfg(unix)] + unsafe { + libc::getpid() as f64 + } + #[cfg(windows)] + { + extern "system" { + fn GetCurrentProcessId() -> u32; + } + unsafe { GetCurrentProcessId() as f64 } + } + #[cfg(not(any(unix, windows)))] + { + 0.0 + } + }); + } + #[cfg(unix)] unsafe { libc::getpid() as f64 @@ -679,6 +774,25 @@ pub extern "C" fn js_process_pid() -> f64 { /// Get the parent process ID (process.ppid) #[no_mangle] pub extern "C" fn js_process_ppid() -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("ids.ppid", &format!("{:?}", ()), || { + #[cfg(unix)] + unsafe { + libc::getppid() as f64 + } + #[cfg(windows)] + { + // Fallback: return 1 (system process) + 1.0 + } + #[cfg(not(any(unix, windows)))] + { + 0.0 + } + }); + } + #[cfg(unix)] unsafe { libc::getppid() as f64 @@ -752,6 +866,23 @@ pub extern "C" fn js_process_versions() -> f64 { /// process.hrtime.bigint() -> bigint of nanoseconds #[no_mangle] pub extern "C" fn js_process_hrtime_bigint() -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("clock.hrtime_bigint", &format!("{:?}", ()), || { + crate::effect!(host_read, clock); + use crate::bigint_hooks::js_bigint_from_u64; + use crate::value::js_nanbox_bigint; + + let elapsed = get_hrtime_start().elapsed(); + // Add a base offset so the value is always > 0 even on the first call + let nanos = elapsed.as_nanos() as u64 + 1_000_000_000; + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + crate::snap_census::record_input_value("clock.hrtime.nanos", &nanos); + let bi = js_bigint_from_u64(nanos); + js_nanbox_bigint(bi as i64) + }); + } + crate::effect!(host_read, clock); use crate::bigint_hooks::js_bigint_from_u64; use crate::value::js_nanbox_bigint; @@ -770,6 +901,69 @@ pub extern "C" fn js_process_hrtime_bigint() -> f64 { /// diff is returned (clamped to non-negative). #[no_mangle] pub extern "C" fn js_process_hrtime(prior: f64) -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("clock.hrtime", &format!("{:?}", (prior,)), || { + crate::effect!(host_read, clock); + use crate::value::JSValue; + // #2013 — Node throws TypeError ERR_INVALID_ARG_TYPE when `prior` + // is supplied but isn't an Array (e.g. `process.hrtime('abc')`). + // Undefined falls through to the no-prior baseline read; an array + // with NaN-y entries is silently zeroed by `extract_hrtime_prior`, + // matching Node's lenient numeric coercion inside the array. + let prior_jv = JSValue::from_bits(prior.to_bits()); + if !prior_jv.is_undefined() && !crate::process::is_array_value(prior_jv) { + let message = format!( + "The \"time\" argument must be an instance of Array. Received {}", + crate::fs::validate::describe_received(prior) + ); + crate::fs::validate::throw_type_error_with_code(&message, "ERR_INVALID_ARG_TYPE"); + } + // #3039 — a supplied prior tuple must have exactly two elements; Node + // throws RangeError [ERR_OUT_OF_RANGE] ("It must be 2. Received ") + // for any other length. + if !prior_jv.is_undefined() { + let arr = prior_jv.as_pointer::(); + let len = crate::array::js_array_length(arr); + if len != 2 { + let message = format!( + "The value of \"time\" is out of range. It must be 2. Received {}", + len + ); + crate::fs::validate::throw_range_error_with_code(&message); + } + } + let elapsed = get_hrtime_start().elapsed(); + let total_ns = elapsed.as_nanos() as u64 + 1_000_000_000; + let mut secs = (total_ns / 1_000_000_000) as i64; + let mut nanos = (total_ns % 1_000_000_000) as i64; + + let undef_bits = crate::value::TAG_UNDEFINED; + if prior.to_bits() != undef_bits { + let (prev_s, prev_ns) = extract_hrtime_prior(prior); + let mut diff_s = secs - prev_s; + let mut diff_ns = nanos - prev_ns; + if diff_ns < 0 { + diff_s -= 1; + diff_ns += 1_000_000_000; + } + if diff_s < 0 { + diff_s = 0; + diff_ns = 0; + } + secs = diff_s; + nanos = diff_ns; + } + + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + crate::snap_census::record_input_value("clock.hrtime.tuple", &(secs, nanos)); + let arr = crate::array::js_array_alloc(2); + let arr = crate::array::js_array_push(arr, JSValue::number(secs as f64)); + let arr = crate::array::js_array_push(arr, JSValue::number(nanos as f64)); + f64::from_bits(JSValue::pointer(arr as *const u8).bits()) + }); + } + crate::effect!(host_read, clock); use crate::value::JSValue; // #2013 — Node throws TypeError ERR_INVALID_ARG_TYPE when `prior` diff --git a/crates/perry-runtime/src/perf_clock.rs b/crates/perry-runtime/src/perf_clock.rs index afe88c835aa..ff6ecf7dcc4 100644 --- a/crates/perry-runtime/src/perf_clock.rs +++ b/crates/perry-runtime/src/perf_clock.rs @@ -14,6 +14,16 @@ struct PerfClock { static PERF_CLOCK: OnceLock = OnceLock::new(); fn wall_clock_ms() -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("clock.time_origin", &format!("{:?}", ()), || { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs_f64() * 1000.0) + .unwrap_or(0.0) + }); + } + SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs_f64() * 1000.0) @@ -32,6 +42,15 @@ pub(crate) fn init_time_origin() { } pub(crate) fn time_origin_ms() -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call( + "clock.time_origin_observed", + &format!("{:?}", ()), + || perf_clock().time_origin_ms, + ); + } + perf_clock().time_origin_ms } diff --git a/crates/perry-runtime/src/process/credentials.rs b/crates/perry-runtime/src/process/credentials.rs index 4d29e421d4f..9f5fc77cdfd 100644 --- a/crates/perry-runtime/src/process/credentials.rs +++ b/crates/perry-runtime/src/process/credentials.rs @@ -12,6 +12,21 @@ use crate::value::JSValue; /// POSIX credential accessors (#1408). On non-unix targets each returns 0. #[no_mangle] pub extern "C" fn js_process_getuid() -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("ids.uid", &format!("{:?}", ()), || { + crate::effect!(host_read, ids); + #[cfg(unix)] + unsafe { + libc::getuid() as f64 + } + #[cfg(not(unix))] + { + 0.0 + } + }); + } + crate::effect!(host_read, ids); #[cfg(unix)] unsafe { @@ -24,6 +39,21 @@ pub extern "C" fn js_process_getuid() -> f64 { } #[no_mangle] pub extern "C" fn js_process_geteuid() -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("ids.euid", &format!("{:?}", ()), || { + crate::effect!(host_read, ids); + #[cfg(unix)] + unsafe { + libc::geteuid() as f64 + } + #[cfg(not(unix))] + { + 0.0 + } + }); + } + crate::effect!(host_read, ids); #[cfg(unix)] unsafe { @@ -36,6 +66,21 @@ pub extern "C" fn js_process_geteuid() -> f64 { } #[no_mangle] pub extern "C" fn js_process_getgid() -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("ids.gid", &format!("{:?}", ()), || { + crate::effect!(host_read, ids); + #[cfg(unix)] + unsafe { + libc::getgid() as f64 + } + #[cfg(not(unix))] + { + 0.0 + } + }); + } + crate::effect!(host_read, ids); #[cfg(unix)] unsafe { @@ -48,6 +93,21 @@ pub extern "C" fn js_process_getgid() -> f64 { } #[no_mangle] pub extern "C" fn js_process_getegid() -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("ids.egid", &format!("{:?}", ()), || { + crate::effect!(host_read, ids); + #[cfg(unix)] + unsafe { + libc::getegid() as f64 + } + #[cfg(not(unix))] + { + 0.0 + } + }); + } + crate::effect!(host_read, ids); #[cfg(unix)] unsafe { diff --git a/crates/perry-runtime/src/process/env_misc.rs b/crates/perry-runtime/src/process/env_misc.rs index 14dc614e85a..b1988b93411 100644 --- a/crates/perry-runtime/src/process/env_misc.rs +++ b/crates/perry-runtime/src/process/env_misc.rs @@ -1461,6 +1461,10 @@ fn js_process_env_impl() -> f64 { } let vars: Vec<(String, String)> = std::env::vars().collect(); + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if crate::snap_census::runner_on_for_inputs() { + crate::snap_census::record_input_value("env_block", &vars); + } // Pad alloc_limit so small env sets still have headroom; large // environments (CI runners) spill to the overflow Vec path. let alloc_limit = std::cmp::max(vars.len() as u32, crate::object::INLINE_SLOT_FLOOR as u32); diff --git a/crates/perry-runtime/src/snap_census.rs b/crates/perry-runtime/src/snap_census.rs index f17bf5e7808..b23deca0efe 100644 --- a/crates/perry-runtime/src/snap_census.rs +++ b/crates/perry-runtime/src/snap_census.rs @@ -681,3 +681,40 @@ pub(crate) fn init_guard(addr: usize, guard: usize) { #[cfg(not(all(target_os = "linux", target_arch = "x86_64")))] let _ = (addr, guard); } + +// Values and calls are retained only by the Linux instrumented census. +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +pub use runner::input_record::Value as InputValue; + +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +pub fn input_call(operation: &str, arguments: &str, call: impl FnOnce() -> T) -> T { + if !runner::on() { + return call(); + } + let before_errno = unsafe { *libc::__errno_location() }; + let token = runner::input_event(operation, arguments.to_owned()); + unsafe { + *libc::__errno_location() = before_errno; + } + runner::input_scope(1); + let result = call(); + let saved_errno = unsafe { *libc::__errno_location() }; + runner::input_scope(-1); + runner::input_event("result", format!("{token}:{}", result.input_value())); + unsafe { + *libc::__errno_location() = saved_errno; + } + result +} + +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +pub fn runner_on_for_inputs() -> bool { + runner::on() +} + +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +pub fn record_input_value(operation: &str, value: &T) { + if runner::on() { + runner::input_event(operation, value.input_value()); + } +} diff --git a/crates/perry-runtime/src/snap_census/input_record.rs b/crates/perry-runtime/src/snap_census/input_record.rs new file mode 100644 index 00000000000..b770f837d81 --- /dev/null +++ b/crates/perry-runtime/src/snap_census/input_record.rs @@ -0,0 +1,302 @@ +//! Ordered evidence for execution-prefix eligibility. This module is only +//! compiled with the Linux instruments runner; it never decides production facts. +use super::{read_ir, top_init, State}; +use std::fmt::Write as _; + +#[derive(Default)] +pub(super) struct Record { + pub seq: u64, + pub events: Vec, + pub writes: Vec>, + pub watching: bool, + pub call_depth: u32, + pub allocated_bytes: u64, +} +pub(super) struct Write { + pub seq: u64, + pub ir: u64, + pub word: usize, + pub pc: usize, +} +pub(super) struct Event { + seq: u64, + ir: u64, + allocated_bytes: u64, + init: u32, + stack: Vec<(u32, u32, u8)>, + pcs: [usize; 6], + op: String, + value: String, +} +pub(super) fn event(st: &mut State, op: &str, value: String) -> u64 { + let ir = read_ir(st.perf_fd); + let init = top_init(st); + st.inputs.seq += 1; + let seq = st.inputs.seq; + st.inputs.writes.resize_with(st.inits.len(), || None); + let mut pcs = [0usize; crate::error::MAX_CAPTURED_FRAMES]; + let n = crate::error::capture_ips(&mut pcs); + let mut six = [0; 6]; + for (to, from) in six.iter_mut().zip(pcs[..n].iter().skip(1)) { + *to = *from; + } + let stack = st + .frames + .iter() + .map(|f| { + ( + f.init, + st.inits[f.init as usize].parts as u32, + u8::from(f.kind == super::FrameKind::Job), + ) + }) + .collect(); + st.inputs.events.push(Event { + seq, + ir, + allocated_bytes: st.inputs.allocated_bytes, + init, + stack, + pcs: six, + op: op.to_owned(), + value, + }); + let saved_errno = unsafe { *libc::__errno_location() }; + unsafe { + libc::syscall( + libc::SYS_lseek, + -1 as libc::c_int, + seq as libc::off_t, + 0x5321 as libc::c_int, + ); + } + unsafe { + *libc::__errno_location() = saved_errno; + } + seq +} +pub(super) fn watch(st: &State) -> bool { + let id = top_init(st) as usize; + st.inputs.call_depth == 0 + && id != 0 + && st.inits[id].parts >= 2 + && st.inputs.writes.get(id).is_some_and(Option::is_none) +} +/// Called after the census has excluded bookkeeping and untracked binary +/// words. A signal handler must not allocate or format strings here. +pub(super) fn changed_word(st: &mut State, word: usize, pc: usize) { + let id = top_init(st) as usize; + if !watch(st) { + return; + } + let external = st.globals.contains(word as u64) + || (st.realm.0 <= word && word < st.realm.1) + || super::object_start(st, word) + .and_then(|h| st.born_by.get(h as u64)) + .is_some_and(|owner| owner as usize != id); + if !external { + return; + } + st.inputs.seq += 1; + st.inputs.writes[id] = Some(Write { + seq: st.inputs.seq, + ir: read_ir(st.perf_fd), + word, + pc, + }); +} +pub(super) fn write(st: &State) -> std::io::Result<()> { + use std::io::Write as _; + let mut rows: Vec<(u64, String)> = Vec::new(); + let mut out = std::io::BufWriter::new(std::fs::File::create(format!("{}.inputs.tsv", st.out))?); + writeln!( + out, + "seq\tir\tinit\tstack_parts\top\tvalue_hex\tpcs\tallocated_bytes" + )?; + for e in &st.inputs.events { + let stack = e + .stack + .iter() + .map(|(i, p, k)| format!("{i}:{p}:{k}")) + .collect::>() + .join(","); + let pcs = e + .pcs + .iter() + .map(|p| format!("{p:x}")) + .collect::>() + .join(","); + rows.push(( + e.seq, + format!( + "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", + e.seq, + e.ir, + e.init, + stack, + e.op, + hex(e.value.as_bytes()), + pcs, + e.allocated_bytes + ), + )); + } + for (id, hit) in st.inputs.writes.iter().enumerate() { + if let Some(w) = hit { + rows.push(( + w.seq, + format!( + "{}\t{}\t{}\t\tfirst_changed_tracked_word\t{}\t{:x}\t", + w.seq, + w.ir, + id, + hex(format!("{:x}", w.word).as_bytes()), + w.pc + ), + )); + } + } + rows.sort_unstable_by_key(|r| r.0); + for (_, line) in rows { + writeln!(out, "{line}")?; + } + out.flush() +} +pub(super) fn hex(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + let _ = write!(out, "{b:02x}"); + } + out +} +pub trait Value { + fn input_value(&self) -> String; +} +macro_rules! debug_value { + ($($t:ty),*) => { $(impl Value for $t { + fn input_value(&self) -> String { format!("{self:?}") } + })* } +} +debug_value!( + (), + bool, + u8, + u16, + u32, + u64, + usize, + i8, + i16, + i32, + i64, + isize, + u128, + String +); +impl Value for &str { + fn input_value(&self) -> String { + format!("string:{}", hex(self.as_bytes())) + } +} +impl Value for f64 { + fn input_value(&self) -> String { + format!("f64:{:016x}", self.to_bits()) + } +} +impl Value for Option { + fn input_value(&self) -> String { + match self { + None => "None".into(), + Some(v) => format!("Some({})", v.input_value()), + } + } +} +impl Value for Vec { + fn input_value(&self) -> String { + format!( + "[{}]", + self.iter() + .map(Value::input_value) + .collect::>() + .join(",") + ) + } +} +impl Value for [T; N] { + fn input_value(&self) -> String { + format!( + "[{}]", + self.iter() + .map(Value::input_value) + .collect::>() + .join(",") + ) + } +} +impl Value for (A, B) { + fn input_value(&self) -> String { + format!("({},{})", self.0.input_value(), self.1.input_value()) + } +} +impl Value for *mut crate::StringHeader { + fn input_value(&self) -> String { + if self.is_null() { + return "null".into(); + } + unsafe { + let bytes = + std::slice::from_raw_parts(self.add(1).cast::(), (**self).byte_len as usize); + format!("string:{}", hex(bytes)) + } + } +} +impl Value for *mut crate::ArrayHeader { + fn input_value(&self) -> String { + if self.is_null() { + return "null".into(); + } + let len = unsafe { (**self).length }; + let mut out = String::from("["); + for i in 0..len { + let value = crate::array::js_array_get_f64_unchecked(*self, i).to_bits(); + if i != 0 { + out.push(','); + } + if value >> 48 == 0x7fff { + let ptr = (value & 0x0000_ffff_ffff_ffff) as *mut crate::StringHeader; + out.push_str(&ptr.input_value()); + } else { + let _ = write!(out, "bits:{value:016x}"); + } + } + out.push(']'); + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn exact_scalar_and_absence_values() { + assert_ne!(0.0f64.input_value(), (-0.0f64).input_value()); + assert_ne!( + Option::::None.input_value(), + Some(String::new()).input_value() + ); + assert_eq!(hex(&[0, 0xff, 10]), "00ff0a"); + } +} + +impl Value for *mut crate::buffer::BufferHeader { + fn input_value(&self) -> String { + if self.is_null() { + return "null".into(); + } + let value = f64::from_bits(crate::JSValue::pointer(*self as *const u8).bits()); + crate::buffer::bytes::no_gc(|scope| match crate::buffer::bytes::bytes(value, scope) { + Ok(bytes) => format!("bytes:{}", hex(bytes)), + Err(_) => "unsupported_buffer_payload".into(), + }) + } +} diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 571e346fca4..20f470d80d6 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -35,6 +35,8 @@ //! //! Linux x86_64 only (the reservation, `REG_ERR`, the trap flag). +#[path = "input_record.rs"] +pub(super) mod input_record; use super::Effect; use std::sync::atomic::{AtomicBool, AtomicPtr, AtomicU8, AtomicUsize, Ordering::Relaxed}; @@ -88,6 +90,43 @@ pub(super) fn holds_collection() -> bool { on() && !state().noprotect } +// Native helper threads are visible in the syscall companion, but must not +// borrow the main thread's census buffers. +fn on_main_thread() -> bool { + let p = ANCHOR.state.load(Relaxed); + !p.is_null() && unsafe { (*p).main_tid == libc::syscall(libc::SYS_gettid) } +} + +unsafe fn foreign_input(operation: &[u8], argument: &[u8], value: &[u8]) { + let header = b"SNAP21_FOREIGN_INPUT"; + let mut rows = [ + libc::iovec { + iov_base: header.as_ptr() as *mut _, + iov_len: header.len(), + }, + libc::iovec { + iov_base: operation.as_ptr() as *mut _, + iov_len: operation.len(), + }, + libc::iovec { + iov_base: argument.as_ptr() as *mut _, + iov_len: argument.len(), + }, + libc::iovec { + iov_base: value.as_ptr() as *mut _, + iov_len: value.len(), + }, + ]; + let saved_errno = *libc::__errno_location(); + libc::syscall( + libc::SYS_writev, + -1 as libc::c_int, + rows.as_mut_ptr(), + rows.len(), + ); + *libc::__errno_location() = saved_errno; +} + fn state() -> &'static mut State { // SAFETY: set once at start, never freed; the runner is single-threaded // (module evaluation and jobs run on the main thread; a fault on another @@ -488,6 +527,7 @@ impl PageMap { } struct State { + inputs: input_record::Record, out: String, inits: Vec, frames: Vec, @@ -827,6 +867,7 @@ unsafe fn start_census(out: String) { ); let tls_block = (fs.saturating_sub(executable_tls_size()), fs); let mut st = Box::new(State { + inputs: input_record::Record::default(), out, inits: Vec::with_capacity(4096), frames: Vec::with_capacity(256), @@ -1059,8 +1100,21 @@ unsafe fn start_census(out: String) { } } } + st.inputs.allocated_bytes = st.births.as_slice().iter().map(|b| b.size as u64).sum(); install_handlers(); ANCHOR.on.store(true, Relaxed); + input_record::event(st, "census_start", String::new()); + let mut raw_env = Vec::>::new(); + let mut env_entry = environ; + while !env_entry.is_null() && !(*env_entry).is_null() { + raw_env.push(std::ffi::CStr::from_ptr(*env_entry).to_bytes().to_vec()); + env_entry = env_entry.add(1); + } + input_record::event( + st, + "initial_environment", + input_record::Value::input_value(&raw_env), + ); crate::arena::refresh_inline_allocation_limit(); } @@ -1226,7 +1280,9 @@ fn set_mode_for_top(st: &mut State) { }; } let want = top != 0 && st.inits[top].in_d && !st.noprotect; - if want != st.d_mode { + let watching = input_record::watch(st); + if want != st.d_mode || watching != st.inputs.watching { + st.inputs.watching = watching; st.d_mode = want; unsafe { apply_protection(st) }; } @@ -1311,12 +1367,14 @@ pub(super) fn init_enter(addr: usize) { init: id, kind: FrameKind::Init, }); + input_record::event(st, "init_enter", format!("{addr:x}")); set_mode_for_top(st); } pub(super) fn init_exit(threw: bool) { let st = state(); charge(st); + input_record::event(st, "init_exit", format!("threw={threw}")); let Some(f) = st.frames.pop() else { return; }; @@ -1347,6 +1405,8 @@ pub(super) fn init_part() { i.parts = i.parts.saturating_add(1); } } + input_record::event(st, "init_part", String::new()); + set_mode_for_top(st); } pub(super) fn require_depth(delta: i32) { @@ -1382,11 +1442,13 @@ pub(super) fn job_enter(owner: u32) { init: owner, kind: FrameKind::Job, }); + input_record::event(st, "job_enter", owner.to_string()); set_mode_for_top(st); } pub(super) fn job_exit() { let st = state(); + input_record::event(st, "job_exit", String::new()); charge(st); if st.frames.last().is_some_and(|f| f.kind == FrameKind::Job) { st.frames.pop(); @@ -1416,6 +1478,7 @@ pub(super) fn entry_body() { }); st.inits.push(init); st.entry_init = id; + input_record::event(st, "entry_body", String::new()); st.frames.push(Frame { init: id, kind: FrameKind::Init, @@ -1563,8 +1626,42 @@ pub(super) fn dyn_code(kind: u8) { } } +pub(super) fn input_scope(delta: i32) { + let st = state(); + if delta > 0 { + st.inputs.call_depth += 1; + } else { + st.inputs.call_depth -= 1; + } + set_mode_for_top(st); +} + +pub(super) fn input_event(op: &str, value: String) -> u64 { + input_record::event(state(), op, value) +} + pub(super) fn host(class: Effect, what: &'static str) { let st = state(); + let input_top = top_init(st) as usize; + if class != Effect::TargetFact + && st.inputs.call_depth == 0 + && input_top != 0 + && st.inputs.writes.get(input_top).is_some_and(Option::is_none) + { + st.inputs.seq += 1; + st.inputs.writes[input_top] = Some(input_record::Write { + seq: st.inputs.seq, + ir: read_ir(st.perf_fd), + word: 0, + pc: 0, + }); + st.inputs.watching = false; + unsafe { + apply_protection(st); + } + } + + input_record::event(st, "effect", format!("{class:?}:{what}")); if st.sabotage_env && what == "env" { return; } @@ -1637,6 +1734,7 @@ fn object_start(st: &State, word: usize) -> Option { pub(super) fn birth(addr: usize, size: usize) { let st = state(); + st.inputs.allocated_bytes += size as u64; // Memory handed out again (a free-list slot, a rewound bump) holds new // locations: an earlier birth there and what members of D did to its // words are gone. @@ -1702,10 +1800,7 @@ pub(super) fn global_root(slot: usize) { // That member is skipped here, so the first registration this run sees // is an importer's duplicate: bookkeeping as above, and the slot stays // D's binding, owned by no imaged init. - if st.slot_owner_rule - && !st.skip.is_empty() - && st.env_words.get(slot as u64) == Some(POISON) - { + if st.slot_owner_rule && !st.skip.is_empty() && st.env_words.get(slot as u64) == Some(POISON) { return; } count_unwatched_root(st, slot); @@ -1801,6 +1896,11 @@ fn tls_slot(st: &State, addr: usize) -> Option<*mut u8> { // --------------------------------------------------------------------------- fn policy(st: &State, page: usize) -> i32 { + if input_record::watch(st) + && page_flags(st, page).is_some_and(|f| unsafe { *f & PF_TRACKED != 0 }) + { + return libc::PROT_READ; + } // The weights run (`PERRY_SNAP_NOPROTECT=1`) takes no fault: its Ir per // init is the program's, not the census's. if st.noprotect { @@ -1858,6 +1958,12 @@ unsafe fn for_each_tracked_range(st: &State, mut f: impl FnMut(usize, usize)) { } unsafe fn apply_protection(st: &mut State) { + if input_record::watch(st) { + for_each_tracked_range(st, |b, e| { + libc::mprotect(b as *mut _, e - b, libc::PROT_READ); + }); + return; + } if st.top_mode { for_each_tracked_range(st, |b, e| { libc::mprotect(b as *mut _, e - b, libc::PROT_READ | libc::PROT_WRITE); @@ -2327,6 +2433,15 @@ fn note_access(st: &mut State, word: usize, write: bool, pc: usize) { st.faults_ignored += 1; return; } + if write && input_record::watch(st) { + input_record::changed_word(st, word, pc); + if !input_record::watch(st) { + st.inputs.watching = false; + unsafe { + apply_protection(st); + } + } + } if st.transp_globals.contains(word as u64) { st.transparent_hits += 1; return; @@ -2638,9 +2753,19 @@ pub unsafe extern "C" fn getenv(name: *const libc::c_char) -> *mut libc::c_char e = e.add(1); } } - if on() { + if on() && on_main_thread() { let st = state(); let top = st.frames.last().map_or(0, |f| f.init); + let raw_value = if found.is_null() { + "unset".to_owned() + } else { + input_record::hex(std::ffi::CStr::from_ptr(found).to_bytes()) + }; + input_record::event( + st, + "getenv", + format!("{}={}", input_record::hex(key), raw_value), + ); if st.env_reads.len() < 1 << 20 { let value = (!found.is_null()).then(|| { std::ffi::CStr::from_ptr(found) @@ -2651,6 +2776,14 @@ pub unsafe extern "C" fn getenv(name: *const libc::c_char) -> *mut libc::c_char .push((top, String::from_utf8_lossy(key).into_owned(), value)); } } + if on() && !on_main_thread() { + let value = if found.is_null() { + b"".as_slice() + } else { + std::ffi::CStr::from_ptr(found).to_bytes() + }; + foreign_input(b"getenv", key, value); + } found } @@ -2669,6 +2802,7 @@ pub(super) fn boundary(at: &'static str) { if st.boundary_seq != 0 { return; } + input_record::event(st, "boundary", at.to_owned()); charge(st); st.seq += 1; st.boundary_seq = st.seq; @@ -2682,6 +2816,7 @@ pub(super) fn boundary(at: &'static str) { } crate::arena::refresh_inline_allocation_limit(); write_report(st); + input_record::write(st).expect("write ordered input record"); // The check run proves the image at the boundary; what runs after it // reads bindings of the skipped members of D, so the run ends here. if !st.skip.is_empty() { @@ -3883,6 +4018,7 @@ pub(super) fn env_storage(begin: usize, obj: usize) { pub(super) fn env_var(name: &str, value: Option<&str>, ptr: usize) { let st = state(); + input_record::event(st, "env_get", format!("{name:?}={value:?}")); let top = top_init(st); let site = st.env_site.take().filter(|s| s.0 == top).map(|s| s.1); if !st.env_rule { @@ -3915,6 +4051,7 @@ pub(super) fn env_var(name: &str, value: Option<&str>, ptr: usize) { pub(super) fn env_has(name: &str, present: bool) { let st = state(); + input_record::event(st, "env_has", format!("{name:?}={present}")); if !st.env_rule { return host(Effect::HostRead, "env"); } @@ -4293,3 +4430,114 @@ fn write_env_report(st: &State, f: &mut std::fs::File, rank: &[u64]) { } } } + +// Calling the kernel directly also covers libc callers that would otherwise +// read the vDSO without a syscall visible to the companion native trace. +#[no_mangle] +pub unsafe extern "C" fn clock_gettime( + clock: libc::clockid_t, + out: *mut libc::timespec, +) -> libc::c_int { + let rc = libc::syscall(libc::SYS_clock_gettime, clock, out) as libc::c_int; + let saved_errno = *libc::__errno_location(); + if on() && on_main_thread() { + let value = if rc == 0 && !out.is_null() { + format!( + "clock={clock};sec={};nsec={}", + (*out).tv_sec, + (*out).tv_nsec + ) + } else { + format!("clock={clock};rc={rc};errno={saved_errno}") + }; + input_event("native.clock_gettime", value); + } + *libc::__errno_location() = saved_errno; + rc +} + +#[no_mangle] +pub unsafe extern "C" fn gettimeofday( + out: *mut libc::timeval, + zone: *mut libc::c_void, +) -> libc::c_int { + let rc = libc::syscall(libc::SYS_gettimeofday, out, zone) as libc::c_int; + let saved_errno = *libc::__errno_location(); + if on() && on_main_thread() { + let value = if rc == 0 && !out.is_null() { + format!("sec={};usec={}", (*out).tv_sec, (*out).tv_usec) + } else { + format!("rc={rc};errno={saved_errno}") + }; + input_event("native.gettimeofday", value); + } + *libc::__errno_location() = saved_errno; + rc +} + +#[no_mangle] +pub unsafe extern "C" fn time(out: *mut libc::time_t) -> libc::time_t { + let rc = libc::syscall(libc::SYS_time, out) as libc::time_t; + let saved_errno = *libc::__errno_location(); + if on() && on_main_thread() { + input_event("native.time", format!("seconds={rc};errno={saved_errno}")); + } + *libc::__errno_location() = saved_errno; + rc +} + +// Environment mutations have no syscall and must not disappear from native +// evidence. Resolve the libc operation afresh; this is instruments-only. +#[no_mangle] +pub unsafe extern "C" fn unsetenv(name: *const libc::c_char) -> libc::c_int { + let saved_errno = *libc::__errno_location(); + if on() && on_main_thread() { + let key = if name.is_null() { + "null".into() + } else { + input_record::hex(std::ffi::CStr::from_ptr(name).to_bytes()) + }; + input_event("native.unsetenv", key); + host(Effect::HostWrite, "env"); + } + let next = libc::dlsym(libc::RTLD_NEXT, c"unsetenv".as_ptr()); + assert!(!next.is_null(), "libc unsetenv missing"); + let call: unsafe extern "C" fn(*const libc::c_char) -> libc::c_int = std::mem::transmute(next); + *libc::__errno_location() = saved_errno; + call(name) +} + +#[no_mangle] +pub unsafe extern "C" fn setenv( + name: *const libc::c_char, + value: *const libc::c_char, + overwrite: libc::c_int, +) -> libc::c_int { + let saved_errno = *libc::__errno_location(); + if on() && on_main_thread() { + let key = if name.is_null() { + "null".into() + } else { + input_record::hex(std::ffi::CStr::from_ptr(name).to_bytes()) + }; + let val = if value.is_null() { + "null".into() + } else { + input_record::hex(std::ffi::CStr::from_ptr(value).to_bytes()) + }; + input_event( + "native.setenv", + format!("{key}={val};overwrite={overwrite}"), + ); + host(Effect::HostWrite, "env"); + } + let next = libc::dlsym(libc::RTLD_NEXT, c"setenv".as_ptr()); + assert!(!next.is_null(), "libc setenv missing"); + let call: unsafe extern "C" fn( + *const libc::c_char, + *const libc::c_char, + libc::c_int, + ) -> libc::c_int = std::mem::transmute(next); + *libc::__errno_location() = saved_errno; + call(name, value, overwrite) +} diff --git a/crates/perry-runtime/src/tty.rs b/crates/perry-runtime/src/tty.rs index e6a8671fb62..eabd03e8153 100644 --- a/crates/perry-runtime/src/tty.rs +++ b/crates/perry-runtime/src/tty.rs @@ -80,12 +80,36 @@ pub(crate) fn scan_tty_resize_callback_root_mut(visitor: &mut crate::gc::Runtime #[cfg(unix)] fn isatty_impl(fd: i32) -> bool { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("tty.isatty", &format!("{:?}", (fd,)), || { + crate::effect!(host_read, tty); + unsafe { libc::isatty(fd) != 0 } + }); + } + crate::effect!(host_read, tty); unsafe { libc::isatty(fd) != 0 } } #[cfg(unix)] fn winsize_impl(fd: i32) -> Option<(i32, i32)> { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("tty.winsize", &format!("{:?}", (fd,)), || { + crate::effect!(host_read, tty); + unsafe { + let mut ws: libc::winsize = std::mem::zeroed(); + if libc::ioctl(fd, libc::TIOCGWINSZ, &mut ws) == 0 && ws.ws_col > 0 && ws.ws_row > 0 + { + Some((ws.ws_col as i32, ws.ws_row as i32)) + } else { + None + } + } + }); + } + crate::effect!(host_read, tty); unsafe { let mut ws: libc::winsize = std::mem::zeroed(); @@ -99,6 +123,18 @@ fn winsize_impl(fd: i32) -> Option<(i32, i32)> { #[cfg(windows)] fn isatty_impl(fd: i32) -> bool { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("tty.isatty", &format!("{:?}", (fd,)), || { + crate::effect!(host_read, tty); + // A std handle counts as a TTY when `GetConsoleMode` succeeds on it — + // the same probe `std::io::IsTerminal` uses (see `builtins/console.rs`) + // and the same criterion libuv uses to classify a handle as `UV_TTY`. + // Piped/redirected handles fail the probe → false, matching Node. + crate::win_console::is_console_fd(fd) + }); + } + crate::effect!(host_read, tty); // A std handle counts as a TTY when `GetConsoleMode` succeeds on it — // the same probe `std::io::IsTerminal` uses (see `builtins/console.rs`) @@ -109,6 +145,16 @@ fn isatty_impl(fd: i32) -> bool { #[cfg(windows)] fn winsize_impl(fd: i32) -> Option<(i32, i32)> { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("tty.winsize", &format!("{:?}", (fd,)), || { + crate::effect!(host_read, tty); + // GetConsoleScreenBufferInfo srWindow extents (right-left+1, + // bottom-top+1) for the stdout/stderr console handle. + crate::win_console::window_size(fd) + }); + } + crate::effect!(host_read, tty); // GetConsoleScreenBufferInfo srWindow extents (right-left+1, // bottom-top+1) for the stdout/stderr console handle. @@ -117,12 +163,28 @@ fn winsize_impl(fd: i32) -> Option<(i32, i32)> { #[cfg(not(any(unix, windows)))] fn isatty_impl(_fd: i32) -> bool { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("tty.isatty", &format!("{:?}", (fd,)), || { + crate::effect!(host_read, tty); + false + }); + } + crate::effect!(host_read, tty); false } #[cfg(not(any(unix, windows)))] fn winsize_impl(_fd: i32) -> Option<(i32, i32)> { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return crate::snap_census::input_call("tty.winsize", &format!("{:?}", (fd,)), || { + crate::effect!(host_read, tty); + None + }); + } + crate::effect!(host_read, tty); None } @@ -895,6 +957,9 @@ pub(crate) fn tty_listener_remove_all_value() -> f64 { #[no_mangle] pub extern "C" fn js_tty_read_stream_new(fd: f64) -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + crate::snap_census::record_input_value("native_resource.tty_stream", &fd); + validate_tty_read_fd(fd); ensure_tty_prototypes(); let keys = b"isRaw\0isTTY\0"; @@ -912,6 +977,9 @@ pub extern "C" fn js_tty_read_stream_new(fd: f64) -> f64 { #[no_mangle] pub extern "C" fn js_tty_write_stream_new(fd: f64) -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + crate::snap_census::record_input_value("native_resource.tty_stream", &fd); + validate_tty_write_fd(fd); ensure_tty_prototypes(); let obj = crate::object::js_object_alloc_class_with_keys( diff --git a/crates/perry-stdlib/build.rs b/crates/perry-stdlib/build.rs new file mode 100644 index 00000000000..1bde525f77f --- /dev/null +++ b/crates/perry-stdlib/build.rs @@ -0,0 +1,9 @@ +// Match the runtime's instruments cohort so builtin input hooks cannot +// silently disappear from the instrumented archive. +fn main() { + println!("cargo:rustc-check-cfg=cfg(perry_gc_instruments)"); + println!("cargo:rerun-if-env-changed=PERRY_RUNTIME_INSTRUMENTS"); + if std::env::var("PERRY_RUNTIME_INSTRUMENTS").is_ok_and(|v| v == "1") { + println!("cargo:rustc-cfg=perry_gc_instruments"); + } +} diff --git a/crates/perry-stdlib/src/crypto/random.rs b/crates/perry-stdlib/src/crypto/random.rs index 11e035f9192..076a1e4bcfb 100644 --- a/crates/perry-stdlib/src/crypto/random.rs +++ b/crates/perry-stdlib/src/crypto/random.rs @@ -55,6 +55,35 @@ fn validate_random_bytes_size(size: f64) -> usize { pub extern "C" fn js_crypto_random_bytes_buffer( size: f64, ) -> *mut perry_runtime::buffer::BufferHeader { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return perry_runtime::snap_census::input_call( + "random.crypto.bytes", + &format!("{:?}", (size,)), + || { + perry_runtime::effect!(host_read, random); + let size = validate_random_bytes_size(size); + let (value, pin) = perry_runtime::buffer::bytes::new_bytes( + perry_runtime::buffer::bytes::Brand::Buffer, + size, + perry_runtime::buffer::bytes::Init::Uninit, + ); + unsafe { + rand::rng() + .fill_bytes(std::slice::from_raw_parts_mut(pin.as_mut_ptr(), pin.len())); + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + perry_runtime::snap_census::record_input_value( + "crypto.random.bytes", + &std::slice::from_raw_parts(pin.as_ptr(), pin.len()).to_vec(), + ); + } + JSValue::from_bits(value.to_bits()) + .as_pointer::() + .cast_mut() + }, + ); + } + perry_runtime::effect!(host_read, random); let size = validate_random_bytes_size(size); let (value, pin) = perry_runtime::buffer::bytes::new_bytes( @@ -64,6 +93,11 @@ pub extern "C" fn js_crypto_random_bytes_buffer( ); unsafe { rand::rng().fill_bytes(std::slice::from_raw_parts_mut(pin.as_mut_ptr(), pin.len())); + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + perry_runtime::snap_census::record_input_value( + "crypto.random.bytes", + &std::slice::from_raw_parts(pin.as_ptr(), pin.len()).to_vec(), + ); } JSValue::from_bits(value.to_bits()) .as_pointer::() @@ -115,11 +149,33 @@ pub unsafe extern "C" fn js_crypto_random_bytes_async(size: f64, callback_bits: /// crypto.randomBytes(size).toString('hex') -> string #[no_mangle] pub extern "C" fn js_crypto_random_bytes_hex(size: f64) -> *mut StringHeader { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return perry_runtime::snap_census::input_call( + "random.crypto.hex", + &format!("{:?}", (size,)), + || { + perry_runtime::effect!(host_read, random); + let size = validate_random_bytes_size(size); + + let mut bytes = vec![0u8; size]; + rand::rng().fill_bytes(&mut bytes); + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + perry_runtime::snap_census::record_input_value("crypto.random.bytes", &bytes); + let hex_str = perry_hex::encode(&bytes); + + js_string_from_bytes(hex_str.as_ptr(), hex_str.len() as u32) + }, + ); + } + perry_runtime::effect!(host_read, random); let size = validate_random_bytes_size(size); let mut bytes = vec![0u8; size]; rand::rng().fill_bytes(&mut bytes); + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + perry_runtime::snap_census::record_input_value("crypto.random.bytes", &bytes); let hex_str = perry_hex::encode(&bytes); js_string_from_bytes(hex_str.as_ptr(), hex_str.len() as u32) @@ -133,6 +189,31 @@ pub extern "C" fn js_crypto_random_bytes_hex(size: f64) -> *mut StringHeader { /// `{ disableEntropyCache: true }`, which draws fresh OS entropy. #[no_mangle] pub unsafe extern "C" fn js_crypto_random_uuid(options_bits: f64) -> *mut StringHeader { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return perry_runtime::snap_census::input_call( + "random.crypto.uuid", + &format!("{:?}", (options_bits,)), + || { + perry_runtime::effect!(host_read, random); + let uuid = if validate_random_uuid_options(options_bits) { + perry_uuid::v4_uncached() + } else { + perry_uuid::v4() + }; + // The bytes directly: `as_str` would re-validate 36 known-ASCII bytes as + // UTF-8 on every UUID only for the string constructor to copy them. + let uuid_bytes = uuid.as_bytes(); + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + perry_runtime::snap_census::record_input_value( + "crypto.random.uuid", + &uuid_bytes.to_vec(), + ); + js_string_from_bytes(uuid_bytes.as_ptr(), uuid_bytes.len() as u32) + }, + ); + } + perry_runtime::effect!(host_read, random); let uuid = if validate_random_uuid_options(options_bits) { perry_uuid::v4_uncached() @@ -142,6 +223,8 @@ pub unsafe extern "C" fn js_crypto_random_uuid(options_bits: f64) -> *mut String // The bytes directly: `as_str` would re-validate 36 known-ASCII bytes as // UTF-8 on every UUID only for the string constructor to copy them. let uuid_bytes = uuid.as_bytes(); + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + perry_runtime::snap_census::record_input_value("crypto.random.uuid", &uuid_bytes.to_vec()); js_string_from_bytes(uuid_bytes.as_ptr(), uuid_bytes.len() as u32) } @@ -153,9 +236,30 @@ pub unsafe extern "C" fn js_crypto_random_uuid(options_bits: f64) -> *mut String /// accepted for shape parity but does not change the generated value. #[no_mangle] pub extern "C" fn js_crypto_random_uuidv7() -> *mut StringHeader { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return perry_runtime::snap_census::input_call( + "random.crypto.uuidv7", + &format!("{:?}", ()), + || { + perry_runtime::effect!(host_read, random); + let uuid = perry_uuid::v7(); + let uuid_bytes = uuid.as_bytes(); + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + perry_runtime::snap_census::record_input_value( + "crypto.random.uuid", + &uuid_bytes.to_vec(), + ); + js_string_from_bytes(uuid_bytes.as_ptr(), uuid_bytes.len() as u32) + }, + ); + } + perry_runtime::effect!(host_read, random); let uuid = perry_uuid::v7(); let uuid_bytes = uuid.as_bytes(); + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + perry_runtime::snap_census::record_input_value("crypto.random.uuid", &uuid_bytes.to_vec()); js_string_from_bytes(uuid_bytes.as_ptr(), uuid_bytes.len() as u32) } @@ -252,6 +356,35 @@ fn validate_random_int_arg(value: f64, arg_name: &str) -> i64 { /// Returns a uniformly distributed integer in `[min, max)`. #[no_mangle] pub extern "C" fn js_crypto_random_int(min_bits: f64, max_bits: f64) -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + return perry_runtime::snap_census::input_call( + "random.crypto.int", + &format!("{:?}", (min_bits, max_bits,)), + || { + perry_runtime::effect!(host_read, random); + let min = validate_random_int_arg(min_bits, "min"); + let max = validate_random_int_arg(max_bits, "max"); + if max <= min { + let message = format!( + "The value of \"max\" is out of range. It must be greater than the value of \"min\" ({}). Received {}", + min, max + ); + perry_runtime::fs::validate::throw_range_error_with_code(&message); + } + let range = max as i128 - min as i128; + if range > RANDOM_INT_MAX_RANGE as i128 { + let message = format!( + "The value of \"max - min\" is out of range. It must be <= {}. Received {}", + RANDOM_INT_MAX_RANGE, range + ); + perry_runtime::fs::validate::throw_range_error_with_code(&message); + } + rand::rng().random_range(min..max) as f64 + }, + ); + } + perry_runtime::effect!(host_read, random); let min = validate_random_int_arg(min_bits, "min"); let max = validate_random_int_arg(max_bits, "max"); @@ -648,6 +781,11 @@ pub extern "C" fn js_crypto_random_fill_sync( perry_runtime::buffer::bytes::no_gc(|scope| unsafe { let data = perry_runtime::buffer::bytes::bytes_mut(value, scope)?; rand::rng().fill_bytes(&mut data[start..start + count]); + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + perry_runtime::snap_census::record_input_value( + "crypto.random.fill", + &data[start..start + count].to_vec(), + ); #[cfg(test)] if b2c_sabotage("random_fill_range") && count > 0 { // Make the wrong-window witness deterministic rather than depend diff --git a/scripts/snap_inputs/trace.py b/scripts/snap_inputs/trace.py new file mode 100644 index 00000000000..797037304e7 --- /dev/null +++ b/scripts/snap_inputs/trace.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Lossless syscall evidence plus a per-thread user instruction counter. +Unknown syscalls stay explicit and invalidate input-completeness claims. +This is a diagnostic companion, never part of a produced executable. +""" +import ctypes as C, errno, json, os, signal, struct, sys +from pathlib import Path +L=C.CDLL(None,use_errno=True) +L.ptrace.restype=C.c_long +L.syscall.restype=C.c_long +TRACEME=0; SYSCALL=24; SETOPTIONS=0x4200; GETEVENTMSG=0x4201; INFO=0x420e +WALL=0x40000000 +NAMES={0:"read",1:"write",2:"open",3:"close",4:"stat",5:"fstat",6:"lstat", +7:"poll",8:"lseek",9:"mmap",10:"mprotect",11:"munmap",12:"brk",13:"rt_sigaction", +14:"rt_sigprocmask",15:"rt_sigreturn",16:"ioctl",17:"pread64",18:"pwrite64", +19:"readv",20:"writev",21:"access",22:"pipe",23:"select",24:"sched_yield", +25:"mremap",28:"madvise",32:"dup",33:"dup2",35:"nanosleep",39:"getpid", +41:"socket",42:"connect",43:"accept",44:"sendto",45:"recvfrom",46:"sendmsg", +47:"recvmsg",48:"shutdown",49:"bind",50:"listen",51:"getsockname",52:"getpeername", +53:"socketpair",54:"setsockopt",55:"getsockopt",56:"clone",57:"fork",58:"vfork", +59:"execve",60:"exit",61:"wait4",62:"kill",63:"uname",72:"fcntl",73:"flock", +74:"fsync",75:"fdatasync",78:"getdents",79:"getcwd",80:"chdir",81:"fchdir", +82:"rename",83:"mkdir",84:"rmdir",85:"creat",87:"unlink",89:"readlink", +95:"umask",96:"gettimeofday",97:"getrlimit",98:"getrusage",99:"sysinfo", +102:"getuid",104:"getgid",105:"setuid",106:"setgid",107:"geteuid",108:"getegid", +109:"setpgid",110:"getppid",111:"getpgrp",112:"setsid",115:"getgroups",116:"setgroups", +131:"sigaltstack",157:"prctl",135:"personality",137:"statfs",138:"fstatfs",158:"arch_prctl",186:"gettid", +202:"futex",204:"sched_getaffinity",217:"getdents64",218:"set_tid_address", +228:"clock_gettime",229:"clock_getres",230:"clock_nanosleep",231:"exit_group", +232:"epoll_wait",233:"epoll_ctl",234:"tgkill",257:"openat",262:"newfstatat", +267:"readlinkat",270:"pselect6",271:"ppoll",273:"set_robust_list",281:"epoll_pwait", +288:"accept4",290:"eventfd2",441:"epoll_pwait2",291:"epoll_create1",292:"dup3",293:"pipe2",298:"perf_event_open",299:"recvmmsg", +302:"prlimit64",307:"sendmmsg",318:"getrandom",332:"statx",334:"rseq",435:"clone3", +436:"close_range",439:"faccessat2"} +REP={3,8,9,10,11,12,13,14,15,24,25,28,32,33,60,72,73,158,186,202,218,231,233,234,273,291,292,293,298,334,436} +def ptrace(req,pid,addr=0,data=0): + r=L.ptrace(req,pid,C.c_void_p(addr),C.c_void_p(data)) + if r==-1: + e=C.get_errno() + raise OSError(e,os.strerror(e)) + return r +def counter(pid): + a=(C.c_uint64*8)() + a[0]=64<<32; a[1]=1; a[5]=(1<<5)|(1<<6) + fd=L.syscall(298,C.byref(a),pid,-1,-1,0) + if fd<0: raise OSError(C.get_errno(),"perf_event_open") + return fd +def ir(fd): + return struct.unpack("Q",os.read(fd,8))[0] +def signed(x): + return C.c_int64(x).value +def main(): + output=sys.argv[1]; command=sys.argv[2:] + if command and command[0]=="--":command=command[1:] + pid=os.fork() + if pid==0: + ptrace(TRACEME,0) + os.kill(os.getpid(),signal.SIGSTOP) + os.execvpe(command[0],command,os.environ) + _,status=os.waitpid(pid,0) + ptrace(SETOPTIONS,pid,0,1|(1<<1)|(1<<2)|(1<<3)|(1<<4)|(1<<20)) + active={pid:{"perf":counter(pid),"entry":None,"mem":None,"diagnostic_fds":set()}} + sequence=0; main_rc=None; failures=[] + out=open(output,"w") + def memory(tid,addr,n): + if n==0:return b"" + if not addr:return None + state=active[tid] + if state["mem"] is None: + try:state["mem"]=os.open(f"/proc/{tid}/mem",os.O_RDONLY) + except OSError:return None + try: + data=os.pread(state["mem"],n,addr) + return data if len(data)==n else None + except (OSError,OverflowError):return None + def string(tid,addr): + if not addr:return None + data=bytearray() + while True: + # Do not cross a page when reading a potentially last-page C string. + n=min(256,4096-((addr+len(data))&4095)) + chunk=memory(tid,addr+len(data),n) + if chunk is None:return {"unreadable":addr+len(data)} + end=chunk.find(b"\0") + if end>=0: + data.extend(chunk[:end]);return data.hex() + data.extend(chunk) + def blob(tid,addr,n): + data=memory(tid,addr,n) + if data is None: + failures.append({"tid":tid,"address":addr,"length":n}) + return {"unreadable":addr,"length":n} + return data.hex() + def iov(tid,addr,count,returned): + raw=memory(tid,addr,count*16) + if raw is None:return {"unreadable_iov":addr} + values=[] + for i in range(count): + base,length=struct.unpack_from("QQ",raw,i*16) + size=min(length,max(0,returned)) + values.append(blob(tid,base,size));returned-=size + return values + ptrace(SYSCALL,pid) + while active: + tid,status=os.waitpid(-1,WALL) + if os.WIFEXITED(status) or os.WIFSIGNALED(status): + rc=os.WEXITSTATUS(status) if os.WIFEXITED(status) else 128+os.WTERMSIG(status) + state=active.pop(tid,None) + if state: + os.close(state["perf"]) + if state["mem"] is not None:os.close(state["mem"]) + if tid==pid:main_rc=rc + out.write(json.dumps({"kind":"exit","tid":tid,"rc":rc})+"\n");continue + if tid not in active: + active[tid]={"perf":counter(tid),"entry":None,"mem":None,"diagnostic_fds":set()} + sig=os.WSTOPSIG(status); event=status>>16 + if event in (1,2,3): + child=C.c_ulonglong() + ptrace(GETEVENTMSG,tid,0,C.addressof(child)) + out.write(json.dumps({"kind":"new_task","parent":tid,"tid":child.value,"ir":ir(active[tid]["perf"]),"policy":"STOP"})+"\n") + elif event==4: + state=active[tid] + if state["mem"] is not None:os.close(state["mem"]) + state["mem"]=None + elif sig==(signal.SIGTRAP|0x80): + buf=C.create_string_buffer(128) + ptrace(INFO,tid,128,C.addressof(buf)) + raw=buf.raw; op=raw[0]; ip=struct.unpack_from("Q",raw,8)[0] + state=active[tid]; pos=ir(state["perf"]) + if op==1: + nr,*args=struct.unpack_from("7Q",raw,24) + sequence+=1 + entry={"kind":"syscall","seq":sequence,"tid":tid,"ir_enter":pos,"ip":ip,"nr":nr,"name":NAMES.get(nr,"UNKNOWN"),"args":args,"policy":"representation" if nr in REP else "STOP"} + a=args + if nr in (2,4,6,21,80,82,83,84,85,87,89,137):entry["path"]=string(tid,a[0]) + elif nr in (257,262,267,439):entry["path"]=string(tid,a[1]) + if nr==59: + entry["path"]=string(tid,a[0]); argv=[]; index=0 + while True: + item=memory(tid,a[1]+8*index,8) + if item is None:break + ptr=struct.unpack("Q",item)[0] + if not ptr:break + argv.append(string(tid,ptr));index+=1 + entry["argv"]=argv + if nr==157 and a[0]==0x53564d41:entry["vma_name"]=string(tid,a[4]) + if nr==157 and a[0]==15:entry["input_name"]=string(tid,a[1]) + if nr in (1,18,44):entry["input_bytes"]=blob(tid,a[1],a[2]) + elif nr==20:entry["input_bytes"]=iov(tid,a[1],a[2],1<<63) + elif nr in (42,49):entry["sockaddr"]=blob(tid,a[1],a[2]) + elif nr==54:entry["option"]=blob(tid,a[3],a[4]) + if nr==8 and (a[0]&0xffffffff)==0xffffffff and a[2]==0x5321: + entry["kind"]="runtime_marker";entry["runtime_seq"]=a[1] + if nr in (0,1,8) and a[0] in state["diagnostic_fds"]:entry["policy"]="diagnostic" + state["entry"]=entry + elif op==2 and state["entry"] is not None: + e=state["entry"];state["entry"]=None + ret=struct.unpack_from("q",raw,24)[0] + e["ir_exit"]=pos;e["ret"]=ret;e["is_error"]=bool(raw[32]) + a=e["args"];nr=e["nr"] + if ret>=0: + if nr==298:state["diagnostic_fds"].add(ret) + if nr in (0,17,45,78,217):e["bytes"]=blob(tid,a[1],ret) + elif nr==19:e["bytes"]=iov(tid,a[1],a[2],ret) + elif nr==318:e["bytes"]=blob(tid,a[0],ret) + elif nr in (79,89):e["bytes"]=blob(tid,a[0] if nr==79 else a[1],ret) + elif nr==267:e["bytes"]=blob(tid,a[2],ret) + elif nr in (4,5,6):e["struct"]=blob(tid,a[1],144) + elif nr==262:e["struct"]=blob(tid,a[2],144) + elif nr==332:e["struct"]=blob(tid,a[4],256) + elif nr in (228,229):e["struct"]=blob(tid,a[1],16) + elif nr==96:e["struct"]=blob(tid,a[0],16) + elif nr==63:e["struct"]=blob(tid,a[0],390) + elif nr==99:e["struct"]=blob(tid,a[0],112) + elif nr in (97,302):e["struct"]=blob(tid,a[1] if nr==97 else a[3],16) if (nr==97 or a[3]) else None + elif nr==98:e["struct"]=blob(tid,a[1],144) + elif nr==204:e["struct"]=blob(tid,a[2],ret) + elif nr==115:e["struct"]=blob(tid,a[1],ret*4) if a[1] else None + elif nr==157: + if a[0]==16:e["thread_name"]=blob(tid,a[1],16) + elif a[0]==40:e["struct"]=blob(tid,a[1],8) + elif a[0] in (2,37):e["struct"]=blob(tid,a[1],4) + elif nr==16: + if a[1] in (0x5413,0x5401,0x541b):e["struct"]=blob(tid,a[2],{0x5413:8,0x5401:36,0x541b:4}[a[1]]) + else:e["unsupported_payload"]=True + elif nr in (7,271):e["struct"]=blob(tid,a[0],a[1]*8) + elif nr in (232,281,441):e["struct"]=blob(tid,a[1],ret*12) + elif nr in (43,51,52,288) and a[1] and a[2]: + size=memory(tid,a[2],4) + e["sockaddr"]=blob(tid,a[1],struct.unpack("I",size)[0]) if size else {"unreadable_socklen":a[2]} + elif nr==55: + size=memory(tid,a[4],4) + e["option"]=blob(tid,a[3],struct.unpack("I",size)[0]) if size else {"unreadable_socklen":a[4]} + elif nr==53:e["fds"]=blob(tid,a[3],8) + elif nr in (22,293):e["fds"]=blob(tid,a[0],8) + elif nr in (47,299,307):e["unsupported_payload"]=True + if e["name"]=="UNKNOWN" or e.get("unsupported_payload"):e["complete"]=False + else:e["complete"]=True + out.write(json.dumps(e,separators=(",",":"))+"\n") + elif event == 0 and sig != signal.SIGSTOP: + out.write(json.dumps({"kind":"signal","tid":tid,"signal":sig,"ir":ir(active[tid]["perf"])})+"\n") + ptrace(SYSCALL,tid,0,sig);continue + ptrace(SYSCALL,tid) + out.write(json.dumps({"kind":"summary","main_rc":main_rc,"memory_failures":failures})+"\n") + out.close() + return main_rc if main_rc is not None else 1 +if __name__=="__main__": + sys.exit(main()) + From 44b66df8313a30a1b5a72c6128512eef62aa5811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 6 Nov 2026 19:20:48 +0000 Subject: [PATCH 2/9] chore: verify snapshot boundaries and census instruction accounting --- scripts/snap_inputs/account.py | 73 ++++++++++++++++++++ scripts/snap_inputs/sites.py | 47 +++++++++++++ scripts/snap_inputs/trace.py | 122 +++++++++++++++++++++++++++++++-- 3 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 scripts/snap_inputs/account.py create mode 100644 scripts/snap_inputs/sites.py diff --git a/scripts/snap_inputs/account.py b/scripts/snap_inputs/account.py new file mode 100644 index 00000000000..8c7b3eb48fa --- /dev/null +++ b/scripts/snap_inputs/account.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Join runtime and native counters, reconstruct the census's exact Ir charges.""" +import bisect,collections,csv,json,struct,sys +from pathlib import Path + +def load(report,native): + events=list(csv.DictReader(open(str(report)+".inputs.tsv"),delimiter="\t")) + for e in events: + e["seq"]=int(e["seq"]);e["ir"]=int(e["ir"]);e["init"]=int(e["init"]) + e["value"]=bytes.fromhex(e["value_hex"]).decode("utf-8") + e["stack"]=[tuple(map(int,p.split(":"))) for p in e["stack_parts"].split(",") if p] + raw=[json.loads(x) for x in open(native)] + opens=[x for x in raw if x.get("nr")==298 and x.get("ret",-1)>=0] + fdset={x["ret"] for x in opens} + reads=[x for x in raw if x.get("nr")==0 and x["args"][0] in fdset and x.get("ret")==8 and isinstance(x.get("bytes"),str)] + groups=collections.defaultdict(list) + for r in reads: + r["runtime_ir"]=struct.unpack("=previous,(stamp,previous,op) + sums[owner]+=stamp-previous;previous=stamp + actual={} + for line in open(report): + if line.startswith("#"):continue + row=line.rstrip("\n").split("\t") + actual[int(row[0])]=int(row[12]) + mismatches={i:(actual.get(i,0),sums.get(i,0)) for i in set(actual)|set(sums) if actual.get(i,0)!=sums.get(i,0)} + return events,raw,{"tid":tid,"fd":fd,"offset":offset,"initial":initial,"charges":charges, + "actual":actual,"reconstructed":dict(sums),"mismatches":mismatches, + "real_ids":sorted({e["init"] for e in events if e["op"]=="init_enter"})} + +def main(): + report=Path(sys.argv[1]);native=Path(sys.argv[2]);out=Path(sys.argv[3]) + events,raw,account=load(report,native) + out.write_text(json.dumps(account,indent=2)+"\n") + print("counter_offset",account["offset"],"charges",len(account["charges"]), + "exact_census_accounting",not account["mismatches"]) + if account["mismatches"]:print("mismatches",account["mismatches"]) + else: + ids=set(account["real_ids"]) + total=sum(v for i,v in account["actual"].items() if i in ids) + print("real_init_Ir",total,"real_inits",len(ids)) + return bool(account["mismatches"]) +if __name__=="__main__":sys.exit(main()) + diff --git a/scripts/snap_inputs/sites.py b/scripts/snap_inputs/sites.py new file mode 100644 index 00000000000..09c0200b815 --- /dev/null +++ b/scripts/snap_inputs/sites.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Facts ledger identities from source-span site records, never count verdict rows.""" +import collections,json,os,sys +from pathlib import Path +def canon(p): + return os.path.realpath(p) if p.startswith("/") else p +def ledger(path): + sites=collections.defaultdict(set); verdicts=collections.defaultdict(list) + for line in open(path,errors="strict"): + if not line.startswith("D {"):continue + d=json.loads(line[2:]) + if d.get("record")=="site" and "key" in d and d.get("kind") in ("property","element","value-call","new"): + call=d["kind"] in ("value-call","new") + key=(d.get("unit"),canon(d["file"]),d["start"],d["key"],call) + sites[key].add((canon(d["file"]),d["start"],d["end"],d["kind"],d["key"])) + elif d.get("record")=="verdict": + key=(d.get("unit"),canon(d["file"]),d["start"],d["key"],d["call"]) + verdicts[key].append(not d["verdict"].startswith("No")) + proved=set();missing=[];mixed=[] + for key,vs in verdicts.items(): + if any(vs) and not all(vs):mixed.append(key) + if all(vs): + ids=sites.get(key) + if not ids:missing.append(key) + else:proved.update(ids) + return proved,{"verdict_rows":sum(map(len,verdicts.values())),"groups":len(verdicts), + "mixed":mixed,"unmatched_proven":missing} +def main(): + root=Path(sys.argv[1]); out=Path(sys.argv[2]);out.mkdir(exist_ok=True) + names=["hello","commander_help","commander","fastify_inject","awfy_richards","binary_trees"] + summary=[] + for name in names: + a,da=ledger(root/f"{name}.main.facts.dump");b,db=ledger(root/f"{name}.head.facts.dump") + lost=sorted(a-b);gained=sorted(b-a) + (out/f"{name}.main.json").write_text(json.dumps(sorted(a),indent=2)+"\n") + (out/f"{name}.head.json").write_text(json.dumps(sorted(b),indent=2)+"\n") + (out/f"{name}.lost.json").write_text(json.dumps(lost,indent=2)+"\n") + (out/f"{name}.gained.json").write_text(json.dumps(gained,indent=2)+"\n") + (out/f"{name}.diagnostics.json").write_text(json.dumps({"main":da,"head":db},indent=2)+"\n") + complete=not(da["unmatched_proven"] or db["unmatched_proven"] or da["mixed"] or db["mixed"]) + summary.append((name,len(a),len(b),len(lost),len(gained),complete)) + print(*summary[-1],sep="\t") + if not complete:print("INCOMPLETE",name,da,db) + (out/"summary.tsv").write_text("witness\tmain\thead\tlost\tgained\tcomplete\n"+"".join("\t".join(map(str,r))+"\n" for r in summary)) + return int(any(not r[-1] or r[3] for r in summary)) +if __name__=="__main__":sys.exit(main()) + diff --git a/scripts/snap_inputs/trace.py b/scripts/snap_inputs/trace.py index 797037304e7..02009015528 100644 --- a/scripts/snap_inputs/trace.py +++ b/scripts/snap_inputs/trace.py @@ -1,9 +1,16 @@ #!/usr/bin/env python3 -"""Lossless syscall evidence plus a per-thread user instruction counter. -Unknown syscalls stay explicit and invalidate input-completeness claims. -This is a diagnostic companion, never part of a produced executable. +"""Native input values, exact per-thread instruction positions and module returns. + +Only instrumented executables exposing js_snap_entry_body get return probes. +A guarded-evaluation return is recorded after its registry commit, not at +the earlier census callback exit. Runtime perf reads calibrate the two +counter domains exactly; account.py checks every census Ir charge. +Unknown syscalls and unreadable buffers remain explicit, never silently +certified. Crypto entry probes audit otherwise unhooked RNG providers; +their names are evidence, never an image-admission rule. +This diagnostic companion is never linked into a produced executable. """ -import ctypes as C, errno, json, os, signal, struct, sys +import ctypes as C, errno, json, os, signal, struct, sys, subprocess, hashlib from pathlib import Path L=C.CDLL(None,use_errno=True) L.ptrace.restype=C.c_long @@ -34,8 +41,9 @@ 436:"close_range",439:"faccessat2"} REP={3,8,9,10,11,12,13,14,15,24,25,28,32,33,60,72,73,158,186,202,218,231,233,234,273,291,292,293,298,334,436} def ptrace(req,pid,addr=0,data=0): + C.set_errno(0) r=L.ptrace(req,pid,C.c_void_p(addr),C.c_void_p(data)) - if r==-1: + if r==-1 and C.get_errno(): e=C.get_errno() raise OSError(e,os.strerror(e)) return r @@ -62,6 +70,7 @@ def main(): active={pid:{"perf":counter(pid),"entry":None,"mem":None,"diagnostic_fds":set()}} sequence=0; main_rc=None; failures=[] out=open(output,"w") + out.write(json.dumps({"kind":"collector","sha256":hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),"command":command})+"\n") def memory(tid,addr,n): if n==0:return b"" if not addr:return None @@ -100,6 +109,94 @@ def iov(tid,addr,count,returned): size=min(length,max(0,returned)) values.append(blob(tid,base,size));returned-=size return values + breakpoints={}; returns={}; audit_names={}; probe_number=0 + def message(tid,address,returned): + header=memory(tid,address,56) + if header is None:return {"unreadable_msghdr":address} + name,nlen,vec,count,control,clen,flags=struct.unpack("QI4xQQQQi4x",header) + return {"name":blob(tid,name,nlen) if name and nlen else "", + "iov":iov(tid,vec,count,returned),"control":blob(tid,control,clen) if control and clen else "", + "flags":flags} + def put_breakpoint(tid,address,kind): + if address in breakpoints:return + word=ptrace(1,tid,address)&((1<<64)-1) + ptrace(4,tid,address,(word&~255)|0xcc) + breakpoints[address]=(word,kind) + def install_probes(tid): + # Probe a sealed runtime ABI, not a module-name inventory. + exe=os.readlink(f"/proc/{tid}/exe") + symbols={}; audit={} + result=subprocess.run(["nm","-n","--defined-only",exe],capture_output=True,text=True) + if result.returncode:return + for line in result.stdout.splitlines(): + fields=line.split() + if len(fields)==3: + if fields[2].startswith(("js_crypto_","js_webcrypto_")): + audit.setdefault(int(fields[0],16),[]).append(fields[2]) + if fields[2] in ("js_snap_entry_body","js_run_module_init_guarded"): + symbols[fields[2]]=int(fields[0],16) + if not all(k in symbols for k in ("js_snap_entry_body","js_run_module_init_guarded")):return + with open(exe,"rb") as f:header=f.read(20) + bias=0 + if struct.unpack_from(" Date: Fri, 6 Nov 2026 19:48:50 +0000 Subject: [PATCH 3/9] chore: retain input coverage and replay counterexamples --- scripts/snap_inputs/README.md | 36 +++++++++++++ scripts/snap_inputs/check.py | 43 ++++++++++++++++ .../snap_inputs/fixtures/allinputs/inputs.ts | 19 +++++++ .../snap_inputs/fixtures/allinputs/main.ts | 3 ++ .../fixtures/allinputs/package.json | 1 + scripts/snap_inputs/fixtures/net/main.ts | 3 ++ scripts/snap_inputs/fixtures/net/network.ts | 6 +++ scripts/snap_inputs/fixtures/r1/leaf.ts | 1 + scripts/snap_inputs/fixtures/r1/main.ts | 3 ++ scripts/snap_inputs/fixtures/r1/parent.ts | 2 + scripts/snap_inputs/fixtures/r2/leaf.ts | 1 + scripts/snap_inputs/fixtures/r2/main.ts | 3 ++ scripts/snap_inputs/fixtures/r2/parent.ts | 3 ++ scripts/snap_inputs/fixtures/replay/main.ts | 15 ++++++ scripts/snap_inputs/fixtures/stop/main.ts | 4 ++ scripts/snap_inputs/fixtures/stop/pid.ts | 1 + scripts/snap_inputs/trace.py | 51 +++++++++++++++++-- 17 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 scripts/snap_inputs/README.md create mode 100644 scripts/snap_inputs/check.py create mode 100644 scripts/snap_inputs/fixtures/allinputs/inputs.ts create mode 100644 scripts/snap_inputs/fixtures/allinputs/main.ts create mode 100644 scripts/snap_inputs/fixtures/allinputs/package.json create mode 100644 scripts/snap_inputs/fixtures/net/main.ts create mode 100644 scripts/snap_inputs/fixtures/net/network.ts create mode 100644 scripts/snap_inputs/fixtures/r1/leaf.ts create mode 100644 scripts/snap_inputs/fixtures/r1/main.ts create mode 100644 scripts/snap_inputs/fixtures/r1/parent.ts create mode 100644 scripts/snap_inputs/fixtures/r2/leaf.ts create mode 100644 scripts/snap_inputs/fixtures/r2/main.ts create mode 100644 scripts/snap_inputs/fixtures/r2/parent.ts create mode 100644 scripts/snap_inputs/fixtures/replay/main.ts create mode 100644 scripts/snap_inputs/fixtures/stop/main.ts create mode 100644 scripts/snap_inputs/fixtures/stop/pid.ts diff --git a/scripts/snap_inputs/README.md b/scripts/snap_inputs/README.md new file mode 100644 index 00000000000..3db97cecbbb --- /dev/null +++ b/scripts/snap_inputs/README.md @@ -0,0 +1,36 @@ +# Instrumented snapshot input evidence + +trace.py OUTPUT -- setarch -R INSTRUMENTED-BINARY ... records native inputs +and successful once-guarded module returns. It requires Linux x86-64, ptrace, +hardware perf counters and an executable with the census ABI and symbols. +It never links into a production executable. PERRY_SNAP_CENSUS=REPORT +also writes the ordered semantic events to REPORT.inputs.tsv. + +account.py REPORT OUTPUT ACCOUNT.json joins the counter domains and checks +every census instruction charge, rather than adding whole modules in entry order. +Here OUTPUT is the native JSONL from trace.py. Its instruction positions include +the recorder and tracer probes; they are not production instruction counts. + +sites.py reconstructs the identity ledger from final facts dump site records +joined to their proven verdicts. Its usage is printed by an argument error. + +Fixtures are diagnostic subjects. Keep compiler and runtime archives pinned +separately for the base and changed arm, compile each fixture's main.ts, then run +through the recorder to ROOT/fx/NAME/head/{report,native.jsonl,stdout}. +python3 check.py ROOT checks allinputs, r1 and r2. On the base, requiring the +ordered TSV must fail. r1 includes a dependency-phase parent. r2 writes an +outliving cell before entering a child. net exercises socket inputs. +stop is fresh normally and fails with SNAP21_FORCE_IMAGED_PID=1. + +replay is a counterexample component model, not an image loader. Normally it +passes. SNAP21_UNSAFE_REPLAY=R1 repeats a body effect when a body-started frame is +misclassified as a dependency driver. SNAP21_UNSAFE_REPLAY=R2 repeats a persistent +write. Both must exit 1 while the child's once guard prevents a second child run. +These reds do not establish end-to-end snapshot correctness. That requires a +real image loader, relocation and guard-mismatch tests in a later phase. + +The first changed tracked word is conservative evidence, not a proof of semantic +writes or their absence: representation writes, non-GC native state and local +allocations must be audited separately. A decoder's "representation" annotation +likewise cannot admit a native resource. Unknown input or incomplete payload is +a STOP; module names and TypeScript types never grant eligibility. diff --git a/scripts/snap_inputs/check.py b/scripts/snap_inputs/check.py new file mode 100644 index 00000000000..4c5a0c227e3 --- /dev/null +++ b/scripts/snap_inputs/check.py @@ -0,0 +1,43 @@ +import csv,json,collections,sys +from pathlib import Path +S=Path(sys.argv[1]) +for f in ['allinputs','r1','r2']: + d=S/'fx'/f/'head' + r=list(csv.DictReader(open(d/'report.inputs.tsv'),delimiter='\t')) + assert all(int(a['seq'])0 for a in r) + n=[json.loads(x) for x in open(d/'native.jsonl')] + assert n[-1]['kind']=='summary' and n[-1]['main_rc']==0,n[-1] + assert not n[-1]['memory_failures'],n[-1] + unknown=[(x.get('nr'),x.get('name'),x.get('args')) for x in n if x.get('complete') is False] + assert not unknown,unknown + markers={int(x['runtime_seq']) for x in n if x['kind']=='runtime_marker'} + ordinary={int(x['seq']) for x in r if x['op']!='first_changed_tracked_word'} + assert ordinary<=markers,(ordinary-markers) + ops=collections.Counter(x['op'] for x in r) + assert any(x["kind"]=="guard_enter" for x in n),"guard entry probe never ran" + assert any(x["kind"]=="guard_complete" for x in n),"completed-module boundary probe never ran" + print(f,'events',len(r),'markers',len(markers),'native',len(n),'complete=True') + if f=='allinputs': + required=['initial_environment','getenv','env_get','env_has','env_block','argv','cwd','locale.navigator', + 'locale.time_zone','tty.isatty','ids.uid','ids.gid','ids.pid','ids.ppid','clock.wall_millis', + 'clock.performance','clock.hrtime_bigint','random.Math','random.crypto.bytes','random.crypto.uuid', + 'crypto.random.bytes','stack.capture','stack.payload','stack.rendered','native.clock_gettime'] + assert not(set(required)-ops.keys()),set(required)-ops.keys() + assert any(x.get('name') in ('read','pread64') and x.get('bytes') for x in n) + assert any(x.get('name')=='getrandom' and x.get('bytes') for x in n) + assert any(x.get('is_error') for x in n) + print('all required scalar/byte host classes observed with values') + if f=='r1': + inputs=[x for x in r if x['op']=='env_get'] + assert inputs + assert any(any(int(p.split(':')[1])==1 for p in x['stack_parts'].split(',') if p) for x in inputs) + print('R1 parent dependency-phase flag observed before child input') + if f=='r2': + first_write=[x for x in r if x['op']=='first_changed_tracked_word'] + enters=[x for x in r if x['op']=='init_enter'] + assert any(int(w['seq']) Date: Fri, 6 Nov 2026 19:56:51 +0000 Subject: [PATCH 4/9] chore: preserve mapped-file input observations --- scripts/snap_inputs/trace.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/scripts/snap_inputs/trace.py b/scripts/snap_inputs/trace.py index 0abf9fa2b47..8dfb7f2c694 100644 --- a/scripts/snap_inputs/trace.py +++ b/scripts/snap_inputs/trace.py @@ -245,7 +245,7 @@ def handle_probe(tid): entry["path"]=string(tid,a[0]); argv=[]; index=0 while True: item=memory(tid,a[1]+8*index,8) - if item is None:break + if item is None:entry["unsupported_payload"]=True;break ptr=struct.unpack("Q",item)[0] if not ptr:break argv.append(string(tid,ptr));index+=1 @@ -253,7 +253,7 @@ def handle_probe(tid): envp=[];index=0 while True: item=memory(tid,a[2]+8*index,8) - if item is None:break + if item is None:entry["unsupported_payload"]=True;break pointer=struct.unpack("Q",item)[0] if not pointer:break envp.append(string(tid,pointer));index+=1 @@ -276,6 +276,24 @@ def handle_probe(tid): a=e["args"];nr=e["nr"] if ret>=0: if nr==298:state["diagnostic_fds"].add(ret) + if nr==9 and C.c_int(a[4]).value>=0: + # A file-backed mapping is an input/resource boundary even if its + # contents are first used later. Capture this observation; subsequent + # concurrently changed mapped bytes cannot be certified by a syscall + # tracer and therefore the mapping is a STOP. + try: + mapped_fd=os.open(f"/proc/{tid}/fd/{a[4]}",os.O_RDONLY) + try: + st=os.fstat(mapped_fd);length=min(a[1],max(0,st.st_size-a[5])) + data=os.pread(mapped_fd,length,a[5]) + e["file_mapping"]={"file_size":st.st_size,"offset":a[5],"length":a[1], + "observed_file_bytes":data.hex(),"complete":len(data)==length, + "note":"file observation at mmap; concurrent later mapped changes require memory instrumentation"} + if len(data)!=length:e["complete"]=False + finally:os.close(mapped_fd) + except OSError as error: + e["file_mapping"]={"error":error.errno};e["complete"]=False + e["policy"]="STOP" if nr in (0,17,45,78,217):e["bytes"]=blob(tid,a[1],ret) elif nr==19:e["bytes"]=iov(tid,a[1],a[2],ret) elif nr==318:e["bytes"]=blob(tid,a[0],ret) @@ -340,7 +358,11 @@ def handle_probe(tid): if ret==-errno.EINTR and nr in (35,230): address=a[1] if nr==35 else a[3] e["remaining_time"]=blob(tid,address,16) if address else None - if e["name"]=="UNKNOWN" or e.get("unsupported_payload"):e["complete"]=False + def unreadable(value): + if isinstance(value,dict):return any(str(k).startswith("unreadable") or unreadable(v) for k,v in value.items()) + if isinstance(value,list):return any(unreadable(v) for v in value) + return False + if e["name"]=="UNKNOWN" or e.get("unsupported_payload") or e.get("complete") is False or unreadable(e):e["complete"]=False else:e["complete"]=True out.write(json.dumps(e,separators=(",",":"))+"\n") elif event == 0 and sig==signal.SIGTRAP and handle_probe(tid): From ea632bcfc007780809a326b06ddcbd25cc55d261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 7 Nov 2026 09:02:34 +0000 Subject: [PATCH 5/9] fix: preserve production bytes around census crypto hooks --- crates/perry-runtime/src/snap_census.rs | 10 +++++ crates/perry-stdlib/build.rs | 9 ----- crates/perry-stdlib/src/crypto/random.rs | 51 +++++++++++------------- 3 files changed, 33 insertions(+), 37 deletions(-) delete mode 100644 crates/perry-stdlib/build.rs diff --git a/crates/perry-runtime/src/snap_census.rs b/crates/perry-runtime/src/snap_census.rs index b23deca0efe..55b8137126a 100644 --- a/crates/perry-runtime/src/snap_census.rs +++ b/crates/perry-runtime/src/snap_census.rs @@ -718,3 +718,13 @@ pub fn record_input_value(operation: &str, value: &T) { runner::input_event(operation, value.input_value()); } } + +/// Expand a stdlib observation only in the runtime's instruments cohort. +/// The empty production expansion needs no new stdlib build-script dependency. +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +#[macro_export] +macro_rules! census_inputs { ($($code:tt)*) => {{ $($code)* }}; } +#[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] +#[macro_export] +macro_rules! census_inputs { ($($code:tt)*) => {}; } + diff --git a/crates/perry-stdlib/build.rs b/crates/perry-stdlib/build.rs deleted file mode 100644 index 1bde525f77f..00000000000 --- a/crates/perry-stdlib/build.rs +++ /dev/null @@ -1,9 +0,0 @@ -// Match the runtime's instruments cohort so builtin input hooks cannot -// silently disappear from the instrumented archive. -fn main() { - println!("cargo:rustc-check-cfg=cfg(perry_gc_instruments)"); - println!("cargo:rerun-if-env-changed=PERRY_RUNTIME_INSTRUMENTS"); - if std::env::var("PERRY_RUNTIME_INSTRUMENTS").is_ok_and(|v| v == "1") { - println!("cargo:rustc-cfg=perry_gc_instruments"); - } -} diff --git a/crates/perry-stdlib/src/crypto/random.rs b/crates/perry-stdlib/src/crypto/random.rs index 076a1e4bcfb..9df3c59a81b 100644 --- a/crates/perry-stdlib/src/crypto/random.rs +++ b/crates/perry-stdlib/src/crypto/random.rs @@ -55,8 +55,7 @@ fn validate_random_bytes_size(size: f64) -> usize { pub extern "C" fn js_crypto_random_bytes_buffer( size: f64, ) -> *mut perry_runtime::buffer::BufferHeader { - #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] - { + perry_runtime::census_inputs! { return perry_runtime::snap_census::input_call( "random.crypto.bytes", &format!("{:?}", (size,)), @@ -71,11 +70,11 @@ pub extern "C" fn js_crypto_random_bytes_buffer( unsafe { rand::rng() .fill_bytes(std::slice::from_raw_parts_mut(pin.as_mut_ptr(), pin.len())); - #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + perry_runtime::census_inputs! { perry_runtime::snap_census::record_input_value( "crypto.random.bytes", &std::slice::from_raw_parts(pin.as_ptr(), pin.len()).to_vec(), - ); + ); } } JSValue::from_bits(value.to_bits()) .as_pointer::() @@ -93,11 +92,11 @@ pub extern "C" fn js_crypto_random_bytes_buffer( ); unsafe { rand::rng().fill_bytes(std::slice::from_raw_parts_mut(pin.as_mut_ptr(), pin.len())); - #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + perry_runtime::census_inputs! { perry_runtime::snap_census::record_input_value( "crypto.random.bytes", &std::slice::from_raw_parts(pin.as_ptr(), pin.len()).to_vec(), - ); + ); } } JSValue::from_bits(value.to_bits()) .as_pointer::() @@ -149,8 +148,7 @@ pub unsafe extern "C" fn js_crypto_random_bytes_async(size: f64, callback_bits: /// crypto.randomBytes(size).toString('hex') -> string #[no_mangle] pub extern "C" fn js_crypto_random_bytes_hex(size: f64) -> *mut StringHeader { - #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] - { + perry_runtime::census_inputs! { return perry_runtime::snap_census::input_call( "random.crypto.hex", &format!("{:?}", (size,)), @@ -160,8 +158,8 @@ pub extern "C" fn js_crypto_random_bytes_hex(size: f64) -> *mut StringHeader { let mut bytes = vec![0u8; size]; rand::rng().fill_bytes(&mut bytes); - #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] - perry_runtime::snap_census::record_input_value("crypto.random.bytes", &bytes); + perry_runtime::census_inputs! { + perry_runtime::snap_census::record_input_value("crypto.random.bytes", &bytes); } let hex_str = perry_hex::encode(&bytes); js_string_from_bytes(hex_str.as_ptr(), hex_str.len() as u32) @@ -174,8 +172,8 @@ pub extern "C" fn js_crypto_random_bytes_hex(size: f64) -> *mut StringHeader { let mut bytes = vec![0u8; size]; rand::rng().fill_bytes(&mut bytes); - #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] - perry_runtime::snap_census::record_input_value("crypto.random.bytes", &bytes); + perry_runtime::census_inputs! { + perry_runtime::snap_census::record_input_value("crypto.random.bytes", &bytes); } let hex_str = perry_hex::encode(&bytes); js_string_from_bytes(hex_str.as_ptr(), hex_str.len() as u32) @@ -189,8 +187,7 @@ pub extern "C" fn js_crypto_random_bytes_hex(size: f64) -> *mut StringHeader { /// `{ disableEntropyCache: true }`, which draws fresh OS entropy. #[no_mangle] pub unsafe extern "C" fn js_crypto_random_uuid(options_bits: f64) -> *mut StringHeader { - #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] - { + perry_runtime::census_inputs! { return perry_runtime::snap_census::input_call( "random.crypto.uuid", &format!("{:?}", (options_bits,)), @@ -204,11 +201,11 @@ pub unsafe extern "C" fn js_crypto_random_uuid(options_bits: f64) -> *mut String // The bytes directly: `as_str` would re-validate 36 known-ASCII bytes as // UTF-8 on every UUID only for the string constructor to copy them. let uuid_bytes = uuid.as_bytes(); - #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + perry_runtime::census_inputs! { perry_runtime::snap_census::record_input_value( "crypto.random.uuid", &uuid_bytes.to_vec(), - ); + ); } js_string_from_bytes(uuid_bytes.as_ptr(), uuid_bytes.len() as u32) }, ); @@ -223,8 +220,8 @@ pub unsafe extern "C" fn js_crypto_random_uuid(options_bits: f64) -> *mut String // The bytes directly: `as_str` would re-validate 36 known-ASCII bytes as // UTF-8 on every UUID only for the string constructor to copy them. let uuid_bytes = uuid.as_bytes(); - #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] - perry_runtime::snap_census::record_input_value("crypto.random.uuid", &uuid_bytes.to_vec()); + perry_runtime::census_inputs! { + perry_runtime::snap_census::record_input_value("crypto.random.uuid", &uuid_bytes.to_vec()); } js_string_from_bytes(uuid_bytes.as_ptr(), uuid_bytes.len() as u32) } @@ -236,8 +233,7 @@ pub unsafe extern "C" fn js_crypto_random_uuid(options_bits: f64) -> *mut String /// accepted for shape parity but does not change the generated value. #[no_mangle] pub extern "C" fn js_crypto_random_uuidv7() -> *mut StringHeader { - #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] - { + perry_runtime::census_inputs! { return perry_runtime::snap_census::input_call( "random.crypto.uuidv7", &format!("{:?}", ()), @@ -245,11 +241,11 @@ pub extern "C" fn js_crypto_random_uuidv7() -> *mut StringHeader { perry_runtime::effect!(host_read, random); let uuid = perry_uuid::v7(); let uuid_bytes = uuid.as_bytes(); - #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + perry_runtime::census_inputs! { perry_runtime::snap_census::record_input_value( "crypto.random.uuid", &uuid_bytes.to_vec(), - ); + ); } js_string_from_bytes(uuid_bytes.as_ptr(), uuid_bytes.len() as u32) }, ); @@ -258,8 +254,8 @@ pub extern "C" fn js_crypto_random_uuidv7() -> *mut StringHeader { perry_runtime::effect!(host_read, random); let uuid = perry_uuid::v7(); let uuid_bytes = uuid.as_bytes(); - #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] - perry_runtime::snap_census::record_input_value("crypto.random.uuid", &uuid_bytes.to_vec()); + perry_runtime::census_inputs! { + perry_runtime::snap_census::record_input_value("crypto.random.uuid", &uuid_bytes.to_vec()); } js_string_from_bytes(uuid_bytes.as_ptr(), uuid_bytes.len() as u32) } @@ -356,8 +352,7 @@ fn validate_random_int_arg(value: f64, arg_name: &str) -> i64 { /// Returns a uniformly distributed integer in `[min, max)`. #[no_mangle] pub extern "C" fn js_crypto_random_int(min_bits: f64, max_bits: f64) -> f64 { - #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] - { + perry_runtime::census_inputs! { return perry_runtime::snap_census::input_call( "random.crypto.int", &format!("{:?}", (min_bits, max_bits,)), @@ -781,11 +776,11 @@ pub extern "C" fn js_crypto_random_fill_sync( perry_runtime::buffer::bytes::no_gc(|scope| unsafe { let data = perry_runtime::buffer::bytes::bytes_mut(value, scope)?; rand::rng().fill_bytes(&mut data[start..start + count]); - #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + perry_runtime::census_inputs! { perry_runtime::snap_census::record_input_value( "crypto.random.fill", &data[start..start + count].to_vec(), - ); + ); } #[cfg(test)] if b2c_sabotage("random_fill_range") && count > 0 { // Make the wrong-window witness deterministic rather than depend From 8da58ff7a80d23ae929738a94b731cab7b644e43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 7 Nov 2026 09:02:35 +0000 Subject: [PATCH 6/9] chore: retain native census shape publication diagnostics --- crates/perry-runtime/src/object/shapes.rs | 12 +++- .../perry-runtime/src/object/shapes_snap22.rs | 57 +++++++++++++++++++ .../src/object/shapes_store/slab.rs | 7 +++ .../perry-runtime/src/snap_census/runner.rs | 1 + 4 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 crates/perry-runtime/src/object/shapes_snap22.rs diff --git a/crates/perry-runtime/src/object/shapes.rs b/crates/perry-runtime/src/object/shapes.rs index 6d6ba51aa20..6b29a115abd 100644 --- a/crates/perry-runtime/src/object/shapes.rs +++ b/crates/perry-runtime/src/object/shapes.rs @@ -2456,7 +2456,11 @@ fn shape_descriptor_mint_fresh( // complete descriptor. // SAFETY: no slab reference is held; `slab()` above went out of scope. let id = unsafe { table.slab_mut().publish_dynamic(id, record) } - .map_err(|_| ShapeDescriptorError::IdExhausted)? + .map_err(|_error| { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + snap22::snapshot("publish_error", _error as u64 + 1); + ShapeDescriptorError::IdExhausted + })? .word .0; // `id` was just handed out by `alloc_shape_id`, which never reuses a @@ -2730,6 +2734,8 @@ pub(crate) unsafe fn receiver_extra_summary(_obj: *const crate::object::ObjectHe #[optimize(size)] #[inline(never)] fn shape_id_exhausted_abort() -> ! { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + snap22::snapshot("abort", 0); eprintln!("Perry ShapeId space exhausted; refusing to publish an untracked object shape"); std::process::abort() } @@ -7405,3 +7411,7 @@ pub(crate) fn recompute_record_handle_carriers() { } } } + +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +#[path = "shapes_snap22.rs"] +pub(crate) mod snap22; diff --git a/crates/perry-runtime/src/object/shapes_snap22.rs b/crates/perry-runtime/src/object/shapes_snap22.rs new file mode 100644 index 00000000000..2981eb289ab --- /dev/null +++ b/crates/perry-runtime/src/object/shapes_snap22.rs @@ -0,0 +1,57 @@ +//! Native-only diagnostic evidence; no shape authority or allocator behavior changes. +use super::*; +use std::fmt::Write; +struct Line { + bytes: [u8; 2048], + len: usize, +} +impl std::fmt::Write for Line { + fn write_str(&mut self, text: &str) -> std::fmt::Result { + if self.len + text.len() > self.bytes.len() { + return Err(std::fmt::Error); + } + self.bytes[self.len..self.len + text.len()].copy_from_slice(text.as_bytes()); + self.len += text.len(); + Ok(()) + } +} +pub(crate) fn snapshot(label: &str, error: u64) { + let saved_errno = unsafe { *libc::__errno_location() }; + let mut out = Line { + bytes: [0; 2048], + len: 0, + }; + let _ = write!(out, "SNAP22_SHAPES {label} error={error} counters="); + for next in SHAPE_ID_NEXT.iter().chain(EXOTIC_SHAPE_ID_NEXT.iter()) { + let _ = write!( + out, + "{:08x},", + next.load(std::sync::atomic::Ordering::Relaxed) + ); + } + let table = &crate::state::state().shapes; + let slab = table.slab(); + let _ = write!( + out, + " semantic={:x} slab={:?}", + SHAPE_SEMANTIC_NEXT.load(std::sync::atomic::Ordering::Relaxed), + slab.snap22_counts() + ); + let mut pcs = [0; crate::error::MAX_CAPTURED_FRAMES]; + let n = crate::error::capture_ips(&mut pcs); + let _ = writeln!(out, " pcs={:x?}", &pcs[..n.min(12)]); + unsafe { + let mut at = 0; + while at < out.len { + let n = libc::write(2, out.bytes.as_ptr().add(at).cast(), out.len - at); + if n > 0 { + at += n as usize; + } else if n < 0 && *libc::__errno_location() == libc::EINTR { + continue; + } else { + break; + } + } + *libc::__errno_location() = saved_errno; + } +} diff --git a/crates/perry-runtime/src/object/shapes_store/slab.rs b/crates/perry-runtime/src/object/shapes_store/slab.rs index 0c5534099ef..173ad03ef66 100644 --- a/crates/perry-runtime/src/object/shapes_store/slab.rs +++ b/crates/perry-runtime/src/object/shapes_store/slab.rs @@ -733,3 +733,10 @@ impl ShapeSlab { dir.clear(); } } + +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +impl ShapeSlab { + pub(in super::super) fn snap22_counts(&self) -> (usize, u32, u32, usize) { + (self.len, self.next_row, self.chunk_end, self.region.capacity()) + } +} diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 20f470d80d6..225ce4c1e6d 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -1367,6 +1367,7 @@ pub(super) fn init_enter(addr: usize) { init: id, kind: FrameKind::Init, }); + crate::object::shapes::snap22::snapshot("init_enter", addr as u64); input_record::event(st, "init_enter", format!("{addr:x}")); set_mode_for_top(st); } From 2d104752d42dfba964840808a4f7d9bb7f287e7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 7 Nov 2026 09:13:46 +0000 Subject: [PATCH 7/9] fix: stream census inputs without allocator re-entry --- .../perry-runtime/src/object/shapes_snap22.rs | 1 + crates/perry-runtime/src/snap_census.rs | 19 +- .../src/snap_census/input_record.rs | 407 +++++++++++++----- .../perry-runtime/src/snap_census/runner.rs | 191 +++++--- scripts/snap_inputs/README.md | 26 ++ scripts/snap_inputs/check_abort.py | 19 + scripts/snap_inputs/check_recorder.py | 26 ++ scripts/snap_inputs/fixtures/abort/abort.ts | 4 + scripts/snap_inputs/fixtures/abort/main.ts | 2 + scripts/snap_inputs/fixtures/effect/main.ts | 3 + scripts/snap_inputs/fixtures/pressure/m0.ts | 9 + scripts/snap_inputs/fixtures/pressure/m1.ts | 9 + scripts/snap_inputs/fixtures/pressure/m2.ts | 9 + scripts/snap_inputs/fixtures/pressure/m3.ts | 9 + scripts/snap_inputs/fixtures/pressure/m4.ts | 9 + scripts/snap_inputs/fixtures/pressure/m5.ts | 9 + scripts/snap_inputs/fixtures/pressure/m6.ts | 9 + scripts/snap_inputs/fixtures/pressure/m7.ts | 9 + scripts/snap_inputs/fixtures/pressure/main.ts | 10 + scripts/snap_inputs/trace.py | 16 +- 20 files changed, 622 insertions(+), 174 deletions(-) create mode 100644 scripts/snap_inputs/check_abort.py create mode 100644 scripts/snap_inputs/check_recorder.py create mode 100644 scripts/snap_inputs/fixtures/abort/abort.ts create mode 100644 scripts/snap_inputs/fixtures/abort/main.ts create mode 100644 scripts/snap_inputs/fixtures/effect/main.ts create mode 100644 scripts/snap_inputs/fixtures/pressure/m0.ts create mode 100644 scripts/snap_inputs/fixtures/pressure/m1.ts create mode 100644 scripts/snap_inputs/fixtures/pressure/m2.ts create mode 100644 scripts/snap_inputs/fixtures/pressure/m3.ts create mode 100644 scripts/snap_inputs/fixtures/pressure/m4.ts create mode 100644 scripts/snap_inputs/fixtures/pressure/m5.ts create mode 100644 scripts/snap_inputs/fixtures/pressure/m6.ts create mode 100644 scripts/snap_inputs/fixtures/pressure/m7.ts create mode 100644 scripts/snap_inputs/fixtures/pressure/main.ts diff --git a/crates/perry-runtime/src/object/shapes_snap22.rs b/crates/perry-runtime/src/object/shapes_snap22.rs index 2981eb289ab..9ea0082e19d 100644 --- a/crates/perry-runtime/src/object/shapes_snap22.rs +++ b/crates/perry-runtime/src/object/shapes_snap22.rs @@ -17,6 +17,7 @@ impl std::fmt::Write for Line { } pub(crate) fn snapshot(label: &str, error: u64) { let saved_errno = unsafe { *libc::__errno_location() }; + let _scope = crate::snap_census::record_scope(); let mut out = Line { bytes: [0; 2048], len: 0, diff --git a/crates/perry-runtime/src/snap_census.rs b/crates/perry-runtime/src/snap_census.rs index 55b8137126a..3c55a6d7c73 100644 --- a/crates/perry-runtime/src/snap_census.rs +++ b/crates/perry-runtime/src/snap_census.rs @@ -688,11 +688,14 @@ pub use runner::input_record::Value as InputValue; #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] pub fn input_call(operation: &str, arguments: &str, call: impl FnOnce() -> T) -> T { - if !runner::on() { + if !runner::inputs_on() || !runner::on_main_thread() { return call(); } let before_errno = unsafe { *libc::__errno_location() }; - let token = runner::input_event(operation, arguments.to_owned()); + let token = { + let _scope = runner::input_record::Scope::enter(); + runner::input_event(operation, arguments.to_owned()) + }; unsafe { *libc::__errno_location() = before_errno; } @@ -700,7 +703,10 @@ pub fn input_call(operation: &str, arguments: &str, call: impl Fn let result = call(); let saved_errno = unsafe { *libc::__errno_location() }; runner::input_scope(-1); - runner::input_event("result", format!("{token}:{}", result.input_value())); + { + let _scope = runner::input_record::Scope::enter(); + runner::input_event("result", format!("{token}:{}", result.input_value())); + } unsafe { *libc::__errno_location() = saved_errno; } @@ -709,12 +715,13 @@ pub fn input_call(operation: &str, arguments: &str, call: impl Fn #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] pub fn runner_on_for_inputs() -> bool { - runner::on() + runner::inputs_on() && runner::on_main_thread() } #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] pub fn record_input_value(operation: &str, value: &T) { - if runner::on() { + if runner::inputs_on() && runner::on_main_thread() { + let _scope = runner::input_record::Scope::enter(); runner::input_event(operation, value.input_value()); } } @@ -728,3 +735,5 @@ macro_rules! census_inputs { ($($code:tt)*) => {{ $($code)* }}; } #[macro_export] macro_rules! census_inputs { ($($code:tt)*) => {}; } +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +pub(crate) fn record_scope() -> impl Drop { runner::input_record::Scope::enter() } diff --git a/crates/perry-runtime/src/snap_census/input_record.rs b/crates/perry-runtime/src/snap_census/input_record.rs index b770f837d81..1f3e97a974f 100644 --- a/crates/perry-runtime/src/snap_census/input_record.rs +++ b/crates/perry-runtime/src/snap_census/input_record.rs @@ -1,34 +1,102 @@ -//! Ordered evidence for execution-prefix eligibility. This module is only -//! compiled with the Linux instruments runner; it never decides production facts. +//! Ordered execution-prefix evidence, streamed through native storage. use super::{read_ir, top_init, State}; use std::fmt::Write as _; -#[derive(Default)] pub(super) struct Record { pub seq: u64, - pub events: Vec, pub writes: Vec>, pub watching: bool, + pub watch_enabled: bool, pub call_depth: u32, pub allocated_bytes: u64, + fd: i32, } -pub(super) struct Write { - pub seq: u64, - pub ir: u64, - pub word: usize, - pub pc: usize, +impl Default for Record { + fn default() -> Self { + Self { + seq: 0, + writes: Vec::new(), + watching: false, + watch_enabled: true, + call_depth: 0, + allocated_bytes: 0, + fd: -1, + } + } +} +pub(super) struct Write; + +pub(super) fn open(st: &mut State) -> std::io::Result<()> { + use std::os::fd::IntoRawFd; + let saved = unsafe { *libc::__errno_location() }; + let _scope = Scope::enter(); + st.inputs.fd = std::fs::File::create(format!("{}.inputs.tsv", st.out))?.into_raw_fd(); + emit( + st.inputs.fd, + b"seq\tir\tinit\tstack_parts\top\tvalue_hex\tpcs\tallocated_bytes\n", + ); + unsafe { + *libc::__errno_location() = saved; + } + Ok(()) +} + +/// The companion records these intervals as diagnostic execution. The marker +/// and file writes preserve errno and never enter a JS allocator. +pub(crate) fn logger_scope(enter: bool) { + if enter { + super::logging_scope(true); + } + unsafe { + let saved = *libc::__errno_location(); + libc::syscall( + libc::SYS_lseek, + -1 as libc::c_int, + u64::from(enter), + 0x5322 as libc::c_int, + ); + *libc::__errno_location() = saved; + } + if !enter { + super::logging_scope(false); + } } -pub(super) struct Event { - seq: u64, - ir: u64, - allocated_bytes: u64, - init: u32, - stack: Vec<(u32, u32, u8)>, - pcs: [usize; 6], - op: String, - value: String, +pub(crate) struct Scope; +impl Scope { + pub(crate) fn enter() -> Self { + logger_scope(true); + Self + } +} +impl Drop for Scope { + fn drop(&mut self) { + logger_scope(false); + } +} +fn emit(fd: i32, bytes: &[u8]) { + let saved = unsafe { *libc::__errno_location() }; + let mut at = 0; + while at < bytes.len() { + let n = unsafe { libc::write(fd, bytes.as_ptr().add(at).cast(), bytes.len() - at) }; + if n > 0 { + at += n as usize; + } else if n < 0 && unsafe { *libc::__errno_location() } == libc::EINTR { + continue; + } else { + let message = b"SNAP22 input record write failed\n"; + unsafe { + libc::write(2, message.as_ptr().cast(), message.len()); + libc::_exit(92); + } + } + } + unsafe { + *libc::__errno_location() = saved; + } } pub(super) fn event(st: &mut State, op: &str, value: String) -> u64 { + let saved = unsafe { *libc::__errno_location() }; + logger_scope(true); let ir = read_ir(st.perf_fd); let init = top_init(st); st.inputs.seq += 1; @@ -40,28 +108,29 @@ pub(super) fn event(st: &mut State, op: &str, value: String) -> u64 { for (to, from) in six.iter_mut().zip(pcs[..n].iter().skip(1)) { *to = *from; } - let stack = st - .frames - .iter() - .map(|f| { - ( - f.init, - st.inits[f.init as usize].parts as u32, - u8::from(f.kind == super::FrameKind::Job), - ) - }) - .collect(); - st.inputs.events.push(Event { - seq, - ir, - allocated_bytes: st.inputs.allocated_bytes, - init, - stack, - pcs: six, - op: op.to_owned(), - value, - }); - let saved_errno = unsafe { *libc::__errno_location() }; + let mut row = format!("{seq}\t{ir}\t{init}\t"); + for (i, f) in st.frames.iter().enumerate() { + if i != 0 { + row.push(','); + } + let _ = write!( + row, + "{}:{}:{}", + f.init, + st.inits[f.init as usize].parts, + u8::from(f.kind == super::FrameKind::Job) + ); + } + let _ = write!(row, "\t{op}\t{}\t", hex(value.as_bytes())); + for (i, pc) in six.iter().enumerate() { + if i != 0 { + row.push(','); + } + let _ = write!(row, "{pc:x}"); + } + let _ = writeln!(row, "\t{}", st.inputs.allocated_bytes); + emit(st.inputs.fd, row.as_bytes()); + logger_scope(false); unsafe { libc::syscall( libc::SYS_lseek, @@ -69,21 +138,82 @@ pub(super) fn event(st: &mut State, op: &str, value: String) -> u64 { seq as libc::off_t, 0x5321 as libc::c_int, ); - } - unsafe { - *libc::__errno_location() = saved_errno; + *libc::__errno_location() = saved; } seq } pub(super) fn watch(st: &State) -> bool { let id = top_init(st) as usize; - st.inputs.call_depth == 0 + st.inputs.watch_enabled + && st.inputs.call_depth == 0 && id != 0 && st.inits[id].parts >= 2 && st.inputs.writes.get(id).is_some_and(Option::is_none) } -/// Called after the census has excluded bookkeeping and untracked binary -/// words. A signal handler must not allocate or format strings here. + +struct SignalRow { + bytes: [u8; N], + len: usize, +} +impl std::fmt::Write for SignalRow { + fn write_str(&mut self, value: &str) -> std::fmt::Result { + if self.len + value.len() > self.bytes.len() { + return Err(std::fmt::Error); + } + self.bytes[self.len..self.len + value.len()].copy_from_slice(value.as_bytes()); + self.len += value.len(); + Ok(()) + } +} +/// No allocation, formatting heap, JS API or deferred payload is permitted in +/// the fault handler. An oversized frame record fails explicitly, never truncates. +pub(super) fn remember_write(st: &mut State, word: usize, pc: usize) { + let saved = unsafe { *libc::__errno_location() }; + let id = top_init(st) as usize; + st.inputs.seq += 1; + st.inputs.writes[id] = Some(Write); + let ir = read_ir(st.perf_fd); + logger_scope(true); + let mut row = SignalRow { + bytes: [0; 65536], + len: 0, + }; + let result = (|| -> std::fmt::Result { + write!(row, "{}\t{ir}\t{id}\t", st.inputs.seq)?; + for (i, f) in st.frames.iter().enumerate() { + if i != 0 { + row.write_char(',')?; + } + write!( + row, + "{}:{}:{}", + f.init, + st.inits[f.init as usize].parts, + u8::from(f.kind == super::FrameKind::Job) + )?; + } + row.write_str("\tfirst_changed_tracked_word\t")?; + let mut text = SignalRow { + bytes: [0; 16], + len: 0, + }; + write!(text, "{word:x}")?; + for byte in &text.bytes[..text.len] { + write!(row, "{byte:02x}")?; + } + writeln!(row, "\t{pc:x}\t{}", st.inputs.allocated_bytes) + })(); + if result.is_err() { + unsafe { + libc::_exit(93); + } + } + emit(st.inputs.fd, &row.bytes[..row.len]); + logger_scope(false); + unsafe { + *libc::__errno_location() = saved; + } +} pub(super) fn changed_word(st: &mut State, word: usize, pc: usize) { let id = top_init(st) as usize; if !watch(st) { @@ -94,73 +224,123 @@ pub(super) fn changed_word(st: &mut State, word: usize, pc: usize) { || super::object_start(st, word) .and_then(|h| st.born_by.get(h as u64)) .is_some_and(|owner| owner as usize != id); - if !external { - return; + if external { + remember_write(st, word, pc); } +} +pub(super) fn write(_st: &State) -> std::io::Result<()> { + // Rows are unbuffered write-all calls, including the fault-handler rows. + Ok(()) +} + +/// Used by libc callbacks, including callbacks from inside mimalloc. No +/// heap allocation, JS API, backtrace cache, or native allocator is entered. +pub(super) fn native_event(st: &mut State, op: &str, value: std::fmt::Arguments<'_>) { + let saved = unsafe { *libc::__errno_location() }; + let _scope = Scope::enter(); + let ir = read_ir(st.perf_fd); st.inputs.seq += 1; - st.inputs.writes[id] = Some(Write { - seq: st.inputs.seq, - ir: read_ir(st.perf_fd), - word, - pc, - }); + let seq = st.inputs.seq; + let mut text = SignalRow { + bytes: [0; 65536], + len: 0, + }; + if text.write_fmt(value).is_err() { + unsafe { + libc::_exit(93); + } + } + let mut row = SignalRow { + bytes: [0; 131072], + len: 0, + }; + let result = (|| -> std::fmt::Result { + write!(row, "{seq}\t{ir}\t{}\t", top_init(st))?; + for (i, f) in st.frames.iter().enumerate() { + if i != 0 { + row.write_char(',')?; + } + write!( + row, + "{}:{}:{}", + f.init, + st.inits[f.init as usize].parts, + u8::from(f.kind == super::FrameKind::Job) + )?; + } + write!(row, "\t{op}\t")?; + for b in &text.bytes[..text.len] { + write!(row, "{b:02x}")?; + } + writeln!(row, "\t0,0,0,0,0,0\t{}", st.inputs.allocated_bytes) + })(); + if result.is_err() { + unsafe { + libc::_exit(93); + } + } + emit(st.inputs.fd, &row.bytes[..row.len]); + unsafe { + libc::syscall( + libc::SYS_lseek, + -1 as libc::c_int, + seq as libc::off_t, + 0x5321 as libc::c_int, + ); + *libc::__errno_location() = saved; + } } -pub(super) fn write(st: &State) -> std::io::Result<()> { - use std::io::Write as _; - let mut rows: Vec<(u64, String)> = Vec::new(); - let mut out = std::io::BufWriter::new(std::fs::File::create(format!("{}.inputs.tsv", st.out))?); - writeln!( - out, - "seq\tir\tinit\tstack_parts\top\tvalue_hex\tpcs\tallocated_bytes" - )?; - for e in &st.inputs.events { - let stack = e - .stack - .iter() - .map(|(i, p, k)| format!("{i}:{p}:{k}")) - .collect::>() - .join(","); - let pcs = e - .pcs - .iter() - .map(|p| format!("{p:x}")) - .collect::>() - .join(","); - rows.push(( - e.seq, - format!( - "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", - e.seq, - e.ir, - e.init, - stack, - e.op, - hex(e.value.as_bytes()), - pcs, - e.allocated_bytes - ), - )); +pub(super) struct Hex<'a>(pub &'a [u8]); +impl std::fmt::Display for Hex<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for b in self.0 { + write!(f, "{b:02x}")?; + } + Ok(()) } - for (id, hit) in st.inputs.writes.iter().enumerate() { - if let Some(w) = hit { - rows.push(( - w.seq, - format!( - "{}\t{}\t{}\t\tfirst_changed_tracked_word\t{}\t{:x}\t", - w.seq, - w.ir, - id, - hex(format!("{:x}", w.word).as_bytes()), - w.pc - ), - )); +} +pub(super) fn env_rows(st: &State) -> std::io::Result)>> { + use std::io::{BufRead, Error, ErrorKind}; + fn unhex(s: &str) -> std::io::Result> { + if s.len() % 2 != 0 { + return Err(Error::new(ErrorKind::InvalidData, "odd hex")); } + (0..s.len()) + .step_by(2) + .map(|i| { + u8::from_str_radix(&s[i..i + 2], 16) + .map_err(|_| Error::new(ErrorKind::InvalidData, "invalid hex")) + }) + .collect() } - rows.sort_unstable_by_key(|r| r.0); - for (_, line) in rows { - writeln!(out, "{line}")?; + let mut rows = Vec::new(); + for line in + std::io::BufReader::new(std::fs::File::open(format!("{}.inputs.tsv", st.out))?).lines() + { + let line = line?; + let fields: Vec<_> = line.split('\t').collect(); + if fields.get(4) != Some(&"getenv") { + continue; + } + let value = String::from_utf8(unhex(fields[5])?) + .map_err(|_| Error::new(ErrorKind::InvalidData, "bad env row"))?; + let (name, value) = value + .split_once('=') + .ok_or_else(|| Error::new(ErrorKind::InvalidData, "missing env separator"))?; + let value = if value == "unset" { + None + } else { + Some(String::from_utf8_lossy(&unhex(value)?).into_owned()) + }; + rows.push(( + fields[2] + .parse() + .map_err(|_| Error::new(ErrorKind::InvalidData, "bad init"))?, + String::from_utf8_lossy(&unhex(name)?).into_owned(), + value, + )); } - out.flush() + Ok(rows) } pub(super) fn hex(bytes: &[u8]) -> String { let mut out = String::with_capacity(bytes.len() * 2); @@ -256,9 +436,22 @@ impl Value for *mut crate::ArrayHeader { return "null".into(); } let len = unsafe { (**self).length }; + if unsafe { + crate::array::array_has_element_descriptors_resolved(*self) || len > (**self).capacity + } { + return "unsupported_array_payload".into(); + } let mut out = String::from("["); for i in 0..len { - let value = crate::array::js_array_get_f64_unchecked(*self, i).to_bits(); + let mut value = unsafe { + (crate::array::array_elements_ptr(*self) as *const f64) + .add(i as usize) + .read() + .to_bits() + }; + if value == crate::value::TAG_HOLE { + value = 0x7ffc_0000_0000_0001; + } if i != 0 { out.push(','); } diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 225ce4c1e6d..1484e8ed3cb 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -85,6 +85,20 @@ pub(super) fn on() -> bool { ANCHOR.on.load(Relaxed) } +// Const-initialized native TLS needs neither lazy initialization nor allocation. +// It lives outside the generated program's tracked TLS pages. +#[thread_local] +static LOGGING_DEPTH: std::cell::Cell = std::cell::Cell::new(0); + +/// Allocator callbacks must be rejected before formatting or borrowing State. +pub(super) fn inputs_on() -> bool { + on() && LOGGING_DEPTH.get() == 0 +} +pub(super) fn logging_scope(enter: bool) { + let depth = LOGGING_DEPTH.get(); + LOGGING_DEPTH.set(if enter { depth + 1 } else { depth - 1 }); +} + /// Collection is held until the boundary (not in the weights run). pub(super) fn holds_collection() -> bool { on() && !state().noprotect @@ -92,7 +106,7 @@ pub(super) fn holds_collection() -> bool { // Native helper threads are visible in the syscall companion, but must not // borrow the main thread's census buffers. -fn on_main_thread() -> bool { +pub(super) fn on_main_thread() -> bool { let p = ANCHOR.state.load(Relaxed); !p.is_null() && unsafe { (*p).main_tid == libc::syscall(libc::SYS_gettid) } } @@ -1047,6 +1061,7 @@ unsafe fn start_census(out: String) { st.last_ir = read_ir(st.perf_fd); ANCHOR.state.store(&mut *st as *mut State, Relaxed); std::mem::forget(st); + let _input_logging = input_record::Scope::enter(); let st = state(); // Nothing moves until the boundary. The weights run (no protection) // collects as production does: its Ir per init includes the collections. @@ -1100,9 +1115,12 @@ unsafe fn start_census(out: String) { } } } + st.inputs.watch_enabled = !std::env::var("PERRY_SNAP22_WATCH").is_ok_and(|v| v == "0"); st.inputs.allocated_bytes = st.births.as_slice().iter().map(|b| b.size as u64).sum(); + input_record::open(st).expect("cannot open ordered input record"); install_handlers(); ANCHOR.on.store(true, Relaxed); + let _input_logging = input_record::Scope::enter(); input_record::event(st, "census_start", String::new()); let mut raw_env = Vec::>::new(); let mut env_entry = environ; @@ -1292,6 +1310,7 @@ pub(super) fn init_enter(addr: usize) { if !on() && !start_if_requested() { return; } + let _input_logging = input_record::Scope::enter(); let st = state(); charge(st); let parent = st.frames.last().copied(); @@ -1373,6 +1392,7 @@ pub(super) fn init_enter(addr: usize) { } pub(super) fn init_exit(threw: bool) { + let _input_logging = input_record::Scope::enter(); let st = state(); charge(st); input_record::event(st, "init_exit", format!("threw={threw}")); @@ -1399,6 +1419,7 @@ pub(super) fn init_guard(addr: usize, guard: usize) { } pub(super) fn init_part() { + let _input_logging = input_record::Scope::enter(); let st = state(); if let Some(f) = st.frames.last() { if f.kind == FrameKind::Init { @@ -1418,6 +1439,7 @@ pub(super) fn require_depth(delta: i32) { /// target's init guard, 0 after it (the target was already evaluated, so no /// evaluation took the mark). pub(super) fn require_edge(on: bool) { + let _input_logging = input_record::Scope::enter(); let st = state(); st.require_edge = on.then(|| top_init(st)); } @@ -1437,6 +1459,7 @@ pub(super) fn bookkeeping_depth(delta: i32) { } pub(super) fn job_enter(owner: u32) { + let _input_logging = input_record::Scope::enter(); let st = state(); charge(st); st.frames.push(Frame { @@ -1448,6 +1471,7 @@ pub(super) fn job_enter(owner: u32) { } pub(super) fn job_exit() { + let _input_logging = input_record::Scope::enter(); let st = state(); input_record::event(st, "job_exit", String::new()); charge(st); @@ -1458,6 +1482,7 @@ pub(super) fn job_exit() { } pub(super) fn entry_body() { + let _input_logging = input_record::Scope::enter(); let st = state(); if st.entry_body_seq == 0 { st.seq += 1; @@ -1497,6 +1522,7 @@ pub(super) fn entry_module() { /// meta word at `slot`: an empty meta behaves as no meta, so the store is /// representation, like the string alias hint (design §2.10.11). pub(super) fn meta_install(slot: usize) { + let _input_logging = input_record::Scope::enter(); let st = state(); if st.meta_rule { st.meta_slot = slot & !7; @@ -1507,6 +1533,7 @@ pub(super) fn meta_install(slot: usize) { /// written into it later is a write of the owner: under rule 4 the record /// of a pre-existing owner is pre-existing too. pub(super) fn meta_installed(slot: usize, meta: usize) { + let _input_logging = input_record::Scope::enter(); let st = state(); st.meta_slot = 0; st.meta_installs += 1; @@ -1542,6 +1569,7 @@ pub(super) fn global_key<'a>( obj: usize, key: impl FnOnce() -> Option<&'a [u8]>, ) -> Option<(u64, [u8; GKEY_NAME], u8)> { + let _input_logging = input_record::Scope::enter(); let st = state(); if !st.gkey_rule { return None; @@ -1569,11 +1597,13 @@ pub(super) fn global_key<'a>( /// The keyed operation [`global_key`] opened is over. pub(super) fn global_key_end(prev: (u64, [u8; GKEY_NAME], u8)) { + let _input_logging = input_record::Scope::enter(); let st = state(); (st.gkey, st.gkey_name, st.gkey_len) = prev; } pub(super) fn representation(delta: i32) -> bool { + let _input_logging = input_record::Scope::enter(); let st = state(); if !st.meta_rule { return false; @@ -1587,6 +1617,7 @@ pub(super) fn representation(delta: i32) -> bool { } pub(super) fn attached(obj: usize, cells: &[usize]) { + let _input_logging = input_record::Scope::enter(); let st = state(); if st.transparent.is_empty() { return; @@ -1609,6 +1640,7 @@ pub(super) fn attached(obj: usize, cells: &[usize]) { } pub(super) fn dyn_code(kind: u8) { + let _input_logging = input_record::Scope::enter(); let st = state(); let top = st.frames.last().map_or(0, |f| f.init) as usize; if top == 0 { @@ -1628,6 +1660,7 @@ pub(super) fn dyn_code(kind: u8) { } pub(super) fn input_scope(delta: i32) { + let _input_logging = input_record::Scope::enter(); let st = state(); if delta > 0 { st.inputs.call_depth += 1; @@ -1638,10 +1671,12 @@ pub(super) fn input_scope(delta: i32) { } pub(super) fn input_event(op: &str, value: String) -> u64 { + let _input_logging = input_record::Scope::enter(); input_record::event(state(), op, value) } pub(super) fn host(class: Effect, what: &'static str) { + let _input_logging = input_record::Scope::enter(); let st = state(); let input_top = top_init(st) as usize; if class != Effect::TargetFact @@ -1649,13 +1684,7 @@ pub(super) fn host(class: Effect, what: &'static str) { && input_top != 0 && st.inputs.writes.get(input_top).is_some_and(Option::is_none) { - st.inputs.seq += 1; - st.inputs.writes[input_top] = Some(input_record::Write { - seq: st.inputs.seq, - ir: read_ir(st.perf_fd), - word: 0, - pc: 0, - }); + input_record::remember_write(st, 0, 0); st.inputs.watching = false; unsafe { apply_protection(st); @@ -1734,6 +1763,7 @@ fn object_start(st: &State, word: usize) -> Option { } pub(super) fn birth(addr: usize, size: usize) { + let _input_logging = input_record::Scope::enter(); let st = state(); st.inputs.allocated_bytes += size as u64; // Memory handed out again (a free-list slot, a rewound bump) holds new @@ -1775,6 +1805,7 @@ pub(super) fn birth(addr: usize, size: usize) { } pub(super) fn region_mapped(base: usize, len: usize) { + let _input_logging = input_record::Scope::enter(); let st = state(); if unsafe { libc::syscall(libc::SYS_gettid) } != st.main_tid { return; @@ -1787,6 +1818,7 @@ pub(super) fn region_mapped(base: usize, len: usize) { } pub(super) fn global_root(slot: usize) { + let _input_logging = input_record::Scope::enter(); let st = state(); // A slot registered again is one address linked into several modules // (codegen registers a class-static global in its defining module and in @@ -1826,6 +1858,7 @@ pub(super) fn global_root(slot: usize) { } fn mark_tracked(lo: usize, hi: usize) { + let _input_logging = input_record::Scope::enter(); let st = state(); let mut p = lo & !(PAGE - 1); while p < hi { @@ -2207,6 +2240,7 @@ extern "C" fn on_segv(sig: libc::c_int, info: *mut libc::siginfo_t, ctx: *mut li (*uc).uc_mcontext.gregs[libc::REG_EFL as usize] |= EFLAGS_TF; return; } + let _input_logging = input_record::Scope::enter(); if st.in_handler > 0 { let uc = ctx as *mut libc::ucontext_t; let pc = (*uc).uc_mcontext.gregs[libc::REG_RIP as usize] as u64; @@ -2319,6 +2353,7 @@ extern "C" fn on_trap(sig: libc::c_int, _info: *mut libc::siginfo_t, ctx: *mut l if st_ptr.is_null() || (*st_ptr).pending[0] == 0 { return chain(std::ptr::addr_of!(PREV_TRAP.0).cast(), sig); } + let _input_logging = input_record::Scope::enter(); let st = &mut *st_ptr; let pending = st.pending; if st.rset_active { @@ -2738,6 +2773,7 @@ unsafe extern "C" { /// knobs, C libraries), not only the entries that declare `env`. #[no_mangle] pub unsafe extern "C" fn getenv(name: *const libc::c_char) -> *mut libc::c_char { + let saved_errno = *libc::__errno_location(); if name.is_null() { return std::ptr::null_mut(); } @@ -2754,30 +2790,28 @@ pub unsafe extern "C" fn getenv(name: *const libc::c_char) -> *mut libc::c_char e = e.add(1); } } - if on() && on_main_thread() { + if inputs_on() && on_main_thread() { + let _input_logging = input_record::Scope::enter(); let st = state(); - let top = st.frames.last().map_or(0, |f| f.init); - let raw_value = if found.is_null() { - "unset".to_owned() + if found.is_null() { + input_record::native_event( + st, + "getenv", + format_args!("{}=unset", input_record::Hex(key)), + ); } else { - input_record::hex(std::ffi::CStr::from_ptr(found).to_bytes()) - }; - input_record::event( - st, - "getenv", - format!("{}={}", input_record::hex(key), raw_value), - ); - if st.env_reads.len() < 1 << 20 { - let value = (!found.is_null()).then(|| { - std::ffi::CStr::from_ptr(found) - .to_string_lossy() - .into_owned() - }); - st.env_reads - .push((top, String::from_utf8_lossy(key).into_owned(), value)); + input_record::native_event( + st, + "getenv", + format_args!( + "{}={}", + input_record::Hex(key), + input_record::Hex(std::ffi::CStr::from_ptr(found).to_bytes()) + ), + ); } } - if on() && !on_main_thread() { + if inputs_on() && !on_main_thread() { let value = if found.is_null() { b"".as_slice() } else { @@ -2785,6 +2819,7 @@ pub unsafe extern "C" fn getenv(name: *const libc::c_char) -> *mut libc::c_char }; foreign_input(b"getenv", key, value); } + *libc::__errno_location() = saved_errno; found } @@ -2799,6 +2834,7 @@ pub(super) fn skip_collection() -> bool { } pub(super) fn boundary(at: &'static str) { + let _input_logging = input_record::Scope::enter(); let st = state(); if st.boundary_seq != 0 { return; @@ -2879,6 +2915,7 @@ fn heap_pointer(st: &State, v: u64) -> bool { } extern "C" fn rset_report() { + let _input_logging = input_record::Scope::enter(); let st = state(); if !st.rset_active { return; @@ -2984,6 +3021,8 @@ pub(super) fn after_boundary_collection() { } fn write_report(st: &mut State) { + st.env_reads = + input_record::env_rows(st).expect("cannot read durable environment observations"); use std::io::Write; let Ok(mut f) = std::fs::File::create(&st.out) else { return; @@ -3972,11 +4011,13 @@ pub(super) fn births_len() -> usize { } pub(super) fn env_site(kind: i32) { + let _input_logging = input_record::Scope::enter(); let st = state(); st.env_site = Some((top_init(st), kind as u8)); } pub(super) fn env_object(bits: u64) { + let _input_logging = input_record::Scope::enter(); let st = state(); if !st.env_rule { return host(Effect::HostRead, "env"); @@ -3998,6 +4039,7 @@ pub(super) fn env_object(bits: u64) { } pub(super) fn env_storage(begin: usize, obj: usize) { + let _input_logging = input_record::Scope::enter(); let st = state(); let h = obj - crate::gc::GC_HEADER_SIZE; let new: Vec<(usize, usize)> = st.births.as_slice()[begin.min(st.births.len)..] @@ -4018,6 +4060,7 @@ pub(super) fn env_storage(begin: usize, obj: usize) { } pub(super) fn env_var(name: &str, value: Option<&str>, ptr: usize) { + let _input_logging = input_record::Scope::enter(); let st = state(); input_record::event(st, "env_get", format!("{name:?}={value:?}")); let top = top_init(st); @@ -4051,6 +4094,7 @@ pub(super) fn env_var(name: &str, value: Option<&str>, ptr: usize) { } pub(super) fn env_has(name: &str, present: bool) { + let _input_logging = input_record::Scope::enter(); let st = state(); input_record::event(st, "env_has", format!("{name:?}={present}")); if !st.env_rule { @@ -4119,6 +4163,7 @@ fn env_word_access(st: &mut State, v: u32, word: usize, write: bool, pc: usize) /// The check run: the evaluation now on top is a member of D. pub(super) fn skip_body() -> bool { + let _input_logging = input_record::Scope::enter(); let st = state(); if st.skip.is_empty() { return false; @@ -4441,17 +4486,25 @@ pub unsafe extern "C" fn clock_gettime( ) -> libc::c_int { let rc = libc::syscall(libc::SYS_clock_gettime, clock, out) as libc::c_int; let saved_errno = *libc::__errno_location(); - if on() && on_main_thread() { - let value = if rc == 0 && !out.is_null() { - format!( - "clock={clock};sec={};nsec={}", - (*out).tv_sec, - (*out).tv_nsec - ) + if inputs_on() && on_main_thread() { + let _input_logging = input_record::Scope::enter(); + if rc == 0 && !out.is_null() { + input_record::native_event( + state(), + "native.clock_gettime", + format_args!( + "clock={clock};sec={};nsec={}", + (*out).tv_sec, + (*out).tv_nsec + ), + ); } else { - format!("clock={clock};rc={rc};errno={saved_errno}") - }; - input_event("native.clock_gettime", value); + input_record::native_event( + state(), + "native.clock_gettime", + format_args!("clock={clock};rc={rc};errno={saved_errno}"), + ); + } } *libc::__errno_location() = saved_errno; rc @@ -4464,13 +4517,21 @@ pub unsafe extern "C" fn gettimeofday( ) -> libc::c_int { let rc = libc::syscall(libc::SYS_gettimeofday, out, zone) as libc::c_int; let saved_errno = *libc::__errno_location(); - if on() && on_main_thread() { - let value = if rc == 0 && !out.is_null() { - format!("sec={};usec={}", (*out).tv_sec, (*out).tv_usec) + if inputs_on() && on_main_thread() { + let _input_logging = input_record::Scope::enter(); + if rc == 0 && !out.is_null() { + input_record::native_event( + state(), + "native.gettimeofday", + format_args!("sec={};usec={}", (*out).tv_sec, (*out).tv_usec), + ); } else { - format!("rc={rc};errno={saved_errno}") - }; - input_event("native.gettimeofday", value); + input_record::native_event( + state(), + "native.gettimeofday", + format_args!("rc={rc};errno={saved_errno}"), + ); + } } *libc::__errno_location() = saved_errno; rc @@ -4480,8 +4541,13 @@ pub unsafe extern "C" fn gettimeofday( pub unsafe extern "C" fn time(out: *mut libc::time_t) -> libc::time_t { let rc = libc::syscall(libc::SYS_time, out) as libc::time_t; let saved_errno = *libc::__errno_location(); - if on() && on_main_thread() { - input_event("native.time", format!("seconds={rc};errno={saved_errno}")); + if inputs_on() && on_main_thread() { + let _input_logging = input_record::Scope::enter(); + input_record::native_event( + state(), + "native.time", + format_args!("seconds={rc};errno={saved_errno}"), + ); } *libc::__errno_location() = saved_errno; rc @@ -4492,13 +4558,18 @@ pub unsafe extern "C" fn time(out: *mut libc::time_t) -> libc::time_t { #[no_mangle] pub unsafe extern "C" fn unsetenv(name: *const libc::c_char) -> libc::c_int { let saved_errno = *libc::__errno_location(); - if on() && on_main_thread() { + if inputs_on() && on_main_thread() { + let _input_logging = input_record::Scope::enter(); let key = if name.is_null() { - "null".into() + b"".as_slice() } else { - input_record::hex(std::ffi::CStr::from_ptr(name).to_bytes()) + std::ffi::CStr::from_ptr(name).to_bytes() }; - input_event("native.unsetenv", key); + input_record::native_event( + state(), + "native.unsetenv", + format_args!("{}", input_record::Hex(key)), + ); host(Effect::HostWrite, "env"); } let next = libc::dlsym(libc::RTLD_NEXT, c"unsetenv".as_ptr()); @@ -4515,20 +4586,26 @@ pub unsafe extern "C" fn setenv( overwrite: libc::c_int, ) -> libc::c_int { let saved_errno = *libc::__errno_location(); - if on() && on_main_thread() { + if inputs_on() && on_main_thread() { + let _input_logging = input_record::Scope::enter(); let key = if name.is_null() { - "null".into() + b"".as_slice() } else { - input_record::hex(std::ffi::CStr::from_ptr(name).to_bytes()) + std::ffi::CStr::from_ptr(name).to_bytes() }; let val = if value.is_null() { - "null".into() + b"".as_slice() } else { - input_record::hex(std::ffi::CStr::from_ptr(value).to_bytes()) + std::ffi::CStr::from_ptr(value).to_bytes() }; - input_event( + input_record::native_event( + state(), "native.setenv", - format!("{key}={val};overwrite={overwrite}"), + format_args!( + "{}={};overwrite={overwrite}", + input_record::Hex(key), + input_record::Hex(val) + ), ); host(Effect::HostWrite, "env"); } diff --git a/scripts/snap_inputs/README.md b/scripts/snap_inputs/README.md index 3db97cecbbb..74400923272 100644 --- a/scripts/snap_inputs/README.md +++ b/scripts/snap_inputs/README.md @@ -34,3 +34,29 @@ writes or their absence: representation writes, non-GC native state and local allocations must be audited separately. A decoder's "representation" annotation likewise cannot admit a native resource. Unknown input or incomplete payload is a STOP; module names and TypeScript types never grant eligibility. + +SNAP22 abort survival: compile `fixtures/abort/main.ts` with the instrumented +cohort, then run with `SNAP22_ABORT_SENTINEL=before_abort` and +`PERRY_SNAP_CENSUS=`; expect SIGABRT/134. Run +`python3 scripts/snap_inputs/check_abort.py .inputs.tsv`. +The assertion is RED with SNAP21's boundary-buffered recorder. Each row now +uses native write-all immediately, including first-write rows from signal +context. Native logging intervals carry lseek marker 0x5322; the syscall +companion excludes only those explicit intervals and their dedicated fds. +No JS values, shapes, native frames or event payload buffers are retained +by the recorder. A failed or oversized write fails explicitly (92/93). + +The recorder suppresses allocator callbacks with const-initialized native TLS +before callback formatting or borrowing census state. Only diagnostic scopes +are suppressed; the actual observed JS/native operation runs outside them. +Clock and libc environment observation payloads use bounded stack buffers. + +For the GC/re-entry regression, compile fixtures/effect/main.ts (requires the +same installed effect package as the subject) and fixtures/pressure/main.ts. +Run each with PERRY_SNAP22_WATCH=0 and =1, PERRY_SNAP_NOPROTECT=1 and distinct +report paths. check_recorder.py ROOT [ARM=guard-head] asserts six ordered +prefixes under ROOT/fx/{effect,shapes,abort}/ARM, report-0/report-1 inputs, +stdout-0/stdout-1, and a live-object census proving GC completed. The abort +fixture intentionally exits 134 without reaching the boundary. On the old +recorder the effect GC crashes, pressure fails, and abort rows are absent or +out of order; a successful process exit alone cannot satisfy these assertions. diff --git a/scripts/snap_inputs/check_abort.py b/scripts/snap_inputs/check_abort.py new file mode 100644 index 00000000000..c962ee833c4 --- /dev/null +++ b/scripts/snap_inputs/check_abort.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +"""An actual SIGABRT must preserve completed env/argv observations mid-init.""" +import csv,sys +from pathlib import Path +p=Path(sys.argv[1]) +assert p.is_file(), "ordered record missing after SIGABRT" +assert p.read_bytes().endswith(b"\n"), "truncated event row" +rows=list(csv.DictReader(p.open(),delimiter="\t")) +assert rows and all(int(r["ir"])>0 for r in rows) +assert all(int(a["seq"]) 2 else 'guard-head' +for fixture in ('effect', 'shapes', 'abort'): + for watch in (0, 1): + d = root / 'fx' / fixture / arm + path = d / f'report-{watch}.inputs.tsv' + assert path.is_file() and path.read_bytes().endswith(b'\n'), path + rows = list(csv.DictReader(path.open(), delimiter='\t')) + assert rows and all(set(r) == set(rows[0]) and None not in r.values() for r in rows), path + assert [int(r['seq']) for r in rows] == list(range(1, len(rows) + 1)), path + for row in rows: + bytes.fromhex(row['value_hex']) + if fixture == 'abort': + subprocess.run([sys.executable, str(Path(__file__).with_name('check_abort.py')), str(path)], check=True) + else: + assert any(r['op'] == 'boundary' for r in rows), f'{path}: GC boundary never reached' + assert (d / f'report-{watch}.live.tsv').is_file(), f'{path}: GC completion missing' + stdout = (d / f'stdout-{watch}').read_text().split() + assert stdout == (['function'] if fixture == 'effect' else ['1000'] * 8), stdout + print(f'{fixture} watch={watch}: complete ordered prefix, {len(rows)} events') diff --git a/scripts/snap_inputs/fixtures/abort/abort.ts b/scripts/snap_inputs/fixtures/abort/abort.ts new file mode 100644 index 00000000000..5872ab9d9bb --- /dev/null +++ b/scripts/snap_inputs/fixtures/abort/abort.ts @@ -0,0 +1,4 @@ +const x = process.env.SNAP22_ABORT_SENTINEL; +const y = process.argv; +process.abort(); +export { x, y }; diff --git a/scripts/snap_inputs/fixtures/abort/main.ts b/scripts/snap_inputs/fixtures/abort/main.ts new file mode 100644 index 00000000000..289ed994634 --- /dev/null +++ b/scripts/snap_inputs/fixtures/abort/main.ts @@ -0,0 +1,2 @@ +import "./abort"; +console.log("UNREACHABLE"); diff --git a/scripts/snap_inputs/fixtures/effect/main.ts b/scripts/snap_inputs/fixtures/effect/main.ts new file mode 100644 index 00000000000..494671cad93 --- /dev/null +++ b/scripts/snap_inputs/fixtures/effect/main.ts @@ -0,0 +1,3 @@ +import * as Result from "effect/Result"; +console.log(typeof Result.succeed); +(globalThis as any).gc(); diff --git a/scripts/snap_inputs/fixtures/pressure/m0.ts b/scripts/snap_inputs/fixtures/pressure/m0.ts new file mode 100644 index 00000000000..f2aade3b85d --- /dev/null +++ b/scripts/snap_inputs/fixtures/pressure/m0.ts @@ -0,0 +1,9 @@ +const keep: any[] = []; +for (let i = 0; i < 1000; i++) { + const proto: any = { marker: i }; + const obj: any = Object.create(proto); + obj.x = i; + Object.defineProperty(obj, "y", {value: i + 1, enumerable: true}); + keep.push(obj); +} +export const value = keep.length; diff --git a/scripts/snap_inputs/fixtures/pressure/m1.ts b/scripts/snap_inputs/fixtures/pressure/m1.ts new file mode 100644 index 00000000000..f2aade3b85d --- /dev/null +++ b/scripts/snap_inputs/fixtures/pressure/m1.ts @@ -0,0 +1,9 @@ +const keep: any[] = []; +for (let i = 0; i < 1000; i++) { + const proto: any = { marker: i }; + const obj: any = Object.create(proto); + obj.x = i; + Object.defineProperty(obj, "y", {value: i + 1, enumerable: true}); + keep.push(obj); +} +export const value = keep.length; diff --git a/scripts/snap_inputs/fixtures/pressure/m2.ts b/scripts/snap_inputs/fixtures/pressure/m2.ts new file mode 100644 index 00000000000..f2aade3b85d --- /dev/null +++ b/scripts/snap_inputs/fixtures/pressure/m2.ts @@ -0,0 +1,9 @@ +const keep: any[] = []; +for (let i = 0; i < 1000; i++) { + const proto: any = { marker: i }; + const obj: any = Object.create(proto); + obj.x = i; + Object.defineProperty(obj, "y", {value: i + 1, enumerable: true}); + keep.push(obj); +} +export const value = keep.length; diff --git a/scripts/snap_inputs/fixtures/pressure/m3.ts b/scripts/snap_inputs/fixtures/pressure/m3.ts new file mode 100644 index 00000000000..f2aade3b85d --- /dev/null +++ b/scripts/snap_inputs/fixtures/pressure/m3.ts @@ -0,0 +1,9 @@ +const keep: any[] = []; +for (let i = 0; i < 1000; i++) { + const proto: any = { marker: i }; + const obj: any = Object.create(proto); + obj.x = i; + Object.defineProperty(obj, "y", {value: i + 1, enumerable: true}); + keep.push(obj); +} +export const value = keep.length; diff --git a/scripts/snap_inputs/fixtures/pressure/m4.ts b/scripts/snap_inputs/fixtures/pressure/m4.ts new file mode 100644 index 00000000000..f2aade3b85d --- /dev/null +++ b/scripts/snap_inputs/fixtures/pressure/m4.ts @@ -0,0 +1,9 @@ +const keep: any[] = []; +for (let i = 0; i < 1000; i++) { + const proto: any = { marker: i }; + const obj: any = Object.create(proto); + obj.x = i; + Object.defineProperty(obj, "y", {value: i + 1, enumerable: true}); + keep.push(obj); +} +export const value = keep.length; diff --git a/scripts/snap_inputs/fixtures/pressure/m5.ts b/scripts/snap_inputs/fixtures/pressure/m5.ts new file mode 100644 index 00000000000..f2aade3b85d --- /dev/null +++ b/scripts/snap_inputs/fixtures/pressure/m5.ts @@ -0,0 +1,9 @@ +const keep: any[] = []; +for (let i = 0; i < 1000; i++) { + const proto: any = { marker: i }; + const obj: any = Object.create(proto); + obj.x = i; + Object.defineProperty(obj, "y", {value: i + 1, enumerable: true}); + keep.push(obj); +} +export const value = keep.length; diff --git a/scripts/snap_inputs/fixtures/pressure/m6.ts b/scripts/snap_inputs/fixtures/pressure/m6.ts new file mode 100644 index 00000000000..f2aade3b85d --- /dev/null +++ b/scripts/snap_inputs/fixtures/pressure/m6.ts @@ -0,0 +1,9 @@ +const keep: any[] = []; +for (let i = 0; i < 1000; i++) { + const proto: any = { marker: i }; + const obj: any = Object.create(proto); + obj.x = i; + Object.defineProperty(obj, "y", {value: i + 1, enumerable: true}); + keep.push(obj); +} +export const value = keep.length; diff --git a/scripts/snap_inputs/fixtures/pressure/m7.ts b/scripts/snap_inputs/fixtures/pressure/m7.ts new file mode 100644 index 00000000000..f2aade3b85d --- /dev/null +++ b/scripts/snap_inputs/fixtures/pressure/m7.ts @@ -0,0 +1,9 @@ +const keep: any[] = []; +for (let i = 0; i < 1000; i++) { + const proto: any = { marker: i }; + const obj: any = Object.create(proto); + obj.x = i; + Object.defineProperty(obj, "y", {value: i + 1, enumerable: true}); + keep.push(obj); +} +export const value = keep.length; diff --git a/scripts/snap_inputs/fixtures/pressure/main.ts b/scripts/snap_inputs/fixtures/pressure/main.ts new file mode 100644 index 00000000000..ccd2f55c842 --- /dev/null +++ b/scripts/snap_inputs/fixtures/pressure/main.ts @@ -0,0 +1,10 @@ +import { value as value0 } from "./m0"; +import { value as value1 } from "./m1"; +import { value as value2 } from "./m2"; +import { value as value3 } from "./m3"; +import { value as value4 } from "./m4"; +import { value as value5 } from "./m5"; +import { value as value6 } from "./m6"; +import { value as value7 } from "./m7"; +console.log(value0,value1,value2,value3,value4,value5,value6,value7); +(globalThis as any).gc(); diff --git a/scripts/snap_inputs/trace.py b/scripts/snap_inputs/trace.py index 8dfb7f2c694..0ab6b63a816 100644 --- a/scripts/snap_inputs/trace.py +++ b/scripts/snap_inputs/trace.py @@ -67,9 +67,9 @@ def main(): os.execvpe(command[0],command,os.environ) _,status=os.waitpid(pid,0) ptrace(SETOPTIONS,pid,0,1|(1<<1)|(1<<2)|(1<<3)|(1<<4)|(1<<20)) - active={pid:{"perf":counter(pid),"entry":None,"mem":None,"diagnostic_fds":set()}} + active={pid:{"perf":counter(pid),"entry":None,"mem":None,"diagnostic_fds":set(),"logger_depth":0}} sequence=0; main_rc=None; failures=[] - out=open(output,"w") + out=open(output,"w",buffering=1) out.write(json.dumps({"kind":"collector","sha256":hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),"command":command})+"\n") def memory(tid,addr,n): if n==0:return b"" @@ -209,7 +209,7 @@ def handle_probe(tid): if tid==pid:main_rc=rc out.write(json.dumps({"kind":"exit","tid":tid,"rc":rc})+"\n");continue if tid not in active: - active[tid]={"perf":counter(tid),"entry":None,"mem":None,"diagnostic_fds":set()} + active[tid]={"perf":counter(tid),"entry":None,"mem":None,"diagnostic_fds":set(),"logger_depth":0} sig=os.WSTOPSIG(status); event=status>>16 if event in (1,2,3): child=C.c_ulonglong() @@ -233,7 +233,9 @@ def handle_probe(tid): a=args # Optional raw stack evidence for source audit. These words are not a # backtrace and never establish an input's eligibility or pointer shape. - if nr not in REP and not(nr in (0,1) and a[0] in state["diagnostic_fds"]): + if state["logger_depth"]: + entry["policy"]="diagnostic" + if nr not in REP and not state["logger_depth"] and not(nr in (0,1) and a[0] in state["diagnostic_fds"]): regs=(C.c_ulonglong*27)() ptrace(12,tid,0,C.addressof(regs)) sp=int(regs[19]);entry["rsp"]=sp @@ -265,6 +267,10 @@ def handle_probe(tid): elif nr==46:entry["message"]=message(tid,a[1],1<<63) elif nr in (42,49):entry["sockaddr"]=blob(tid,a[1],a[2]) elif nr==54:entry["option"]=blob(tid,a[3],a[4]) + if nr==8 and (a[0]&0xffffffff)==0xffffffff and a[2]==0x5322: + entry["kind"]="logger_scope";entry["policy"]="diagnostic" + state["logger_depth"] += 1 if a[1] else -1 + if state["logger_depth"]<0:raise RuntimeError("unbalanced logger scope") if nr==8 and (a[0]&0xffffffff)==0xffffffff and a[2]==0x5321: entry["kind"]="runtime_marker";entry["runtime_seq"]=a[1] if nr in (0,1,8) and a[0] in state["diagnostic_fds"]:entry["policy"]="diagnostic" @@ -275,7 +281,7 @@ def handle_probe(tid): e["ir_exit"]=pos;e["ret"]=ret;e["is_error"]=bool(raw[32]) a=e["args"];nr=e["nr"] if ret>=0: - if nr==298:state["diagnostic_fds"].add(ret) + if nr==298 or (nr in (2,257) and e["policy"]=="diagnostic"):state["diagnostic_fds"].add(ret) if nr==9 and C.c_int(a[4]).value>=0: # A file-backed mapping is an input/resource boundary even if its # contents are first used later. Capture this observation; subsequent From 377449ffb27c06aa591ce2b16f372bcdee5682c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 7 Nov 2026 10:42:03 +0000 Subject: [PATCH 8/9] fix: restore census page policy after first-write tracking --- changelog.d/13935-snap-census-protection.md | 1 + .../perry-runtime/src/snap_census/runner.rs | 11 +++++- scripts/snap_inputs/README.md | 9 +++++ scripts/snap_inputs/check_protection.py | 35 +++++++++++++++++++ .../snap_inputs/fixtures/protection/holder.ts | 2 ++ .../snap_inputs/fixtures/protection/main.ts | 4 +++ .../snap_inputs/fixtures/protection/writer.ts | 5 +++ scripts/snap_inputs/protection_fixture.sh | 21 +++++++++++ 8 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 changelog.d/13935-snap-census-protection.md create mode 100644 scripts/snap_inputs/check_protection.py create mode 100644 scripts/snap_inputs/fixtures/protection/holder.ts create mode 100644 scripts/snap_inputs/fixtures/protection/main.ts create mode 100644 scripts/snap_inputs/fixtures/protection/writer.ts create mode 100644 scripts/snap_inputs/protection_fixture.sh diff --git a/changelog.d/13935-snap-census-protection.md b/changelog.d/13935-snap-census-protection.md new file mode 100644 index 00000000000..4f3cefee81b --- /dev/null +++ b/changelog.d/13935-snap-census-protection.md @@ -0,0 +1 @@ +Fix instrumented startup census page protection after the first-write watcher ends in the weights run; keep bulk and late-root protection consistent with the page policy. diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 1484e8ed3cb..5114db6afe0 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -1853,7 +1853,7 @@ pub(super) fn global_root(slot: usize) { } let page = slot & !(PAGE - 1); if st.d_mode && page_flags(st, page).is_some() { - unsafe { libc::mprotect(page as *mut _, PAGE, libc::PROT_NONE) }; + unsafe { libc::mprotect(page as *mut _, PAGE, policy(st, page)) }; } } @@ -1998,6 +1998,15 @@ unsafe fn apply_protection(st: &mut State) { }); return; } + // A conflict can join D while the first-write watcher is running in + // the weights run. Ending that watcher restores RW even if d_mode is + // still set; bulk protection must agree with policy for every page. + if st.noprotect { + for_each_tracked_range(st, |b, e| { + libc::mprotect(b as *mut _, e - b, libc::PROT_READ | libc::PROT_WRITE); + }); + return; + } if st.top_mode { for_each_tracked_range(st, |b, e| { libc::mprotect(b as *mut _, e - b, libc::PROT_READ | libc::PROT_WRITE); diff --git a/scripts/snap_inputs/README.md b/scripts/snap_inputs/README.md index 74400923272..0cc65ad891a 100644 --- a/scripts/snap_inputs/README.md +++ b/scripts/snap_inputs/README.md @@ -60,3 +60,12 @@ stdout-0/stdout-1, and a live-object census proving GC completed. The abort fixture intentionally exits 134 without reaching the boundary. On the old recorder the effect GC crashes, pressure fails, and abort rows are absent or out of order; a successful process exit alone cannot satisfy these assertions. + +Protection consistency regression: compile `fixtures/protection/main.ts` with +an instrumented cohort, then run `bash scripts/snap_inputs/protection_fixture.sh +BINARY OUTPUT base|head`. A dynamic child changes a cell born before its import, +then accesses other pages. On the base the first-write watcher triggers a +rule-4 conflict, bulk protection closes unrelated pages, and the process faults +although its page policy is RW. Watch=0 finishes; the fix finishes in both modes. +The checker requires the nonzero first-write row, exact output, and the native +mprotect evidence. It also rejects any bulk PROT_NONE in this NOPROTECT fixture. diff --git a/scripts/snap_inputs/check_protection.py b/scripts/snap_inputs/check_protection.py new file mode 100644 index 00000000000..4010214de30 --- /dev/null +++ b/scripts/snap_inputs/check_protection.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Require the real protection transition and its failing base execution.""" +import csv +import json +import sys +from pathlib import Path + +root = Path(sys.argv[1]) +arm = sys.argv[2] +assert arm in ('base', 'head'), arm +statuses = dict(line.split() for line in (root / 'results.tsv').read_text().splitlines()) +for watch in (0, 1): + expected = 139 if arm == 'base' and watch == 1 else 0 + assert int(statuses[str(watch)]) == expected, (arm, watch, statuses) + path = root / f'report-{watch}.inputs.tsv' + assert path.read_bytes().endswith(b'\n'), path + events = list(csv.DictReader(path.open(), delimiter='\t')) + assert [int(r['seq']) for r in events] == list(range(1, len(events) + 1)), path + writes = [r for r in events if r['op'] == 'first_changed_tracked_word' + and bytes.fromhex(r['value_hex']) != b'0'] + assert bool(writes) == bool(watch), (watch, writes) + native = [json.loads(line) for line in (root / f'native-{watch}.jsonl').open()] + assert native[-1]['kind'] == 'summary', native[-1] + assert native[-1]['main_rc'] == expected, native[-1] + none = [r for r in native if r['kind'] == 'syscall' and r.get('nr') == 10 + and r['args'][2] == 0 and r.get('ret') == 0] + assert bool(none) == (arm == 'base' and watch == 1), (arm, watch, len(none)) + stdout = (root / f'stdout-{watch}').read_text() + if expected == 0: + assert stdout.splitlines() == ['before 3000', 'boundary complete 4496999'], stdout + else: + assert stdout.splitlines() == ['before 3000'], stdout + assert 'snap census: foreign fault' in (root / f'stderr-{watch}').read_text() + print(f'{arm} watch={watch}: exit={expected}, nonzero first writes={len(writes)}, ' + f'successful PROT_NONE={len(none)}') diff --git a/scripts/snap_inputs/fixtures/protection/holder.ts b/scripts/snap_inputs/fixtures/protection/holder.ts new file mode 100644 index 00000000000..20278960783 --- /dev/null +++ b/scripts/snap_inputs/fixtures/protection/holder.ts @@ -0,0 +1,2 @@ +export const cells: {value: number}[] = []; +for (let i = 0; i < 3000; i++) cells.push({value: i}); diff --git a/scripts/snap_inputs/fixtures/protection/main.ts b/scripts/snap_inputs/fixtures/protection/main.ts new file mode 100644 index 00000000000..239b7830467 --- /dev/null +++ b/scripts/snap_inputs/fixtures/protection/main.ts @@ -0,0 +1,4 @@ +import { cells } from './holder'; +console.log('before', cells.length); +const m = await import('./writer'); +console.log('boundary complete', m.result); diff --git a/scripts/snap_inputs/fixtures/protection/writer.ts b/scripts/snap_inputs/fixtures/protection/writer.ts new file mode 100644 index 00000000000..5e75a3fb10d --- /dev/null +++ b/scripts/snap_inputs/fixtures/protection/writer.ts @@ -0,0 +1,5 @@ +import { cells } from './holder'; +cells[1500].value = -1; +let sum = 0; +for (let i = 0; i < cells.length; i++) sum += cells[i].value; +export const result = sum; diff --git a/scripts/snap_inputs/protection_fixture.sh b/scripts/snap_inputs/protection_fixture.sh new file mode 100644 index 00000000000..1b7643468ac --- /dev/null +++ b/scripts/snap_inputs/protection_fixture.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Run against separately pinned base/head instrumented binaries. +set -euo pipefail +ulimit -c 0 +binary=$(readlink -f "${1:?binary}") +out=${2:?output directory} +arm=${3:?base or head} +mkdir -p "$out" +out=$(readlink -f "$out") +scripts=$(cd "$(dirname "$0")" && pwd) +: > "$out/results.tsv" +for watch in 0 1; do + set +e + env PERRY_SNAP_NOPROTECT=1 PERRY_SNAP22_WATCH=$watch PERRY_SNAP_CENSUS="$out/report-$watch" \ + timeout 120 python3 "$scripts/trace.py" "$out/native-$watch.jsonl" -- \ + setarch -R "$binary" > "$out/stdout-$watch" 2> "$out/stderr-$watch" + rc=$? + set -e + printf '%s\t%s\n' "$watch" "$rc" >> "$out/results.tsv" +done +python3 "$scripts/check_protection.py" "$out" "$arm" From 3c391bf43dcc70bb212ccb599a22ea672f376aa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 7 Nov 2026 10:56:25 +0000 Subject: [PATCH 9/9] fix: share the census writable bulk transition --- crates/perry-runtime/src/snap_census/runner.rs | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 5114db6afe0..69a92d50a3a 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -1998,16 +1998,9 @@ unsafe fn apply_protection(st: &mut State) { }); return; } - // A conflict can join D while the first-write watcher is running in - // the weights run. Ending that watcher restores RW even if d_mode is - // still set; bulk protection must agree with policy for every page. - if st.noprotect { - for_each_tracked_range(st, |b, e| { - libc::mprotect(b as *mut _, e - b, libc::PROT_READ | libc::PROT_WRITE); - }); - return; - } - if st.top_mode { + // Ending the first-write watcher restores RW in the weights run, + // even if a conflict has set d_mode. Bulk protection agrees with policy. + if st.top_mode || st.noprotect { for_each_tracked_range(st, |b, e| { libc::mprotect(b as *mut _, e - b, libc::PROT_READ | libc::PROT_WRITE); });