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 01/22] 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 02/22] 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 03/22] 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 04/22] 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 05/22] 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 06/22] 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 07/22] 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 08/22] 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 09/22] 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); }); From a44cd9804ce96bbbb3159ea75399a7f2e002715d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 7 Nov 2026 14:56:37 +0000 Subject: [PATCH 10/22] feat: census funnels, per-read argv sites, clock inputs and pc checks (SNAP25) Instrumented builds only (cfg(perry_gc_instruments)); the production build is unchanged. PERRY_SNAP25=0 restores the SNAP23 rows. - Native rows carry the captured return pcs. - Funnels (exec_path, stack_maps, stack_bounds, parse, parallelism, gc, exec_argv) tag the syscalls and native rows issued inside them; the GC heap budget's first computation is in the gc funnel. - navigator.hardwareConcurrency is an input call; env reads inside the exec_argv funnel are program inputs (guard members, never knobs). - unsetenv/setenv rows record whether the name was set; timer, uptime, console.time and GC-stats clocks are input rows. - Every Error stack capture records whether its pcs lie in the executable text. - Census compiles mark each process.argv read: length, constant index (Number or Integer literal) or escape. - The fault handler brings a tracked page whose policy is open but whose protection is still an earlier bulk protect back to its policy instead of treating the fault as foreign. --- crates/perry-codegen/src/expr/dispatch.rs | 1 + crates/perry-codegen/src/expr/env_clones.rs | 5 +- crates/perry-codegen/src/expr/mod.rs | 1 + .../perry-codegen/src/expr/snap_argv_site.rs | 72 +++++++ .../src/runtime_decls/strings.rs | 1 + crates/perry-runtime/src/builtins/console.rs | 2 + crates/perry-runtime/src/dyn_eval/mod.rs | 2 + .../perry-runtime/src/error_stack_frames.rs | 1 + crates/perry-runtime/src/gc/copying.rs | 1 + crates/perry-runtime/src/gc/cycle.rs | 2 + crates/perry-runtime/src/gc/heap_budget.rs | 3 + crates/perry-runtime/src/gc/mod.rs | 4 + crates/perry-runtime/src/gc/policy.rs | 5 + .../src/gc/roots/stack_maps_sections.rs | 3 + crates/perry-runtime/src/native_stack.rs | 1 + crates/perry-runtime/src/navigator.rs | 20 +- crates/perry-runtime/src/node_vm.rs | 5 +- crates/perry-runtime/src/os.rs | 6 +- crates/perry-runtime/src/process.rs | 2 + crates/perry-runtime/src/snap_census.rs | 81 ++++++++ .../src/snap_census/input_record.rs | 47 ++++- .../perry-runtime/src/snap_census/runner.rs | 193 +++++++++++++++++- crates/perry-runtime/src/timer/store.rs | 2 + scripts/snap_inputs/check25.py | 85 ++++++++ scripts/snap_inputs/fixtures/snap25/argv/m.ts | 1 + .../snap_inputs/fixtures/snap25/argv/main.ts | 3 + .../fixtures/snap25/argv/package.json | 1 + .../snap_inputs/fixtures/snap25/clock/m.ts | 1 + .../snap_inputs/fixtures/snap25/clock/main.ts | 3 + .../fixtures/snap25/clock/package.json | 1 + .../snap_inputs/fixtures/snap25/errpc/m.ts | 1 + .../snap_inputs/fixtures/snap25/errpc/main.ts | 3 + .../fixtures/snap25/errpc/package.json | 1 + .../snap_inputs/fixtures/snap25/execargv/m.ts | 1 + .../fixtures/snap25/execargv/main.ts | 3 + .../fixtures/snap25/execargv/package.json | 1 + scripts/snap_inputs/fixtures/snap25/gc/m.ts | 1 + .../snap_inputs/fixtures/snap25/gc/main.ts | 3 + .../fixtures/snap25/gc/package.json | 1 + .../fixtures/snap25/navigator/m.ts | 1 + .../fixtures/snap25/navigator/main.ts | 3 + .../fixtures/snap25/navigator/package.json | 1 + .../snap_inputs/fixtures/snap25/parse/m.ts | 5 + .../snap_inputs/fixtures/snap25/parse/main.ts | 3 + .../fixtures/snap25/parse/package.json | 1 + .../snap_inputs/fixtures/snap25/unsetenv/m.ts | 1 + .../fixtures/snap25/unsetenv/main.ts | 3 + .../fixtures/snap25/unsetenv/package.json | 1 + scripts/snap_inputs/trace.py | 30 ++- 49 files changed, 602 insertions(+), 17 deletions(-) create mode 100644 crates/perry-codegen/src/expr/snap_argv_site.rs create mode 100644 scripts/snap_inputs/check25.py create mode 100644 scripts/snap_inputs/fixtures/snap25/argv/m.ts create mode 100644 scripts/snap_inputs/fixtures/snap25/argv/main.ts create mode 100644 scripts/snap_inputs/fixtures/snap25/argv/package.json create mode 100644 scripts/snap_inputs/fixtures/snap25/clock/m.ts create mode 100644 scripts/snap_inputs/fixtures/snap25/clock/main.ts create mode 100644 scripts/snap_inputs/fixtures/snap25/clock/package.json create mode 100644 scripts/snap_inputs/fixtures/snap25/errpc/m.ts create mode 100644 scripts/snap_inputs/fixtures/snap25/errpc/main.ts create mode 100644 scripts/snap_inputs/fixtures/snap25/errpc/package.json create mode 100644 scripts/snap_inputs/fixtures/snap25/execargv/m.ts create mode 100644 scripts/snap_inputs/fixtures/snap25/execargv/main.ts create mode 100644 scripts/snap_inputs/fixtures/snap25/execargv/package.json create mode 100644 scripts/snap_inputs/fixtures/snap25/gc/m.ts create mode 100644 scripts/snap_inputs/fixtures/snap25/gc/main.ts create mode 100644 scripts/snap_inputs/fixtures/snap25/gc/package.json create mode 100644 scripts/snap_inputs/fixtures/snap25/navigator/m.ts create mode 100644 scripts/snap_inputs/fixtures/snap25/navigator/main.ts create mode 100644 scripts/snap_inputs/fixtures/snap25/navigator/package.json create mode 100644 scripts/snap_inputs/fixtures/snap25/parse/m.ts create mode 100644 scripts/snap_inputs/fixtures/snap25/parse/main.ts create mode 100644 scripts/snap_inputs/fixtures/snap25/parse/package.json create mode 100644 scripts/snap_inputs/fixtures/snap25/unsetenv/m.ts create mode 100644 scripts/snap_inputs/fixtures/snap25/unsetenv/main.ts create mode 100644 scripts/snap_inputs/fixtures/snap25/unsetenv/package.json diff --git a/crates/perry-codegen/src/expr/dispatch.rs b/crates/perry-codegen/src/expr/dispatch.rs index 6d11f4320d6..742bdaf8da8 100644 --- a/crates/perry-codegen/src/expr/dispatch.rs +++ b/crates/perry-codegen/src/expr/dispatch.rs @@ -53,6 +53,7 @@ fn lower_expr_inner(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // SNAP census (instrumented compiles only): an env read whose value this // expression stores is a store site (`expr/snap_env_site.rs`). super::snap_env_site::note_store_parent(ctx, expr); + super::snap_argv_site::note_parent(expr); // #9856: under `--debug-symbols`, record this expression's source line // for the DWARF line table (`crate::debug_info`). No-op otherwise. crate::debug_info::mark_expr_location(ctx, expr); diff --git a/crates/perry-codegen/src/expr/env_clones.rs b/crates/perry-codegen/src/expr/env_clones.rs index 81af7e9f3fe..473f675cc19 100644 --- a/crates/perry-codegen/src/expr/env_clones.rs +++ b/crates/perry-codegen/src/expr/env_clones.rs @@ -403,7 +403,10 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // -------- process.pid / process.ppid — raw f64 number -------- Expr::ProcessPid => Ok(ctx.block().call(DOUBLE, "js_process_pid", &[])), Expr::ProcessPpid => Ok(ctx.block().call(DOUBLE, "js_process_ppid", &[])), - Expr::ProcessArgv => Ok(ctx.block().call(DOUBLE, "js_process_argv_value", &[])), + Expr::ProcessArgv => { + super::snap_argv_site::emit(ctx, expr); + Ok(ctx.block().call(DOUBLE, "js_process_argv_value", &[])) + } // -------- structuredClone(v[, options]) — real deep copy -------- Expr::StructuredClone { value, options } => { diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index b682ee4ba91..543d9876137 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -105,6 +105,7 @@ pub(crate) mod hot_tls; pub(crate) mod inline_birth; pub(crate) mod literal_chunks; mod literal_descriptor; +pub(crate) mod snap_argv_site; pub(crate) mod snap_env_site; /// A worker program's literal-descriptor IR, for the dialect reader's round trip. #[cfg(test)] diff --git a/crates/perry-codegen/src/expr/snap_argv_site.rs b/crates/perry-codegen/src/expr/snap_argv_site.rs new file mode 100644 index 00000000000..ba5fa8feab6 --- /dev/null +++ b/crates/perry-codegen/src/expr/snap_argv_site.rs @@ -0,0 +1,72 @@ +//! SNAP25 census: the grain of a `process.argv` read, emitted in +//! instrumented compiles only (`PERRY_SNAP_CENSUS` set while compiling). An +//! ordinary compile emits nothing. +//! +//! `process.argv.length` is a length read (kind 1), `process.argv[]` +//! an element read (kind 2, the index is the call's second argument; +//! `process.execPath` is a metadata property, not an argv read), and every other use lets the +//! whole array escape (kind 0). Codegen calls `js_snap_argv_site(kind, index)` +//! right before `js_process_argv_value`; no table is emitted. + +use std::cell::RefCell; + +use perry_hir::Expr; + +use crate::types::{I32, I64}; + +use super::FnCtx; + +thread_local! { + /// `process.argv` expressions (by address) whose parent reads one grain. + static SITES: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// `expr` is about to be lowered: note the grain of a direct `process.argv` child. +pub(crate) fn note_parent(expr: &Expr) { + if !super::snap_env_site::census_compile() { + return; + } + let site = match expr { + Expr::PropertyGet { + object, property, .. + } if matches!(**object, Expr::ProcessArgv) && property == "length" => { + Some((&**object, 1, 0)) + } + Expr::IndexGet { object, index, .. } if matches!(**object, Expr::ProcessArgv) => { + match **index { + Expr::Number(n) if n >= 0.0 && n.fract() == 0.0 && n < 1e9 => { + Some((&**object, 2, n as i64)) + } + // Literal indices that fit an integer lower as Expr::Integer. + Expr::Integer(n) if n >= 0 => Some((&**object, 2, n)), + _ => None, + } + } + _ => None, + }; + if let Some((child, kind, index)) = site { + SITES.with(|s| s.borrow_mut().push((child as *const Expr as usize, kind, index))); + } +} + +/// Emit the site hook for the `process.argv` read `expr`, right before its runtime call. +pub(crate) fn emit(ctx: &mut FnCtx<'_>, expr: &Expr) { + if !super::snap_env_site::census_compile() { + return; + } + let addr = expr as *const Expr as usize; + let (kind, index) = SITES.with(|s| { + let mut s = s.borrow_mut(); + match s.iter().position(|&(a, _, _)| a == addr) { + Some(i) => { + let (_, k, x) = s.swap_remove(i); + (k, x) + } + None => (0, 0), + } + }); + ctx.block().call_void( + "js_snap_argv_site", + &[(I32, &kind.to_string()), (I64, &index.to_string())], + ); +} diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index f5a917c290f..636a9b5d339 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -965,6 +965,7 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { // SNAP census site hook (`expr/snap_env_site.rs`): instrumented compiles only. if crate::expr::snap_env_site::census_compile() { module.declare_function("js_snap_env_site", VOID, &[I32]); + module.declare_function("js_snap_argv_site", VOID, &[I32, I64]); module.declare_function("js_snap_require_edge", VOID, &[I32]); } // #1344: process.env.X = v / delete process.env.X. diff --git a/crates/perry-runtime/src/builtins/console.rs b/crates/perry-runtime/src/builtins/console.rs index b67cf0b0c4e..137a87994e6 100644 --- a/crates/perry-runtime/src/builtins/console.rs +++ b/crates/perry-runtime/src/builtins/console.rs @@ -797,6 +797,8 @@ pub extern "C" fn js_console_time(label_ptr: *const StringHeader) { // Capture wall-clock start before any string decoding or TLS overhead // so the stored Instant reflects the call site, not the bookkeeping cost. let start = Instant::now(); + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + crate::snap_census::clock_input("clock.console_time", 0); let label = unsafe { label_from_str_ptr(label_ptr) }; CONSOLE_TIMERS.with( #[inline(never)] diff --git a/crates/perry-runtime/src/dyn_eval/mod.rs b/crates/perry-runtime/src/dyn_eval/mod.rs index 1fc78eec5de..effa2af8351 100644 --- a/crates/perry-runtime/src/dyn_eval/mod.rs +++ b/crates/perry-runtime/src/dyn_eval/mod.rs @@ -688,6 +688,7 @@ fn script_statements(script: &InterpFn) -> &[ast::Stmt] { } fn parse_script_statements(source: &str) -> Vec { + crate::snap_funnel!(Parse); let mut cache = perry_diagnostics_cache(); let parsed = perry_parser::parse_typescript_with_cache(source, "perry-vm-script.cjs", &mut cache) @@ -778,6 +779,7 @@ fn prepare_source(source: &str) -> f64 { // on sloppy semantics (find-my-way assigns the undeclared `value`), and // module auto-detection must not kick in on `import(`-looking substrings. let mut cache = perry_diagnostics_cache(); + crate::snap_funnel!(Parse); let parsed = match perry_parser::parse_typescript_with_cache(source, "perry-dyn-fn.cjs", &mut cache) { Ok(p) => p, diff --git a/crates/perry-runtime/src/error_stack_frames.rs b/crates/perry-runtime/src/error_stack_frames.rs index 44d4aa1416d..80c4d196bc9 100644 --- a/crates/perry-runtime/src/error_stack_frames.rs +++ b/crates/perry-runtime/src/error_stack_frames.rs @@ -453,6 +453,7 @@ fn capture_encoded( 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); + crate::snap_census::pc_check(&pcs[..n]); if diag_enabled() { eprintln!( "[stackdiag] capture: frames={n} want={want} top={:#x} fp0={:#x} pcs={:x?}", diff --git a/crates/perry-runtime/src/gc/copying.rs b/crates/perry-runtime/src/gc/copying.rs index d020a890d53..98318e95ba0 100644 --- a/crates/perry-runtime/src/gc/copying.rs +++ b/crates/perry-runtime/src/gc/copying.rs @@ -929,6 +929,7 @@ pub(super) fn run_copied_minor_attempt( _trigger_kind: GcTriggerKind, may_speculate: bool, ) -> CopiedMinorAttempt { + crate::snap_funnel!(Gc); let _heap_change = crate::gc::heap_generation::HeapChange::begin( crate::gc::heap_generation::HeapChangeKind::CopyingMinor, ); diff --git a/crates/perry-runtime/src/gc/cycle.rs b/crates/perry-runtime/src/gc/cycle.rs index cdeffb0676d..4abc18f4078 100644 --- a/crates/perry-runtime/src/gc/cycle.rs +++ b/crates/perry-runtime/src/gc/cycle.rs @@ -648,6 +648,7 @@ pub(super) struct GcCycleState { impl GcCycleState { pub(super) fn new_full(trigger: GcTriggerSnapshot) -> Self { + crate::snap_funnel!(Gc); super::allocation_pacing::begin_full(); // Build the lazy stack-map index HERE, not only at the entry points. // @@ -843,6 +844,7 @@ impl GcCycleState { &mut self, budget: GcWorkBudget, ) -> GcCycleStepResult { + crate::snap_funnel!(Gc); let _allocation_guard = super::allocation_pacing::CollectorStepGuard::enter(); let phase_before = self.phase; if self.phase == GcCyclePhase::Complete { diff --git a/crates/perry-runtime/src/gc/heap_budget.rs b/crates/perry-runtime/src/gc/heap_budget.rs index 1a3f0f2e0e3..6fd04f48cfd 100644 --- a/crates/perry-runtime/src/gc/heap_budget.rs +++ b/crates/perry-runtime/src/gc/heap_budget.rs @@ -39,6 +39,9 @@ use super::policy::{ pub(crate) fn gc_heap_budget_bytes() -> Option { static CACHED: OnceLock> = OnceLock::new(); *crate::once_init::get_or_init(&CACHED, || { + // The heap budget is the collector's own sizing (first asked by an + // allocation slow path, outside any collection). + crate::snap_funnel!(Gc); if let Ok(v) = std::env::var("PERRY_GC_HEAP_LIMIT") { if let Ok(mb) = v.trim().parse::() { if mb > 0 { diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 1ec7c92a718..b7d190fa002 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -431,6 +431,7 @@ fn gc_collect_minor_with_diagnostics( escalation: FullEscalation, copying: CopyingFastPath, ) -> GcCollectOutcome { + crate::snap_funnel!(Gc); // PERRY_GC_SAFEPOINT_ONLY: held for the whole collection so every // consumer of the scan decision (root scan, copying eligibility, // evacuation pinning, verifier) sees the same healed answer. @@ -901,6 +902,7 @@ pub(crate) fn test_collect_synchronous_full_trace() { } fn gc_collect_full_mark_sweep_with_trigger(trigger: GcTriggerSnapshot) -> GcCollectOutcome { + crate::snap_funnel!(Gc); // Build the stack-map index if it is still owed. This is the chokepoint: // every collection funnels through one of these three entries, and here // allocation is still legal — the root scan itself must stay @@ -1764,6 +1766,8 @@ pub extern "C" fn js_gc_stats( out_freed: *mut u64, out_pause_us: *mut u64, ) { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + crate::snap_census::clock_input("clock.gc_stats", 0); GC_STATS.with(|stats| { let stats = stats.borrow(); unsafe { diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index c98ac4ed6ae..0a980f4fd70 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -4398,6 +4398,7 @@ fn gc_start_budgeted_minor_fallback_cycle_with_snapshot( rebaseline: BudgetedGcRebaseline, progress_kind: GcProgressKind, ) -> BudgetedGcCycle { + crate::snap_funnel!(Gc); // Same direct-constructor path as gc_start_budgeted_full_cycle above. crate::arena::sync_inline_arena_state(); super::roots::ensure_stack_maps_built(); @@ -4878,6 +4879,7 @@ pub(super) fn gc_idle_reclaim_try_start() -> bool { /// is active the stepper's pressure check runs exactly as it would at any host /// safepoint. pub(super) fn gc_idle_reclaim_step(budget_us: u64) -> GcStepReport { + crate::snap_funnel!(Gc); let start = Instant::now(); let mut result = gc_budgeted_step_work_units_inner_with_progress( GC_NORMAL_INCREMENTAL_WORK_UNITS, @@ -4976,6 +4978,7 @@ fn gc_budgeted_start_or_step( start_progress_kind: GcProgressKind, automatic: bool, ) -> GcStepReport { + crate::snap_funnel!(Gc); if let Some(due) = due { if start_progress_kind.is_budgeted() && (due == BudgetedGcTrigger::YoungScavengeCap @@ -5147,6 +5150,7 @@ pub extern "C" fn js_gc_step_work_units(work_units: u64, out: *mut JsGcStepResul #[no_mangle] pub extern "C" fn js_gc_step_us(budget_us: u64, out: *mut JsGcStepResult) -> u32 { + crate::snap_funnel!(Gc); if budget_us == 0 { let result = gc_budgeted_status_result().with_debt(); return write_gc_step_result(out, result); @@ -5221,6 +5225,7 @@ extern "C" fn js_gc_collect_from(pc: usize, sp: usize, fp: usize) { } fn js_gc_collect_body() { + crate::snap_funnel!(Gc); if crate::snap_census::explicit_gc() { return; } diff --git a/crates/perry-runtime/src/gc/roots/stack_maps_sections.rs b/crates/perry-runtime/src/gc/roots/stack_maps_sections.rs index ba8670652a6..0336dcafcd7 100644 --- a/crates/perry-runtime/src/gc/roots/stack_maps_sections.rs +++ b/crates/perry-runtime/src/gc/roots/stack_maps_sections.rs @@ -22,6 +22,7 @@ use std::ffi::c_void; /// 32-bit blob-relative offsets on every target.) #[cfg(target_vendor = "apple")] pub(super) fn loaded_stack_map_sections() -> Result, String> { + crate::snap_funnel!(StackMaps); use mach2::dyld::{_dyld_get_image_header, _dyld_get_image_vmaddr_slide, _dyld_image_count}; const LC_SEGMENT_64: u32 = 0x19; @@ -134,6 +135,7 @@ pub(super) fn loaded_stack_map_sections() -> Result, String> #[cfg(not(any(target_vendor = "apple", target_os = "linux")))] pub(super) fn loaded_stack_map_sections() -> Result, String> { + crate::snap_funnel!(StackMaps); Ok(loaded_stack_map_section().into_iter().collect()) } @@ -149,6 +151,7 @@ pub(super) fn loaded_stack_map_sections() -> Result, String> /// disappear from the collector exactly when a full collection evacuates. #[cfg(target_os = "linux")] pub(super) fn loaded_stack_map_sections() -> Result, String> { + crate::snap_funnel!(StackMaps); use std::ffi::CStr; use std::os::unix::ffi::OsStrExt; use std::path::Path; diff --git a/crates/perry-runtime/src/native_stack.rs b/crates/perry-runtime/src/native_stack.rs index 6d431621891..25a63229ed2 100644 --- a/crates/perry-runtime/src/native_stack.rs +++ b/crates/perry-runtime/src/native_stack.rs @@ -61,6 +61,7 @@ pub(crate) fn stack_top() -> usize { /// consistency check. #[cfg(target_os = "linux")] fn main_thread_bounds() -> Option<(usize, usize)> { + crate::snap_funnel!(StackBounds); // The main thread is the one whose tid is the pid. Every other thread // (the runtime's own, or an embedder's) takes the pthread path, whose // answer is read from its descriptor. diff --git a/crates/perry-runtime/src/navigator.rs b/crates/perry-runtime/src/navigator.rs index c82f470f7a2..f3ecd4316ad 100644 --- a/crates/perry-runtime/src/navigator.rs +++ b/crates/perry-runtime/src/navigator.rs @@ -201,9 +201,23 @@ pub(crate) fn navigator_object_with_constructor(constructor: f64) -> f64 { }); // hardwareConcurrency: recommended parallelism (>= 1). - let cores = std::thread::available_parallelism() - .map(|n| n.get() as f64) - .unwrap_or(1.0); + let cores = { + crate::snap_funnel!(Parallelism); + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + { + crate::snap_census::input_call("navigator.hardwareConcurrency", "()", || { + std::thread::available_parallelism() + .map(|n| n.get() as f64) + .unwrap_or(1.0) + }) + } + #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] + { + std::thread::available_parallelism() + .map(|n| n.get() as f64) + .unwrap_or(1.0) + } + }; obj.with_mut_ptr(|obj| js_object_set_field(obj, 3, JSValue::number(cores))); // platform: browser-style token. diff --git a/crates/perry-runtime/src/node_vm.rs b/crates/perry-runtime/src/node_vm.rs index 9ec0c972f6a..9cdae4d2fa5 100644 --- a/crates/perry-runtime/src/node_vm.rs +++ b/crates/perry-runtime/src/node_vm.rs @@ -80,7 +80,10 @@ mod de { ) } pub(super) fn validate_module_source(source: &str) -> bool { - perry_parser::parse_typescript(source, "perry-vm-module.mjs").is_ok() + { + crate::snap_funnel!(Parse); + perry_parser::parse_typescript(source, "perry-vm-module.mjs").is_ok() + } } } diff --git a/crates/perry-runtime/src/os.rs b/crates/perry-runtime/src/os.rs index 4a8acd7657e..4ce246f3fad 100644 --- a/crates/perry-runtime/src/os.rs +++ b/crates/perry-runtime/src/os.rs @@ -549,6 +549,8 @@ pub extern "C" fn js_os_version() -> *mut StringHeader { /// Get the process uptime in seconds (time since process started) #[no_mangle] pub extern "C" fn js_process_uptime() -> f64 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + crate::snap_census::clock_input("clock.uptime", 0); get_process_start().elapsed().as_secs_f64() } @@ -594,7 +596,7 @@ pub unsafe extern "C" fn js_set_process_entry_path(ptr: *const u8, len: u32) { 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!("{:?}", ()), || { + let value = 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) = @@ -605,6 +607,8 @@ pub extern "C" fn js_process_argv_value() -> f64 { } crate::value::js_nanbox_pointer(js_process_argv() as i64) }); + crate::snap_census::argv_read(value); + return value; } crate::effect!(host_read, argv); diff --git a/crates/perry-runtime/src/process.rs b/crates/perry-runtime/src/process.rs index a821b50c544..33ca471e69b 100644 --- a/crates/perry-runtime/src/process.rs +++ b/crates/perry-runtime/src/process.rs @@ -407,6 +407,7 @@ pub(crate) fn module_builtin_modules_array() -> f64 { fn process_exec_argv_value() -> f64 { crate::effect!(host_read, argv); + crate::snap_funnel!(ExecArgv); static EXEC_ARGV: OnceLock> = OnceLock::new(); let values = crate::once_init::get_or_init(&EXEC_ARGV, || { let raw = std::env::var("PERRY_PROCESS_EXEC_ARGV").unwrap_or_else(|_| "[]".to_string()); @@ -453,6 +454,7 @@ pub(crate) fn process_argv0_string() -> String { /// example, after unlinking the executable). Windows already supplies an /// absolute path, and canonicalize would introduce a verbatim-path prefix. pub(crate) fn process_exec_path_string() -> String { + crate::snap_funnel!(ExecPath); std::env::current_exe() .map(|exe| { #[cfg(unix)] diff --git a/crates/perry-runtime/src/snap_census.rs b/crates/perry-runtime/src/snap_census.rs index 3c55a6d7c73..2cb9a9356b4 100644 --- a/crates/perry-runtime/src/snap_census.rs +++ b/crates/perry-runtime/src/snap_census.rs @@ -642,6 +642,87 @@ pub extern "C" fn js_snap_env_site(kind: i32) { let _ = kind; } +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +/// SNAP25 funnels: the runtime routine a native read is issued by. A census +/// row (and, through an lseek marker, the syscall companion) names the +/// innermost funnel open on its thread; the class follows from the funnel. +#[derive(Clone, Copy)] +#[repr(u8)] +pub(crate) enum Funnel { + ExecPath = 1, + StackMaps = 2, + StackBounds = 3, + Parse = 4, + Parallelism = 5, + Gc = 6, + ExecArgv = 7, +} + +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +#[inline(always)] +pub(crate) fn funnel_enter(f: Funnel) -> runner::FunnelScope { + runner::FunnelScope::enter(f as u8) +} + +/// Open a SNAP25 funnel for the rest of the enclosing block, in the +/// instrumented Linux cohort only; nothing in any other build. +#[macro_export] +macro_rules! snap_funnel { + ($f:ident) => { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + let _snap_funnel = $crate::snap_census::funnel_enter($crate::snap_census::Funnel::$f); + }; +} + +/// Codegen calls this right before a `process.argv` read, in instrumented +/// compiles only (`expr/snap_argv_site.rs`): `kind` 1 = the read is +/// `process.argv.length`, 2 = `process.argv[index]` with a constant index, +/// 0 = any other use (the whole array escapes). Kept for the next +/// `js_process_argv_value` on this thread. +#[cfg(perry_gc_instruments)] +#[no_mangle] +pub extern "C" fn js_snap_argv_site(kind: i32, index: i64) { + crate::effect!(pure); + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + runner::argv_site(kind, index); + } + #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] + let _ = (kind, index); +} + +/// SNAP25: one `process.argv` read at its finest grain (`argv.read` row). +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +pub(crate) fn argv_read(value: f64) { + if runner::on() { + runner::argv_read(value); + } +} + +/// SNAP25: a JS-reachable clock read (timer creation, `process.uptime`, +/// `console.time`, GC stats): a clock-class census input. +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +pub(crate) fn clock_input(operation: &str, value: u64) { + if runner::c25() { + record_input_value(operation, &value); + } +} + +/// SNAP25: every captured Error pc must lie in the executable text of an +/// EXEC (non-PIE) image (`stack.pc_check` row). +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +pub(crate) fn pc_check(pcs: &[usize]) { + if runner::c25() && runner::inputs_on() && runner::on_main_thread() { + let (exec, lo, hi) = runner::exe_text(); + let out = pcs.iter().filter(|&&pc| pc < lo || pc >= hi).count(); + let _scope = runner::input_record::Scope::enter(); + runner::input_event( + "stack.pc_check", + format!("exec={};out={out};n={};text={lo:x}-{hi:x}", exec as u8, pcs.len()), + ); + } +} + /// Codegen calls this around a literal CommonJS require edge's init-guard /// call, in instrumented compiles only (`cjsRequireEdge`): `on` 1 before, 0 /// after. The evaluation the guard starts was reached by `require` (a static diff --git a/crates/perry-runtime/src/snap_census/input_record.rs b/crates/perry-runtime/src/snap_census/input_record.rs index 1f3e97a974f..246721f1baf 100644 --- a/crates/perry-runtime/src/snap_census/input_record.rs +++ b/crates/perry-runtime/src/snap_census/input_record.rs @@ -250,6 +250,17 @@ pub(super) fn native_event(st: &mut State, op: &str, value: std::fmt::Arguments< libc::_exit(93); } } + let mut six = [0usize; 6]; + if super::c25() { + if super::funnel() != 0 && !op.starts_with("getenv") { + let _ = write!(text, ";funnel={}", super::funnel_name(super::funnel())); + } + let mut pcs = [0usize; crate::error::MAX_CAPTURED_FRAMES]; + let n = crate::error::capture_ips(&mut pcs); + for (to, from) in six.iter_mut().zip(pcs[..n].iter().skip(1)) { + *to = *from; + } + } let mut row = SignalRow { bytes: [0; 131072], len: 0, @@ -272,7 +283,14 @@ pub(super) fn native_event(st: &mut State, op: &str, value: std::fmt::Arguments< for b in &text.bytes[..text.len] { write!(row, "{b:02x}")?; } - writeln!(row, "\t0,0,0,0,0,0\t{}", st.inputs.allocated_bytes) + row.write_char('\t')?; + for (i, pc) in six.iter().enumerate() { + if i != 0 { + row.write_char(',')?; + } + write!(row, "{pc:x}")?; + } + writeln!(row, "\t{}", st.inputs.allocated_bytes) })(); if result.is_err() { unsafe { @@ -319,7 +337,7 @@ pub(super) fn env_rows(st: &State) -> std::io::Result = line.split('\t').collect(); - if fields.get(4) != Some(&"getenv") { + if fields.get(4) != Some(&"getenv") && fields.get(4) != Some(&"getenv.program") { continue; } let value = String::from_utf8(unhex(fields[5])?) @@ -342,6 +360,31 @@ pub(super) fn env_rows(st: &State) -> std::io::Result Vec { + use std::io::BufRead; + let mut out = Vec::new(); + let Ok(f) = std::fs::File::open(format!("{}.inputs.tsv", st.out)) else { + return out; + }; + for line in std::io::BufReader::new(f).lines().map_while(Result::ok) { + let fields: Vec<_> = line.split('\t').collect(); + if fields.get(4) == Some(&"getenv.program") { + let raw = fields[5]; + let bytes: Vec = (0..raw.len() / 2) + .filter_map(|i| u8::from_str_radix(&raw[2 * i..2 * i + 2], 16).ok()) + .collect(); + let text = String::from_utf8_lossy(&bytes).into_owned(); + let name = text.split_once('=').map_or(text.as_str(), |(n, _)| n); + let name: Vec = (0..name.len() / 2) + .filter_map(|i| u8::from_str_radix(&name[2 * i..2 * i + 2], 16).ok()) + .collect(); + out.push(String::from_utf8_lossy(&name).into_owned()); + } + } + out +} pub(super) fn hex(bytes: &[u8]) -> String { let mut out = String::with_capacity(bytes.len() * 2); for b in bytes { diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 69a92d50a3a..87917e2fd67 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -1116,6 +1116,7 @@ unsafe fn start_census(out: String) { } } st.inputs.watch_enabled = !std::env::var("PERRY_SNAP22_WATCH").is_ok_and(|v| v == "0"); + C25.store(!std::env::var("PERRY_SNAP25").is_ok_and(|v| v == "0"), Relaxed); 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(); @@ -2285,7 +2286,16 @@ unsafe fn on_segv_inner( return; } let tracked = page_flags(st, page).is_some_and(|f| *f & PF_TRACKED != 0); - if !tracked || policy(st, page) == libc::PROT_READ | libc::PROT_WRITE { + // A tracked page whose policy is open now but whose protection is + // still a bulk protect of an earlier mode (the watch ended while the + // page stayed read-only): bring the page to its policy and retry. An + // extra closed page only costs a fault; it never hides a write. + if tracked && policy(st, page) == libc::PROT_READ | libc::PROT_WRITE { + libc::mprotect(page as *mut _, PAGE, libc::PROT_READ | libc::PROT_WRITE); + st.faults_ignored += 1; + return; + } + if !tracked { let uc = ctx as *mut libc::ucontext_t; let pc = (*uc).uc_mcontext.gregs[libc::REG_RIP as usize] as u64; let flags = page_flags(st, page).map_or(0xff, |f| *f as u64); @@ -2795,16 +2805,17 @@ pub unsafe extern "C" fn getenv(name: *const libc::c_char) -> *mut libc::c_char if inputs_on() && on_main_thread() { let _input_logging = input_record::Scope::enter(); let st = state(); + let op = if c25() && FUNNEL.get() == 7 { "getenv.program" } else { "getenv" }; if found.is_null() { input_record::native_event( st, - "getenv", + op, format_args!("{}=unset", input_record::Hex(key)), ); } else { input_record::native_event( st, - "getenv", + op, format_args!( "{}={}", input_record::Hex(key), @@ -4406,8 +4417,9 @@ fn write_env_report(st: &State, f: &mut std::fs::File, rank: &[u64]) { _ => {} } } + let program = input_record::program_env_names(st); for (init, name, value) in &st.env_reads { - if name.starts_with("PERRY_") || name.starts_with("RUST_") { + if (name.starts_with("PERRY_") || name.starts_with("RUST_")) && !program.contains(name) { let _ = writeln!(f, "#knob\t{name}\t{init}"); continue; } @@ -4567,10 +4579,15 @@ pub unsafe extern "C" fn unsetenv(name: *const libc::c_char) -> libc::c_int { } else { std::ffi::CStr::from_ptr(name).to_bytes() }; + let was = if c25() { + if name.is_null() || getenv(name).is_null() { ";was=unset" } else { ";was=set" } + } else { + "" + }; input_record::native_event( state(), "native.unsetenv", - format_args!("{}", input_record::Hex(key)), + format_args!("{}{was}", input_record::Hex(key)), ); host(Effect::HostWrite, "env"); } @@ -4604,9 +4621,16 @@ pub unsafe extern "C" fn setenv( state(), "native.setenv", format_args!( - "{}={};overwrite={overwrite}", + "{}={};overwrite={overwrite}{}", input_record::Hex(key), - input_record::Hex(val) + input_record::Hex(val), + if !c25() { + "" + } else if getenv(name).is_null() { + ";was=unset" + } else { + ";was=set" + } ), ); host(Effect::HostWrite, "env"); @@ -4621,3 +4645,158 @@ pub unsafe extern "C" fn setenv( *libc::__errno_location() = saved_errno; call(name, value, overwrite) } + +// --------------------------------------------------------------------------- +// SNAP25: funnels, per-read argv, executable text (census only) +// --------------------------------------------------------------------------- + +/// `PERRY_SNAP25=0` is the off arm: the census rows are exactly SNAP23's. +static C25: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +pub(super) fn c25() -> bool { + C25.load(Relaxed) +} + +#[thread_local] +static FUNNEL: std::cell::Cell = std::cell::Cell::new(0); +#[thread_local] +static ARGV_SITE: std::cell::Cell<(i32, i64)> = std::cell::Cell::new((-1, 0)); + +pub(super) fn funnel() -> u8 { + FUNNEL.get() +} +pub(super) fn funnel_name(id: u8) -> &'static str { + match id { + 1 => "exec_path", + 2 => "stack_maps", + 3 => "stack_bounds", + 4 => "parse", + 5 => "parallelism", + 6 => "gc", + 7 => "exec_argv", + _ => "none", + } +} +/// The syscall companion tags every later syscall of this thread with `id`. +fn funnel_marker(id: u8) { + unsafe { + let saved = *libc::__errno_location(); + libc::syscall( + libc::SYS_lseek, + -1 as libc::c_int, + u64::from(id), + 0x5323 as libc::c_int, + ); + *libc::__errno_location() = saved; + } +} +pub(crate) struct FunnelScope(u8, bool); +impl FunnelScope { + #[inline(always)] + pub(crate) fn enter(id: u8) -> Self { + if !(on() && c25()) { + return Self(0, false); + } + let prev = FUNNEL.replace(id); + funnel_marker(id); + Self(prev, true) + } +} +impl Drop for FunnelScope { + #[inline(always)] + fn drop(&mut self) { + if self.1 { + FUNNEL.set(self.0); + funnel_marker(self.0); + } + } +} + +pub(super) fn argv_site(kind: i32, index: i64) { + ARGV_SITE.set((kind, index)); +} + +/// One `process.argv` read: `length=N`, `index=I;value=V` or `escape`. +pub(super) fn argv_read(value: f64) { + let (kind, index) = ARGV_SITE.replace((-1, 0)); + if !(c25() && inputs_on() && on_main_thread()) { + return; + } + let bits = value.to_bits(); + let arr = if bits >> 48 == 0x7ffd { + (bits & 0x0000_ffff_ffff_ffff) as *mut crate::ArrayHeader + } else { + std::ptr::null_mut() + }; + let text = { + let _scope = input_record::Scope::enter(); + if arr.is_null() { + format!("escape;site={kind};value=non_array:{bits:016x}") + } else { + let len = unsafe { (*arr).length }; + match kind { + 1 => format!("length={len}"), + 2 if index >= 0 && (index as u64) < u64::from(len) => { + let e = unsafe { + (crate::array::array_elements_ptr(arr) as *const f64) + .add(index as usize) + .read() + .to_bits() + }; + let v = if e >> 48 == 0x7fff { + input_record::Value::input_value( + &((e & 0x0000_ffff_ffff_ffff) as *mut crate::StringHeader), + ) + } else { + format!("bits:{e:016x}") + }; + format!("index={index};value={v}") + } + 2 => format!("index={index};value=undefined;length={len}"), + _ => format!("escape;site={kind};length={len}"), + } + } + }; + let _scope = input_record::Scope::enter(); + input_record::event(state(), "argv.read", text); +} + +static EXE_TEXT: [std::sync::atomic::AtomicUsize; 3] = [ + std::sync::atomic::AtomicUsize::new(0), + std::sync::atomic::AtomicUsize::new(0), + std::sync::atomic::AtomicUsize::new(0), +]; +/// (EXEC image, text lo, text hi) of the main executable: its executable +/// PT_LOAD; EXEC when the loader applied no bias (non-PIE). +pub(super) fn exe_text() -> (bool, usize, usize) { + if EXE_TEXT[2].load(Relaxed) == 0 { + unsafe extern "C" fn each( + info: *mut libc::dl_phdr_info, + _size: libc::size_t, + data: *mut libc::c_void, + ) -> libc::c_int { + let out = &mut *(data as *mut (usize, usize, usize)); + let info = &*info; + for i in 0..info.dlpi_phnum as usize { + let ph = &*info.dlpi_phdr.add(i); + if ph.p_type == libc::PT_LOAD && ph.p_flags & libc::PF_X != 0 { + let lo = info.dlpi_addr as usize + ph.p_vaddr as usize; + out.0 = usize::from(info.dlpi_addr == 0); + out.1 = lo; + out.2 = lo + ph.p_memsz as usize; + break; + } + } + 1 // the first object is the executable + } + let mut r = (0usize, 0usize, 0usize); + unsafe { libc::dl_iterate_phdr(Some(each), &mut r as *mut _ as *mut libc::c_void) }; + EXE_TEXT[0].store(r.0, Relaxed); + EXE_TEXT[1].store(r.1, Relaxed); + EXE_TEXT[2].store(r.2.max(1), Relaxed); + } + ( + EXE_TEXT[0].load(Relaxed) == 1, + EXE_TEXT[1].load(Relaxed), + EXE_TEXT[2].load(Relaxed), + ) +} diff --git a/crates/perry-runtime/src/timer/store.rs b/crates/perry-runtime/src/timer/store.rs index ad00ea169ee..d2e730da7aa 100644 --- a/crates/perry-runtime/src/timer/store.rs +++ b/crates/perry-runtime/src/timer/store.rs @@ -411,6 +411,8 @@ impl AgentTimers { /// Insert a timers-phase entry. pub(super) fn insert_timer(&mut self, entry: Entry) -> usize { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + crate::snap_census::clock_input("clock.timer", entry.delay_ms); crate::effect!(host_write, timer); debug_assert!(entry.class.is_timer()); let index = self.alloc(entry); diff --git a/scripts/snap_inputs/check25.py b/scripts/snap_inputs/check25.py new file mode 100644 index 00000000000..f499920ed37 --- /dev/null +++ b/scripts/snap_inputs/check25.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""SNAP25 classification fixtures: check25.py NAME REPORT NATIVE_JSONL. +Exit 0 when the census row/trace carries the SNAP25 class the fixture +exercises; with PERRY_SNAP25=0 (the SNAP23 rows) every fixture must fail.""" +import json,sys +name,report,native=sys.argv[1:4] +rows=[l.rstrip("\n").split("\t") for l in open(report+".inputs.tsv")][1:] +def dec(h): + try:return bytes.fromhex(h).decode("utf8","replace") + except ValueError:return h +ops=[(r[4],dec(r[5]),r[6]) for r in rows if len(r)>6] +# Native traces reach GBs: stream them, keep the markers and the syscalls +# a check looks at (funnel-tagged, named below, or with a path). +WANT={"readlink","readlinkat","sched_getaffinity","getpid","prlimit64"} +def keep(l): + return ("runtime_marker" in l or "funnel_marker" in l or "\"funnel\"" in l or "\"path\"" in l + or any("\"name\":\"%s\"" % n in l for n in WANT)) +nat=[json.loads(l) for l in open(native) if keep(l)] if native!="-" else [] +# The census window: syscalls between the first and the last runtime row +# marker. Process start (setarch, ld.so, the runtime before census_start) and +# everything after the boundary are outside it; funnels are open only inside. +mk=[e["ir_enter"] for e in nat if e.get("kind")=="runtime_marker"] +lo,hi=(min(mk),max(mk)) if mk else (0,-1) +def path(e): + try:return bytes.fromhex(e.get("path") or "").decode("utf8","replace") + except ValueError:return e.get("path") or "" +# Every syscall in the window: a funnelled one may be reclassified (diagnostic). +sc=[e for e in nat if e.get("kind")=="syscall" and lo<=e["ir_enter"]<=hi] +fails=[] +def need(c,msg): + if not c:fails.append(msg) +def vals(op):return [v for o,v,_ in ops if o==op] +nrows=[(o,v,p) for o,v,p in ops if o.startswith("native.")] +if nrows:need(any(p.strip("0,") for _,_,p in nrows),"C1 native rows carry no pcs") +if name=="argv": + r=vals("argv.read") + need(any(v.startswith("length=") for v in r),"no argv length read") + need(any(v.startswith("index=2;value=string:"+"x25".encode().hex()) for v in r),"no argv[2] read") + # process.execPath is a process metadata property (process.rs + # process_exec_path_string: readlink /proc/self/exe), not argv[0]: it is + # the exec_path funnel's readlink below, never an argv.read row. + need(not any(v.startswith("index=0;") for v in r),"execPath read as argv[0]") + need(any(v.startswith("escape") for v in r),"no whole-array escape") + rl=[e for e in sc if e["name"] in("readlink","readlinkat")] + need(rl and all(e.get("funnel")==1 for e in rl),"readlink not in exec_path funnel") +elif name=="navigator": + need(vals("navigator.hardwareConcurrency"),"no hardwareConcurrency input") + # Parallelism = the affinity mask and the cpu cgroup files. The memory + # cgroup files in the window are the GC heap budget (funnel 6), not P1. + cg=[e for e in sc if e["name"]=="sched_getaffinity" or path(e)=="/proc/self/cgroup" or path(e).endswith("/cpu.max")] + need(cg and all(e.get("funnel")==5 for e in cg),"parallelism syscalls untagged") + mem=[e for e in sc if path(e).startswith("/sys/fs/cgroup") and not path(e).endswith("/cpu.max")] + need(all(e.get("funnel") in (5,6) for e in mem),"cgroup read outside a funnel") +elif name=="execargv": + rep=open(report).read().splitlines() + # process.execArgv is a host argv read: its init is in D, and the report + # writes #guard lines for imaged inits only. The class source is the + # getenv.program row (cut25 makes it a guard member); it must not be a knob. + prog=[bytes.fromhex(v.split("=",1)[0]).decode() for v in vals("getenv.program")] + need("PERRY_PROCESS_EXEC_ARGV" in prog,"PERRY_PROCESS_EXEC_ARGV read not a getenv.program row") + need(not any(l.startswith("#knob\tPERRY_PROCESS_EXEC_ARGV") for l in rep),"PERRY_PROCESS_EXEC_ARGV a knob") +elif name=="unsetenv": + u=vals("native.unsetenv") + need(any(v==("SNAP25_X".encode().hex()+";was=unset") for v in u),"unsetenv without was=unset") +elif name=="clock": + for op in("clock.timer","clock.uptime","clock.console_time"):need(vals(op),"no "+op) +elif name=="errpc": + need(any(v.startswith("exec=1;out=0;") for v in vals("stack.pc_check")),"no EXEC/text pc check") +elif name=="gc": + need(any(v.endswith(";funnel=gc") for v in vals("native.clock_gettime")),"no GC-funnel clock read") + need(not [v for v in vals("native.clock_gettime") if ";funnel=" not in v and False],"") + sm=[e for e in sc if e["name"] in("getpid","prlimit64")] + need(sm and all(e.get("funnel") in(2,3,6) for e in sm),"stack bounds syscalls untagged") +elif name=="parse": + # The parse funnel opens (eval + new Function); on the fixture it issues + # no syscall: the only /proc/self/maps read is before census_start + # (SNAP24's "stacker reads maps while parsing" does not hold here). + fm=[e for e in nat if e.get("kind")=="funnel_marker" and e["args"][1]==4 and lo<=e["ir_enter"]<=hi] + need(len(fm)>=2,"parse funnel not entered by eval and new Function") + need(not any(e.get("funnel")==4 and e.get("policy")=="STOP" for e in sc),"STOP syscall in the parse funnel") + mp=[e for e in sc if path(e)=="/proc/self/maps"] + need(all(e.get("funnel")==4 for e in mp),"/proc/self/maps outside the parse funnel") +fails=[f for f in fails if f] +print(name,"PASS" if not fails else "FAIL "+"; ".join(fails)) +sys.exit(1 if fails else 0) diff --git a/scripts/snap_inputs/fixtures/snap25/argv/m.ts b/scripts/snap_inputs/fixtures/snap25/argv/m.ts new file mode 100644 index 00000000000..0cdab318a84 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snap25/argv/m.ts @@ -0,0 +1 @@ +const n = process.argv.length; const a = process.argv[2]; const p = process.execPath; const all = [...process.argv]; export const v = [n, a, p, all.length]; diff --git a/scripts/snap_inputs/fixtures/snap25/argv/main.ts b/scripts/snap_inputs/fixtures/snap25/argv/main.ts new file mode 100644 index 00000000000..b73eac07cd4 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snap25/argv/main.ts @@ -0,0 +1,3 @@ +import { v } from "./m.ts"; +gc(); +console.log("DONE25", typeof v); diff --git a/scripts/snap_inputs/fixtures/snap25/argv/package.json b/scripts/snap_inputs/fixtures/snap25/argv/package.json new file mode 100644 index 00000000000..95e3ab884d7 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snap25/argv/package.json @@ -0,0 +1 @@ +{"name":"snap25-argv","type":"module"} diff --git a/scripts/snap_inputs/fixtures/snap25/clock/m.ts b/scripts/snap_inputs/fixtures/snap25/clock/m.ts new file mode 100644 index 00000000000..6210284e4bb --- /dev/null +++ b/scripts/snap_inputs/fixtures/snap25/clock/m.ts @@ -0,0 +1 @@ +setTimeout(() => {}, 1); const u = process.uptime(); console.time("t25"); console.timeEnd("t25"); export const v = u >= 0; diff --git a/scripts/snap_inputs/fixtures/snap25/clock/main.ts b/scripts/snap_inputs/fixtures/snap25/clock/main.ts new file mode 100644 index 00000000000..b73eac07cd4 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snap25/clock/main.ts @@ -0,0 +1,3 @@ +import { v } from "./m.ts"; +gc(); +console.log("DONE25", typeof v); diff --git a/scripts/snap_inputs/fixtures/snap25/clock/package.json b/scripts/snap_inputs/fixtures/snap25/clock/package.json new file mode 100644 index 00000000000..f2fae5e7d96 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snap25/clock/package.json @@ -0,0 +1 @@ +{"name":"snap25-clock","type":"module"} diff --git a/scripts/snap_inputs/fixtures/snap25/errpc/m.ts b/scripts/snap_inputs/fixtures/snap25/errpc/m.ts new file mode 100644 index 00000000000..b28e14ba2d3 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snap25/errpc/m.ts @@ -0,0 +1 @@ +export const v = new Error("e25").stack; diff --git a/scripts/snap_inputs/fixtures/snap25/errpc/main.ts b/scripts/snap_inputs/fixtures/snap25/errpc/main.ts new file mode 100644 index 00000000000..b73eac07cd4 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snap25/errpc/main.ts @@ -0,0 +1,3 @@ +import { v } from "./m.ts"; +gc(); +console.log("DONE25", typeof v); diff --git a/scripts/snap_inputs/fixtures/snap25/errpc/package.json b/scripts/snap_inputs/fixtures/snap25/errpc/package.json new file mode 100644 index 00000000000..dc9b3395530 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snap25/errpc/package.json @@ -0,0 +1 @@ +{"name":"snap25-errpc","type":"module"} diff --git a/scripts/snap_inputs/fixtures/snap25/execargv/m.ts b/scripts/snap_inputs/fixtures/snap25/execargv/m.ts new file mode 100644 index 00000000000..723f211a015 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snap25/execargv/m.ts @@ -0,0 +1 @@ +export const v = process.execArgv.length; diff --git a/scripts/snap_inputs/fixtures/snap25/execargv/main.ts b/scripts/snap_inputs/fixtures/snap25/execargv/main.ts new file mode 100644 index 00000000000..b73eac07cd4 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snap25/execargv/main.ts @@ -0,0 +1,3 @@ +import { v } from "./m.ts"; +gc(); +console.log("DONE25", typeof v); diff --git a/scripts/snap_inputs/fixtures/snap25/execargv/package.json b/scripts/snap_inputs/fixtures/snap25/execargv/package.json new file mode 100644 index 00000000000..cfe917a8759 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snap25/execargv/package.json @@ -0,0 +1 @@ +{"name":"snap25-execargv","type":"module"} diff --git a/scripts/snap_inputs/fixtures/snap25/gc/m.ts b/scripts/snap_inputs/fixtures/snap25/gc/m.ts new file mode 100644 index 00000000000..9e8fdf470ff --- /dev/null +++ b/scripts/snap_inputs/fixtures/snap25/gc/m.ts @@ -0,0 +1 @@ +const a: object[] = []; for (let i = 0; i < 400000; i++) a.push({ i, s: "x" + i }); export const v = a.length; diff --git a/scripts/snap_inputs/fixtures/snap25/gc/main.ts b/scripts/snap_inputs/fixtures/snap25/gc/main.ts new file mode 100644 index 00000000000..b73eac07cd4 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snap25/gc/main.ts @@ -0,0 +1,3 @@ +import { v } from "./m.ts"; +gc(); +console.log("DONE25", typeof v); diff --git a/scripts/snap_inputs/fixtures/snap25/gc/package.json b/scripts/snap_inputs/fixtures/snap25/gc/package.json new file mode 100644 index 00000000000..16e74470968 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snap25/gc/package.json @@ -0,0 +1 @@ +{"name":"snap25-gc","type":"module"} diff --git a/scripts/snap_inputs/fixtures/snap25/navigator/m.ts b/scripts/snap_inputs/fixtures/snap25/navigator/m.ts new file mode 100644 index 00000000000..4a626b77651 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snap25/navigator/m.ts @@ -0,0 +1 @@ +export const v = [navigator.language, navigator.hardwareConcurrency]; diff --git a/scripts/snap_inputs/fixtures/snap25/navigator/main.ts b/scripts/snap_inputs/fixtures/snap25/navigator/main.ts new file mode 100644 index 00000000000..b73eac07cd4 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snap25/navigator/main.ts @@ -0,0 +1,3 @@ +import { v } from "./m.ts"; +gc(); +console.log("DONE25", typeof v); diff --git a/scripts/snap_inputs/fixtures/snap25/navigator/package.json b/scripts/snap_inputs/fixtures/snap25/navigator/package.json new file mode 100644 index 00000000000..9621f44d519 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snap25/navigator/package.json @@ -0,0 +1 @@ +{"name":"snap25-navigator","type":"module"} diff --git a/scripts/snap_inputs/fixtures/snap25/parse/m.ts b/scripts/snap_inputs/fixtures/snap25/parse/m.ts new file mode 100644 index 00000000000..59aabf7520b --- /dev/null +++ b/scripts/snap_inputs/fixtures/snap25/parse/m.ts @@ -0,0 +1,5 @@ +// The sources are runtime values: a literal eval/new Function source can be +// compiled ahead of time and would never reach the runtime parser. +const parts = [String(1), "+", String(2)]; +const body = ["return", String(3)].join(" "); +export const v = [eval(parts.join(" ")), new Function(body)()]; diff --git a/scripts/snap_inputs/fixtures/snap25/parse/main.ts b/scripts/snap_inputs/fixtures/snap25/parse/main.ts new file mode 100644 index 00000000000..b73eac07cd4 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snap25/parse/main.ts @@ -0,0 +1,3 @@ +import { v } from "./m.ts"; +gc(); +console.log("DONE25", typeof v); diff --git a/scripts/snap_inputs/fixtures/snap25/parse/package.json b/scripts/snap_inputs/fixtures/snap25/parse/package.json new file mode 100644 index 00000000000..edc3ef28ceb --- /dev/null +++ b/scripts/snap_inputs/fixtures/snap25/parse/package.json @@ -0,0 +1 @@ +{"name":"snap25-parse","type":"module"} diff --git a/scripts/snap_inputs/fixtures/snap25/unsetenv/m.ts b/scripts/snap_inputs/fixtures/snap25/unsetenv/m.ts new file mode 100644 index 00000000000..cfb0049ed01 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snap25/unsetenv/m.ts @@ -0,0 +1 @@ +const before = process.env.SNAP25_X; delete process.env.SNAP25_X; export const v = before; diff --git a/scripts/snap_inputs/fixtures/snap25/unsetenv/main.ts b/scripts/snap_inputs/fixtures/snap25/unsetenv/main.ts new file mode 100644 index 00000000000..b73eac07cd4 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snap25/unsetenv/main.ts @@ -0,0 +1,3 @@ +import { v } from "./m.ts"; +gc(); +console.log("DONE25", typeof v); diff --git a/scripts/snap_inputs/fixtures/snap25/unsetenv/package.json b/scripts/snap_inputs/fixtures/snap25/unsetenv/package.json new file mode 100644 index 00000000000..039455d1c81 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snap25/unsetenv/package.json @@ -0,0 +1 @@ +{"name":"snap25-unsetenv","type":"module"} diff --git a/scripts/snap_inputs/trace.py b/scripts/snap_inputs/trace.py index 0ab6b63a816..70b1aaaf18b 100644 --- a/scripts/snap_inputs/trace.py +++ b/scripts/snap_inputs/trace.py @@ -67,7 +67,7 @@ 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(),"logger_depth":0}} + active={pid:{"perf":counter(pid),"entry":None,"mem":None,"diagnostic_fds":set(),"logger_depth":0,"funnel":0}} sequence=0; main_rc=None; failures=[] out=open(output,"w",buffering=1) out.write(json.dumps({"kind":"collector","sha256":hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),"command":command})+"\n") @@ -117,6 +117,25 @@ def message(tid,address,returned): 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} + exe_text={} + def scan_pcs(tid,window): + if tid not in exe_text: + exe=os.readlink(f"/proc/{tid}/exe");spans=[] + for line in open(f"/proc/{tid}/maps"): + f=line.split(None,5) + if len(f)==6 and f[5].strip()==exe and "x" in f[1]: + lo,hi=(int(x,16) for x in f[0].split("-"));spans.append((lo,hi)) + exe_text[tid]=spans + out=[] + for i in range(0,len(window)-7,8): + w=struct.unpack_from("Q",window,i)[0] + if not any(lo+7<=w>3)&7==2) or (b[4]==0xff and (b[5]>>3)&7==2) or (b[1]==0xff and (b[2]>>3)&7==2) or (b[0]==0xff and (b[1]>>3)&7==2): + out.append(w) + if len(out)>=12:break + return out def put_breakpoint(tid,address,kind): if address in breakpoints:return word=ptrace(1,tid,address)&((1<<64)-1) @@ -209,7 +228,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(),"logger_depth":0} + active[tid]={"perf":counter(tid),"entry":None,"mem":None,"diagnostic_fds":set(),"logger_depth":0,"funnel":0} sig=os.WSTOPSIG(status); event=status>>16 if event in (1,2,3): child=C.c_ulonglong() @@ -241,6 +260,9 @@ def handle_probe(tid): sp=int(regs[19]);entry["rsp"]=sp window=memory(tid,sp,min(2048,4096-(sp&4095))) entry["stack_window"]=window.hex() if window is not None else None + # SNAP25 C1: call-preceded return addresses inside the executable's + # text, innermost first (a stack scan, labelled as such). + if window is not None:entry["pcs_scan"]=scan_pcs(tid,window) 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: @@ -271,6 +293,10 @@ def handle_probe(tid): 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]==0x5323: + # SNAP25 C2: the runtime funnel now open on this thread. + entry["kind"]="funnel_marker";entry["policy"]="diagnostic";state["funnel"]=a[1] + elif state["funnel"]:entry["funnel"]=state["funnel"] 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" From 34f82db6ccc171ec3e724ea7c3a4bdd8f3f4861d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 11:03:07 +0000 Subject: [PATCH 11/22] snap census K2a (WIP): host-value reads and scan, P-entry (a)(b)(c), P-reach rows; fixtures F1 F2 F3 F10 (instr-only) --- crates/perry-runtime/src/os.rs | 1 + crates/perry-runtime/src/process.rs | 5 +- crates/perry-runtime/src/snap_census.rs | 33 +++ .../src/snap_census/input_record.rs | 20 ++ .../perry-runtime/src/snap_census/runner.rs | 248 ++++++++++++++++++ scripts/snap_inputs/checkk2.py | 68 +++++ scripts/snap_inputs/fixtures/snapk2/f1/m.ts | 2 + .../snap_inputs/fixtures/snapk2/f1/main.ts | 4 + .../fixtures/snapk2/f1/package.json | 1 + scripts/snap_inputs/fixtures/snapk2/f10/m.ts | 2 + .../snap_inputs/fixtures/snapk2/f10/main.ts | 2 + .../fixtures/snapk2/f10/package.json | 1 + scripts/snap_inputs/fixtures/snapk2/f2/a.ts | 3 + scripts/snap_inputs/fixtures/snapk2/f2/b.ts | 3 + .../snap_inputs/fixtures/snapk2/f2/eager.ts | 1 + .../snap_inputs/fixtures/snapk2/f2/main.ts | 8 + .../fixtures/snapk2/f2/package.json | 1 + scripts/snap_inputs/fixtures/snapk2/f3/a.ts | 2 + scripts/snap_inputs/fixtures/snapk2/f3/b.ts | 2 + .../snap_inputs/fixtures/snapk2/f3/main.ts | 6 + .../fixtures/snapk2/f3/package.json | 1 + 21 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 scripts/snap_inputs/checkk2.py create mode 100644 scripts/snap_inputs/fixtures/snapk2/f1/m.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2/f1/main.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2/f1/package.json create mode 100644 scripts/snap_inputs/fixtures/snapk2/f10/m.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2/f10/main.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2/f10/package.json create mode 100644 scripts/snap_inputs/fixtures/snapk2/f2/a.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2/f2/b.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2/f2/eager.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2/f2/main.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2/f2/package.json create mode 100644 scripts/snap_inputs/fixtures/snapk2/f3/a.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2/f3/b.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2/f3/main.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2/f3/package.json diff --git a/crates/perry-runtime/src/os.rs b/crates/perry-runtime/src/os.rs index 4ce246f3fad..ade12f6fb58 100644 --- a/crates/perry-runtime/src/os.rs +++ b/crates/perry-runtime/src/os.rs @@ -634,6 +634,7 @@ 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::snap_host_value!(1); crate::effect!(host_read, argv); use crate::array::js_array_push_f64; use crate::value::js_nanbox_string; diff --git a/crates/perry-runtime/src/process.rs b/crates/perry-runtime/src/process.rs index f60b56b2200..2cf6b545c8a 100644 --- a/crates/perry-runtime/src/process.rs +++ b/crates/perry-runtime/src/process.rs @@ -871,7 +871,10 @@ pub fn process_metadata_property(property: &str) -> Option { "stdin" | "stdout" | "stderr" => process_stdio_property(property)?, "allowedNodeEnvironmentFlags" => report::process_allowed_flags_value(), "argv0" => module_string_value(&process_argv0_string()), - "execPath" => module_string_value(&process_exec_path_string()), + "execPath" => { + crate::snap_host_value!(2); + module_string_value(&process_exec_path_string()) + } "config" => report::process_config_value(), "debugPort" => 9229.0, "execArgv" => process_exec_argv_value(), diff --git a/crates/perry-runtime/src/snap_census.rs b/crates/perry-runtime/src/snap_census.rs index 2cb9a9356b4..7da6a6242f6 100644 --- a/crates/perry-runtime/src/snap_census.rs +++ b/crates/perry-runtime/src/snap_census.rs @@ -664,6 +664,39 @@ pub(crate) fn funnel_enter(f: Funnel) -> runner::FunnelScope { runner::FunnelScope::enter(f as u8) } +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +/// SNAPK2a: a host value (`process.argv`, `process.execPath`) is +/// materialized inside this scope (DESIGN27 §1.3). +pub(crate) struct HostValueScope(bool); + +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +impl HostValueScope { + #[inline(always)] + pub(crate) fn enter(what: u8) -> Self { + Self(runner::on() && runner::host_value_enter(what)) + } +} + +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +impl Drop for HostValueScope { + #[inline(always)] + fn drop(&mut self) { + if self.0 { + runner::host_value_exit(); + } + } +} + +/// Open a SNAPK2a host-value scope (1 argv, 2 execPath) for the rest of the +/// enclosing block, in the instrumented Linux cohort only. +#[macro_export] +macro_rules! snap_host_value { + ($what:expr) => { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + let _snap_host_value = $crate::snap_census::HostValueScope::enter($what); + }; +} + /// Open a SNAP25 funnel for the rest of the enclosing block, in the /// instrumented Linux cohort only; nothing in any other build. #[macro_export] diff --git a/crates/perry-runtime/src/snap_census/input_record.rs b/crates/perry-runtime/src/snap_census/input_record.rs index 246721f1baf..7f470259377 100644 --- a/crates/perry-runtime/src/snap_census/input_record.rs +++ b/crates/perry-runtime/src/snap_census/input_record.rs @@ -108,6 +108,26 @@ 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; } + if st.p_reach_rule + && super::is_entry(st, init) + && !matches!( + op, + "init_enter" + | "init_exit" + | "entry_body" + | "result" + | "boundary" + | "job_enter" + | "job_exit" + | "census_start" + | "p_reach" + | "p_entry" + | "k2a" + | "host_value.held" + ) + { + st.entry_rows.push((seq, op.to_owned())); + } let mut row = format!("{seq}\t{ir}\t{init}\t"); for (i, f) in st.frames.iter().enumerate() { if i != 0 { diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 87917e2fd67..38da74f2653 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -483,6 +483,8 @@ struct Init { /// The entry module's own evaluation (a program with a worker evaluates /// its entry as a module): the entry body. The check run runs it. entry_module: bool, + /// SNAPK2a P-entry: this init's first (a)/(b) hit is logged. + p_entry_hit: bool, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -503,6 +505,9 @@ struct Birth { addr: usize, size: u32, init: u32, + /// The ordered input record's seq when the birth happened (SNAPK2a scans + /// date a held pointer no earlier than its holder's and target's births). + seq: u64, } /// Page flags (one byte per page). @@ -809,6 +814,29 @@ struct State { /// (`PERRY_SNAP_WD`, by logical name). wd_names: std::collections::HashSet<(u64, usize)>, entry_init: u32, + /// SNAPK2a (DESIGN27 §1.3, §2). Host values (`PERRY_SNAP_HOST=0` off): + /// a `process.argv`/`process.execPath` read is live in the entry and a + /// STOP anywhere else; a word outside entry state that holds a pointer + /// into a host value is a STOP. + host_rule: bool, + /// P-entry (`PERRY_SNAP_PENTRY=0` off): no imaged init reads a word an + /// entry wrote (a), writes a word an entry read (b), or holds a pointer + /// to an entry birth (c). + p_entry_rule: bool, + /// P-reach (`PERRY_SNAP_PREACH=0` off): an init an entry reaches by + /// `import()`/`require` names the entry's inputs read before it. + p_reach_rule: bool, + /// Words an entry read (P-entry (b)). + er: WordSet, + /// First (a)/(b) hit per init: (init, rule, word, pc, input seq). + /// Pushed in signal context: the capacity is reserved up front. + p_entry_log: Vec<(u32, u8, usize, usize, u64)>, + /// The open host-value scope (1 argv, 2 execPath; 0 none) and the births + /// made inside one: (start, end, what, input seq). + host_what: u8, + host_targets: Vec<(usize, usize, u8, u64)>, + /// The entry's input rows since the last reach point: (seq, op). + entry_rows: Vec<(u64, String)>, mark: bool, /// R5 (`PERRY_SNAP_RSET=1`): after the boundary, the imaged cells stay /// write-protected and their writes are counted until exit. @@ -1047,6 +1075,14 @@ unsafe fn start_census(out: String) { }) .unwrap_or_default(), entry_init: 0, + host_rule: !std::env::var("PERRY_SNAP_HOST").is_ok_and(|v| v == "0"), + p_entry_rule: !std::env::var("PERRY_SNAP_PENTRY").is_ok_and(|v| v == "0"), + p_reach_rule: !std::env::var("PERRY_SNAP_PREACH").is_ok_and(|v| v == "0"), + er: WordSet::new(24), + p_entry_log: Vec::with_capacity(CHECK_FAIL_MAX), + host_what: 0, + host_targets: Vec::new(), + entry_rows: Vec::new(), mark: std::env::var("PERRY_SNAP_MARK").is_ok_and(|v| v == "1"), rset: std::env::var("PERRY_SNAP_RSET").is_ok_and(|v| v == "1"), rset_active: false, @@ -1077,6 +1113,7 @@ unsafe fn start_census(out: String) { addr: header as usize, size: size as u32, init: 0, + seq: 0, }); }); crate::arena::snap_reservation_regions(|base, end| st.regions.push((base, end))); @@ -1203,6 +1240,7 @@ fn blank_init(addr: usize, parent: u32, reach: Reach) -> Init { faults: 0, skipped: false, entry_module: false, + p_entry_hit: false, } } @@ -1342,6 +1380,25 @@ pub(super) fn init_enter(addr: usize) { } else { Reach::DynImport }; + if st.p_reach_rule + && !entry_module + && matches!(reach, Reach::DynImport | Reach::Require) + && is_entry(st, parent_id) + { + // P-reach (DESIGN27 §2): the entry reached this init by control flow; + // what the entry read before it are required guard members. + let members: Vec = st + .entry_rows + .drain(..) + .map(|(s, op)| format!("{s}:{op}")) + .collect(); + let id = st.inits.len(); + input_record::event( + st, + "p_reach", + format!("reach_init={id};addr={addr:x};members={}", members.join(",")), + ); + } st.seq += 1; let id = st.inits.len() as u32; assert!((id as usize) < MAX_INITS, "snap census: too many inits"); @@ -1780,6 +1837,7 @@ pub(super) fn birth(addr: usize, size: usize) { for w in (addr..addr + size).step_by(8) { st.w.remove(w as u64); st.r.remove(w as u64); + st.er.remove(w as u64); } if let (Some(a), Some(b)) = (start_bit(st, addr), start_bit(st, addr + size - 1)) { for i in a..=b { @@ -1799,7 +1857,12 @@ pub(super) fn birth(addr: usize, size: usize) { addr, size: size as u32, init: top, + seq: st.inputs.seq, }); + if st.host_what != 0 { + st.host_targets + .push((addr, addr + size, st.host_what, st.inputs.seq)); + } let i = &mut st.inits[top as usize]; i.births += 1; i.birth_bytes += size as u64; @@ -2689,6 +2752,27 @@ fn note_loc(st: &mut State, top: usize, word: usize, w: u64, write: bool, pc: us unsafe { apply_protection(st) }; return; } + if st.p_entry_rule && top != 0 { + if is_entry(st, top as u32) { + if !write { + st.er.insert(w); + } + } else if !st.inits[top].p_entry_hit { + let rule = if write { + st.er.contains(w).then_some(b'b') + } else { + (st.w.contains(w) && st.wby.get(w).is_some_and(|c| is_entry(st, c.0))) + .then_some(b'a') + }; + if let Some(rule) = rule { + if st.p_entry_log.len() < st.p_entry_log.capacity() { + st.inits[top].p_entry_hit = true; + let seq = st.inputs.seq; + st.p_entry_log.push((top as u32, rule, word, pc, seq)); + } + } + } + } if st.d_mode { // A member (seeded, or joined earlier in this run) still names the // first earlier member it conflicts with: the edge of the cascade @@ -3034,6 +3118,9 @@ pub(super) fn after_boundary_collection() { } fn write_report(st: &mut State) { + if st.host_rule || st.p_entry_rule { + k2a_rows(st); + } st.env_reads = input_record::env_rows(st).expect("cannot read durable environment observations"); use std::io::Write; @@ -3997,6 +4084,167 @@ struct EnvObs { by_pc: usize, } +/// The entry's state (DESIGN27 §2): the entry pseudo-init or the entry +/// module's own evaluation. It re-runs live; nothing it holds is imaged. +fn is_entry(st: &State, id: u32) -> bool { + id != 0 && (id == st.entry_init || st.inits[id as usize].entry_module) +} + +fn host_name(what: u8) -> &'static str { + match what { + 1 => "argv", + 2 => "exec_path", + _ => "entry_birth", + } +} + +/// A host value is materialized next (`HostValueScope`): one `host_value` +/// row; the births until `host_value_exit` are its targets. +pub(super) fn host_value_enter(what: u8) -> bool { + if !(inputs_on() && on_main_thread()) { + return false; + } + let _input_logging = input_record::Scope::enter(); + let st = state(); + if !st.host_rule || st.host_what != 0 { + return false; + } + let top = top_init(st); + let by = if top == 0 { + "runtime" + } else if is_entry(st, top) { + "entry" + } else { + "imaged" + }; + let policy = if by == "entry" { "live" } else { "STOP" }; + input_record::event( + st, + "host_value", + format!("what={};by={by};policy={policy}", host_name(what)), + ); + st.host_what = what; + true +} + +pub(super) fn host_value_exit() { + state().host_what = 0; +} + +/// SNAPK2a scan at the boundary (DESIGN27 §1.3 and P-entry (c)): every word +/// outside entry state (births that are neither the entry's nor a host +/// value's, module-global slots no entry owns) that holds a pointer into a +/// host value or into an entry birth is a STOP. The store is no earlier than +/// both births, so the row is dated at the later one (`at_seq`). Then the +/// P-entry (a)/(b) hits logged during the run. +fn k2a_rows(st: &mut State) { + let mut t: Vec<(usize, usize, u8, u64, u32)> = Vec::new(); + if st.host_rule { + t.extend(st.host_targets.iter().map(|&(a, e, w, s)| (a, e, w, s, 0))); + } + if st.p_entry_rule { + for b in st.births.as_slice() { + if is_entry(st, b.init) { + t.push((b.addr, b.addr + b.size as usize, 255, b.seq, b.init)); + } + } + } + t.sort_unstable(); + let find = |v: u64| -> Option { + let hi = v >> 48; + if !(hi == 0 || (0x7FFC..=0x7FFF).contains(&hi)) { + return None; + } + let a = (v & 0xFFFF_FFFF_FFFF) as usize; + let i = t.partition_point(|x| x.0 <= a); + (a != 0 && i > 0 && a < t[i - 1].1).then(|| i - 1) + }; + let is_target = |a: usize| { + let i = t.partition_point(|x| x.0 <= a); + i > 0 && t[i - 1].0 == a + }; + let mut rows: Vec<(&'static str, String)> = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut held = 0u64; + let row = |rows: &mut Vec<(&'static str, String)>, k: usize, holder: String, at: u64| { + let (op, rule) = if t[k].2 == 255 { + ("p_entry", "rule=c;") + } else { + ("host_value.held", "") + }; + rows.push(( + op, + format!( + "{rule}what={};{holder};target_init={};target_seq={};at_seq={at};policy=STOP", + host_name(t[k].2), + t[k].4, + t[k].3 + ), + )); + }; + if !t.is_empty() { + for b in st.births.as_slice() { + if is_entry(st, b.init) || is_target(b.addr) { + continue; + } + for off in (8..b.size as usize).step_by(8) { + let v = unsafe { ((b.addr + off) as *const u64).read() }; + if let Some(k) = find(v) { + held += 1; + if seen.insert((b.addr, k)) && rows.len() < CHECK_FAIL_MAX { + row( + &mut rows, + k, + format!( + "holder=birth;holder_init={};holder_addr={:x};off={off}", + b.init, b.addr + ), + b.seq.max(t[k].3), + ); + } + } + } + } + for (n, &slot) in st.globals_log.iter().enumerate() { + let owner = st.slot_owner.get(n).copied().unwrap_or(0); + if is_entry(st, owner) { + continue; + } + let v = unsafe { (slot as *const u64).read() }; + if let Some(k) = find(v) { + held += 1; + if seen.insert((slot, k)) && rows.len() < CHECK_FAIL_MAX { + row( + &mut rows, + k, + format!("holder=slot;holder_init={owner};slot={}", slot_name(slot)), + t[k].3, + ); + } + } + } + } + for &(init, rule, word, pc, seq) in &st.p_entry_log { + rows.push(( + "p_entry", + format!( + "rule={};init={init};word={word:x};pc={pc:x};at_seq={seq};policy=STOP", + rule as char + ), + )); + } + let summary = format!( + "host_targets={};entry_births={};held_words={held};p_entry_ab={}", + st.host_targets.len(), + t.iter().filter(|x| x.2 == 255).count(), + st.p_entry_log.len() + ); + for (op, v) in rows { + input_record::event(st, op, v); + } + input_record::event(st, "k2a", summary); +} + fn top_init(st: &State) -> u32 { st.frames.last().map_or(0, |f| f.init) } diff --git a/scripts/snap_inputs/checkk2.py b/scripts/snap_inputs/checkk2.py new file mode 100644 index 00000000000..f091bb39279 --- /dev/null +++ b/scripts/snap_inputs/checkk2.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""SNAPK2a census fixtures (DESIGN27 §1.3, §2): checkk2.py NAME REPORT. +Exit 0 when the census rows carry the K2a rule the fixture exercises; with the +rule's knob off (PERRY_SNAP_HOST / PERRY_SNAP_PENTRY / PERRY_SNAP_PREACH = 0) +every fixture must fail.""" +import sys +name, report = sys.argv[1:3] +rows = [l.rstrip("\n").split("\t") for l in open(report + ".inputs.tsv")][1:] + + +def dec(h): + try: + return bytes.fromhex(h).decode("utf8", "replace") + except ValueError: + return h + + +ops = [(int(r[0]), int(r[2]), r[4], dec(r[5])) for r in rows if len(r) > 6] +fails = [] + + +def need(c, msg): + if not c: + fails.append(msg) + + +def vals(op): + return [v for _, _, o, v in ops if o == op] + + +def kv(v): + return dict(p.split("=", 1) for p in v.split(";") if "=" in p) + + +host = [kv(v) for v in vals("host_value")] +held = [kv(v) for v in vals("host_value.held")] +pent = [kv(v) for v in vals("p_entry")] +reach = [kv(v) for v in vals("p_reach")] +if name in ("f1", "f10"): + what = "argv" if name == "f1" else "exec_path" + need(any(h["what"] == what and h["by"] == "imaged" and h["policy"] == "STOP" for h in host), + f"no imaged {what} read STOP") + need(any(h["what"] == what and h["policy"] == "STOP" for h in held), f"no held {what} pointer STOP") + need(not any(h["by"] == "entry" and h["policy"] != "live" for h in host), "entry read not live") + if name == "f1": + need(any(h["what"] == "argv" and h["by"] == "entry" and h["policy"] == "live" for h in host), + "no live entry argv read") + need(not any(h.get("holder_init") and h["what"] == "argv" and h["holder_init"] == "0" + and h.get("holder") == "slot" for h in held), "entry slot counted as holder") +elif name == "f2": + for r in "abc": + need(any(p["rule"] == r and p["policy"] == "STOP" for p in pent), f"no P-entry ({r}) STOP") + # (a) and (b) name the importing inits, never the entry. + ab = {p["rule"]: int(p["init"]) for p in pent if p["rule"] in "ab"} + need(len(set(ab.values())) == len(ab) == 2, f"(a)/(b) not two inits: {ab}") +elif name == "f3": + need(len(reach) == 2, f"want 2 reach points (b, a), got {len(reach)}") + if reach: + first = reach[0].get("members", "") + seqs = {int(s.split(":")[0]): s.split(":", 1)[1] for s in first.split(",") if ":" in s} + need(any(op == "argv.read" for op in seqs.values()), f"first reach lacks the argv read: {first}") + r2 = [v for _, _, o, v in ops if o == "argv.read" and v.startswith("index=2;")] + need(bool(r2), "no argv[2] index read row") +else: + fails.append("unknown fixture") +k2a = vals("k2a") +print(f"{name} {'PASS' if not fails else 'FAIL ' + '; '.join(fails)} | k2a={k2a[-1] if k2a else '-'}") +sys.exit(1 if fails else 0) diff --git a/scripts/snap_inputs/fixtures/snapk2/f1/m.ts b/scripts/snap_inputs/fixtures/snapk2/f1/m.ts new file mode 100644 index 00000000000..93045d7774f --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2/f1/m.ts @@ -0,0 +1,2 @@ +// Imaged init (eager import) that keeps process.argv: a host value held outside the entry. +export const a = process.argv; diff --git a/scripts/snap_inputs/fixtures/snapk2/f1/main.ts b/scripts/snap_inputs/fixtures/snapk2/f1/main.ts new file mode 100644 index 00000000000..8fc1ec01d16 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2/f1/main.ts @@ -0,0 +1,4 @@ +import { a } from "./m.ts"; +// The entry reads argv too: a host value read live, never a STOP. +const mine = process.argv.length; +console.log("K2A_F1", a.length >= 2, mine >= 2); diff --git a/scripts/snap_inputs/fixtures/snapk2/f1/package.json b/scripts/snap_inputs/fixtures/snapk2/f1/package.json new file mode 100644 index 00000000000..4c2c6ce06f3 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2/f1/package.json @@ -0,0 +1 @@ +{"name":"snapk2-f1","type":"module"} diff --git a/scripts/snap_inputs/fixtures/snapk2/f10/m.ts b/scripts/snap_inputs/fixtures/snapk2/f10/m.ts new file mode 100644 index 00000000000..c42a851d066 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2/f10/m.ts @@ -0,0 +1,2 @@ +// Imaged init that keeps process.execPath. +export const p = process.execPath; diff --git a/scripts/snap_inputs/fixtures/snapk2/f10/main.ts b/scripts/snap_inputs/fixtures/snapk2/f10/main.ts new file mode 100644 index 00000000000..ecf5926496c --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2/f10/main.ts @@ -0,0 +1,2 @@ +import { p } from "./m.ts"; +console.log("K2A_F10", typeof p, p.length > 0); diff --git a/scripts/snap_inputs/fixtures/snapk2/f10/package.json b/scripts/snap_inputs/fixtures/snapk2/f10/package.json new file mode 100644 index 00000000000..fa633093e2f --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2/f10/package.json @@ -0,0 +1 @@ +{"name":"snapk2-f10","type":"module"} diff --git a/scripts/snap_inputs/fixtures/snapk2/f2/a.ts b/scripts/snap_inputs/fixtures/snapk2/f2/a.ts new file mode 100644 index 00000000000..725aab486cb --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2/f2/a.ts @@ -0,0 +1,3 @@ +// (a): reads a global key the entry wrote before the import. +(globalThis as any).fromA = (globalThis as any).k + 1; +export {}; diff --git a/scripts/snap_inputs/fixtures/snapk2/f2/b.ts b/scripts/snap_inputs/fixtures/snapk2/f2/b.ts new file mode 100644 index 00000000000..472214bf6f1 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2/f2/b.ts @@ -0,0 +1,3 @@ +// (b): writes a global key the entry read before the import. +(globalThis as any).j = 7; +export {}; diff --git a/scripts/snap_inputs/fixtures/snapk2/f2/eager.ts b/scripts/snap_inputs/fixtures/snapk2/f2/eager.ts new file mode 100644 index 00000000000..0460ff68a0d --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2/f2/eager.ts @@ -0,0 +1 @@ +export const box: { x: unknown } = { x: null }; diff --git a/scripts/snap_inputs/fixtures/snapk2/f2/main.ts b/scripts/snap_inputs/fixtures/snapk2/f2/main.ts new file mode 100644 index 00000000000..414f7574223 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2/f2/main.ts @@ -0,0 +1,8 @@ +import { box } from "./eager.ts"; +(globalThis as any).k = 41; +const j0 = (globalThis as any).j; +// (c): an entry birth stored into an imaged object. +box.x = { tag: "entry" }; +await import("./a.ts"); +await import("./b.ts"); +console.log("K2A_F2", j0, (globalThis as any).j, (globalThis as any).fromA); diff --git a/scripts/snap_inputs/fixtures/snapk2/f2/package.json b/scripts/snap_inputs/fixtures/snapk2/f2/package.json new file mode 100644 index 00000000000..d319d97fe0d --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2/f2/package.json @@ -0,0 +1 @@ +{"name":"snapk2-f2","type":"module"} diff --git a/scripts/snap_inputs/fixtures/snapk2/f3/a.ts b/scripts/snap_inputs/fixtures/snapk2/f3/a.ts new file mode 100644 index 00000000000..0440e4daf95 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2/f3/a.ts @@ -0,0 +1,2 @@ +(globalThis as any).seen = (globalThis as any).flag ?? "none"; +export {}; diff --git a/scripts/snap_inputs/fixtures/snapk2/f3/b.ts b/scripts/snap_inputs/fixtures/snapk2/f3/b.ts new file mode 100644 index 00000000000..d3f0cb3d290 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2/f3/b.ts @@ -0,0 +1,2 @@ +(globalThis as any).flag = "b"; +export {}; diff --git a/scripts/snap_inputs/fixtures/snapk2/f3/main.ts b/scripts/snap_inputs/fixtures/snapk2/f3/main.ts new file mode 100644 index 00000000000..bf6ed394e52 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2/f3/main.ts @@ -0,0 +1,6 @@ +// The entry decides by argv[2] which init it reaches (dispatch.ts:8,14 shape). +if (process.argv[2] === "B") { + await import("./b.ts"); +} +await import("./a.ts"); +console.log("K2A_F3", (globalThis as any).seen); diff --git a/scripts/snap_inputs/fixtures/snapk2/f3/package.json b/scripts/snap_inputs/fixtures/snapk2/f3/package.json new file mode 100644 index 00000000000..3319a48c281 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2/f3/package.json @@ -0,0 +1 @@ +{"name":"snapk2-f3","type":"module"} From aca3cc71bd0fc2ebf0d11a6ae28e26302141e995 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 13:46:18 +0000 Subject: [PATCH 12/22] snap census K2a: fixture boundaries, SIGTRAP re-protect order, P-entry words, owned storage in the (c) scan (instr-only) - F1/F2/F3/F10 call gc() before the output, so the boundary report and the K2a rows are written; checkk2.py fails "no boundary row" first. - on_trap applies protection after every store of the step is recorded; note_access returns whether the first-write watch ended and on_segv calls end_watch(). A nested fault inside on_trap reports 0xdead instead of dying on the blocked SIGTRAP. - P-entry (a) uses its own word set `ew` (last writer is an entry); (a) is a conflict rule and is read from the PERRY_SNAP22_WATCH=0 run. - The p_reach row follows the init_enter event (exact Ir accounting join). - (c) scan: an entry birth reached through an owned-storage edge of a non-entry holder (ObjectHeader.meta; ObjectMeta spill, overflow, elements, expando, owned_state) is the holder's representation, not an entry value: no target, scanned as the holder's words. F2 named the global's meta record (born by the runtime, holding the spill that the entry's globalThis.k grew) as a (c) holder; checkk2 F2 now allows only the box. - held rows name holder_type and holder_seq. --- .../perry-runtime/src/snap_census/runner.rs | 204 +++++++++++++++--- scripts/snap_inputs/checkk2.py | 7 + .../snap_inputs/fixtures/snapk2/f1/main.ts | 2 + .../snap_inputs/fixtures/snapk2/f10/main.ts | 2 + .../snap_inputs/fixtures/snapk2/f2/main.ts | 2 + .../snap_inputs/fixtures/snapk2/f3/main.ts | 2 + 6 files changed, 184 insertions(+), 35 deletions(-) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 38da74f2653..034257e7c1d 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -828,6 +828,9 @@ struct State { p_reach_rule: bool, /// Words an entry read (P-entry (b)). er: WordSet, + /// Words whose last writer is an entry (P-entry (a)). `w`/`wby` cannot + /// say it: outside D they record only a conflicting init's accesses. + ew: WordSet, /// First (a)/(b) hit per init: (init, rule, word, pc, input seq). /// Pushed in signal context: the capacity is reserved up front. p_entry_log: Vec<(u32, u8, usize, usize, u64)>, @@ -1079,6 +1082,7 @@ unsafe fn start_census(out: String) { p_entry_rule: !std::env::var("PERRY_SNAP_PENTRY").is_ok_and(|v| v == "0"), p_reach_rule: !std::env::var("PERRY_SNAP_PREACH").is_ok_and(|v| v == "0"), er: WordSet::new(24), + ew: WordSet::new(24), p_entry_log: Vec::with_capacity(CHECK_FAIL_MAX), host_what: 0, host_targets: Vec::new(), @@ -1153,7 +1157,10 @@ unsafe fn start_census(out: String) { } } st.inputs.watch_enabled = !std::env::var("PERRY_SNAP22_WATCH").is_ok_and(|v| v == "0"); - C25.store(!std::env::var("PERRY_SNAP25").is_ok_and(|v| v == "0"), Relaxed); + C25.store( + !std::env::var("PERRY_SNAP25").is_ok_and(|v| v == "0"), + Relaxed, + ); 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(); @@ -1380,25 +1387,26 @@ pub(super) fn init_enter(addr: usize) { } else { Reach::DynImport }; - if st.p_reach_rule + // P-reach (DESIGN27 §2): the entry reached this init by control flow; + // what the entry read before it are required guard members. The row + // follows `init_enter`: the charge's counter read must stay the last one + // before `init_enter` (the exact Ir accounting joins them). + let reach_row = (st.p_reach_rule && !entry_module && matches!(reach, Reach::DynImport | Reach::Require) - && is_entry(st, parent_id) - { - // P-reach (DESIGN27 §2): the entry reached this init by control flow; - // what the entry read before it are required guard members. + && is_entry(st, parent_id)) + .then(|| { let members: Vec = st .entry_rows .drain(..) .map(|(s, op)| format!("{s}:{op}")) .collect(); - let id = st.inits.len(); - input_record::event( - st, - "p_reach", - format!("reach_init={id};addr={addr:x};members={}", members.join(",")), - ); - } + format!( + "reach_init={};addr={addr:x};members={}", + st.inits.len(), + members.join(",") + ) + }); st.seq += 1; let id = st.inits.len() as u32; assert!((id as usize) < MAX_INITS, "snap census: too many inits"); @@ -1446,6 +1454,9 @@ pub(super) fn init_enter(addr: usize) { }); crate::object::shapes::snap22::snapshot("init_enter", addr as u64); input_record::event(st, "init_enter", format!("{addr:x}")); + if let Some(row) = reach_row { + input_record::event(st, "p_reach", row); + } set_mode_for_top(st); } @@ -1838,6 +1849,7 @@ pub(super) fn birth(addr: usize, size: usize) { st.w.remove(w as u64); st.r.remove(w as u64); st.er.remove(w as u64); + st.ew.remove(w as u64); } if let (Some(a), Some(b)) = (start_bit(st, addr), start_bit(st, addr + size - 1)) { for i in a..=b { @@ -2395,7 +2407,11 @@ unsafe fn on_segv_inner( let word = addr & !7; match st.stores.iter_mut().find(|s| s.0 == 0) { Some(slot) if write => *slot = (word, *(word as *const u64), pc), - _ => note_access(st, word, write, pc), + _ => { + if note_access(st, word, write, pc) { + end_watch(st); + } + } } gregs[libc::REG_EFL as usize] |= EFLAGS_TF; } @@ -2430,6 +2446,10 @@ extern "C" fn on_trap(sig: libc::c_int, _info: *mut libc::siginfo_t, ctx: *mut l } let _input_logging = input_record::Scope::enter(); let st = &mut *st_ptr; + // A fault in this handler (SIGTRAP is blocked here) must not open a + // page and set TF: the single step would be a blocked SIGTRAP, which + // kills the process silently. on_segv reports it instead (0xdead). + st.in_handler += 1; let pending = st.pending; if st.rset_active { let w = std::mem::replace(&mut st.rset_word, 0); @@ -2448,20 +2468,25 @@ extern "C" fn on_trap(sig: libc::c_int, _info: *mut libc::siginfo_t, ctx: *mut l // A store that left the word's value as it was is no write // (nothing anyone reads changed); it still read the word. let stores = std::mem::replace(&mut st.stores, [(0, 0, 0); 4]); + let mut watch_ended = false; for &(word, before, pc) in stores.iter().take_while(|s| s.0 != 0) { let changed = *(word as *const u64) != before; if !changed { st.silent_stores += 1; } st.store_before = Some(before); - note_access(st, word, changed, pc); + watch_ended |= note_access(st, word, changed, pc); st.store_before = None; } + if watch_ended { + apply_protection(st); + } for &page in pending.iter().take_while(|p| **p != 0) { libc::mprotect(page as *mut _, PAGE, policy(st, page)); } } st.pending = [0; 8]; + st.in_handler -= 1; let uc = ctx as *mut libc::ucontext_t; (*uc).uc_mcontext.gregs[libc::REG_EFL as usize] &= !EFLAGS_TF; } @@ -2508,7 +2533,29 @@ fn transparent_cell(st: &mut State, h: usize, ty: u8) -> bool { found } -fn note_access(st: &mut State, word: usize, write: bool, pc: usize) { +/// True when this access ended the first-write watch: the caller re-applies +/// the bulk protection once the fault is recorded (`end_watch`). +#[must_use] +fn note_access(st: &mut State, word: usize, write: bool, pc: usize) -> bool { + let mut watch_ended = false; + note_access_at(st, word, write, pc, &mut watch_ended); + watch_ended +} + +/// The end of the first-write watch re-protects every tracked page. It runs +/// after the fault's records: they read the stored words and cell headers on +/// the open pages, and a fault inside on_trap is a blocked SIGTRAP, which +/// kills the process. +unsafe fn end_watch(st: &mut State) { + apply_protection(st); + // The pages of the instruction being stepped stay open until on_trap + // restores their policy. + for &page in st.pending.iter().take_while(|p| **p != 0) { + libc::mprotect(page as *mut _, PAGE, libc::PROT_READ | libc::PROT_WRITE); + } +} + +fn note_access_at(st: &mut State, word: usize, write: bool, pc: usize, watch_ended: &mut bool) { if st.r1 { // The census's own bookkeeping (a root registration, a key's bytes) // is no access; a representation scope or a meta install is one, of @@ -2548,9 +2595,7 @@ fn note_access(st: &mut State, word: usize, write: bool, pc: usize) { input_record::changed_word(st, word, pc); if !input_record::watch(st) { st.inputs.watching = false; - unsafe { - apply_protection(st); - } + *watch_ended = true; } } if st.transp_globals.contains(word as u64) { @@ -2754,16 +2799,23 @@ fn note_loc(st: &mut State, top: usize, word: usize, w: u64, write: bool, pc: us } if st.p_entry_rule && top != 0 { if is_entry(st, top as u32) { - if !write { + if write { + st.ew.insert(w); + } else { st.er.insert(w); } - } else if !st.inits[top].p_entry_hit { - let rule = if write { + } else { + let rule = if st.inits[top].p_entry_hit { + None + } else if write { st.er.contains(w).then_some(b'b') } else { - (st.w.contains(w) && st.wby.get(w).is_some_and(|c| is_entry(st, c.0))) - .then_some(b'a') + st.ew.contains(w).then_some(b'a') }; + if write { + // The word's last writer is no longer an entry. + st.ew.remove(w); + } if let Some(rule) = rule { if st.p_entry_log.len() < st.p_entry_log.capacity() { st.inits[top].p_entry_hit = true; @@ -2889,13 +2941,13 @@ pub unsafe extern "C" fn getenv(name: *const libc::c_char) -> *mut libc::c_char if inputs_on() && on_main_thread() { let _input_logging = input_record::Scope::enter(); let st = state(); - let op = if c25() && FUNNEL.get() == 7 { "getenv.program" } else { "getenv" }; + let op = if c25() && FUNNEL.get() == 7 { + "getenv.program" + } else { + "getenv" + }; if found.is_null() { - input_record::native_event( - st, - op, - format_args!("{}=unset", input_record::Hex(key)), - ); + input_record::native_event(st, op, format_args!("{}=unset", input_record::Hex(key))); } else { input_record::native_event( st, @@ -4150,6 +4202,68 @@ fn k2a_rows(st: &mut State) { } } t.sort_unstable(); + // Owned storage (meta rule: a meta record's words are its owner's; its + // spill, overflow, elements, expando and owned-state arrays are child + // edges that move and die with the owner): an entry birth reached + // through such an edge of a non-entry holder is that holder's + // representation, grown by the entry's write to the holder (named by + // key as P-entry (a)/(b)), not an entry value. It is no (c) target, and + // its words are scanned as the holder's. Edges into adopted storage + // (an entry-born meta's spill) are followed to a fixpoint. + let owned: Vec<(usize, usize, u32)> = if st.p_entry_rule { + let hs = crate::gc::GC_HEADER_SIZE; + let meta_edges = [ + std::mem::offset_of!(crate::object::ObjectMeta, spill), + std::mem::offset_of!(crate::object::ObjectMeta, overflow), + std::mem::offset_of!(crate::object::ObjectMeta, elements), + std::mem::offset_of!(crate::object::ObjectMeta, expando), + std::mem::offset_of!(crate::object::ObjectMeta, owned_state), + ]; + let obj_meta = std::mem::offset_of!(crate::object::ObjectHeader, meta); + let entry_at = |v: u64| -> Option { + let a = (v & 0xFFFF_FFFF_FFFF) as usize; + let i = t.partition_point(|x| x.0 <= a); + (a != 0 && i > 0 && t[i - 1].2 == 255 && (a == t[i - 1].0 || a == t[i - 1].0 + hs)) + .then(|| i - 1) + }; + let mut adopted: Vec> = vec![None; t.len()]; + let mut work: Vec<(usize, u32)> = st + .births + .as_slice() + .iter() + .filter(|b| !is_entry(st, b.init)) + .map(|b| (b.addr, b.init)) + .collect(); + while let Some((h, init)) = work.pop() { + let ty = unsafe { (h as *const u8).read() }; + let edges: &[usize] = match ty { + crate::gc::GC_TYPE_OBJECT_META => &meta_edges, + crate::gc::GC_TYPE_OBJECT => std::slice::from_ref(&obj_meta), + _ => &[], + }; + for &e in edges { + let v = unsafe { ((h + hs + e) as *const u64).read() }; + if let Some(k) = entry_at(v) { + if adopted[k].is_none() { + adopted[k] = Some(init); + work.push((t[k].0, init)); + } + } + } + } + let mut keep = Vec::with_capacity(t.len()); + let mut owned = Vec::new(); + for (k, x) in t.iter().enumerate() { + match adopted[k] { + Some(init) => owned.push((x.0, x.1, init)), + None => keep.push(*x), + } + } + t = keep; + owned + } else { + Vec::new() + }; let find = |v: u64| -> Option { let hi = v >> 48; if !(hi == 0 || (0x7FFC..=0x7FFF).contains(&hi)) { @@ -4183,10 +4297,23 @@ fn k2a_rows(st: &mut State) { )); }; if !t.is_empty() { - for b in st.births.as_slice() { - if is_entry(st, b.init) || is_target(b.addr) { + let holders = st + .births + .as_slice() + .iter() + .filter(|b| !is_entry(st, b.init)) + .map(|b| (b.addr, b.size as usize, b.init, b.seq)) + .chain(owned.iter().map(|&(a, e, init)| (a, e - a, init, 0))); + for (addr, size, init, seq) in holders { + if is_target(addr) { continue; } + let b = Birth { + addr, + size: size as u32, + init, + seq, + }; for off in (8..b.size as usize).step_by(8) { let v = unsafe { ((b.addr + off) as *const u64).read() }; if let Some(k) = find(v) { @@ -4196,8 +4323,11 @@ fn k2a_rows(st: &mut State) { &mut rows, k, format!( - "holder=birth;holder_init={};holder_addr={:x};off={off}", - b.init, b.addr + "holder=birth;holder_init={};holder_type={};holder_seq={};holder_addr={:x};off={off}", + b.init, + unsafe { (b.addr as *const u8).read() }, + b.seq, + b.addr ), b.seq.max(t[k].3), ); @@ -4828,7 +4958,11 @@ pub unsafe extern "C" fn unsetenv(name: *const libc::c_char) -> libc::c_int { std::ffi::CStr::from_ptr(name).to_bytes() }; let was = if c25() { - if name.is_null() || getenv(name).is_null() { ";was=unset" } else { ";was=set" } + if name.is_null() || getenv(name).is_null() { + ";was=unset" + } else { + ";was=set" + } } else { "" }; diff --git a/scripts/snap_inputs/checkk2.py b/scripts/snap_inputs/checkk2.py index f091bb39279..b92c7e344fb 100644 --- a/scripts/snap_inputs/checkk2.py +++ b/scripts/snap_inputs/checkk2.py @@ -17,6 +17,8 @@ def dec(h): ops = [(int(r[0]), int(r[2]), r[4], dec(r[5])) for r in rows if len(r) > 6] fails = [] +if not any(o == "boundary" for _, _, o, _ in ops): + fails.append("no boundary row (the run never reached gc())") def need(c, msg): @@ -53,6 +55,11 @@ def kv(v): # (a) and (b) name the importing inits, never the entry. ab = {p["rule"]: int(p["init"]) for p in pent if p["rule"] in "ab"} need(len(set(ab.values())) == len(ab) == 2, f"(a)/(b) not two inits: {ab}") + # (c) names imaged words only: the box slot eager.ts bore. The global's + # spill word the entry grew (a rolled-back representation word) is not. + c = [p for p in pent if p["rule"] == "c"] + need(len(c) <= 1, f"(c) names {len(c)} holders: " + ",".join( + f"{p.get('holder_init')}/{p.get('holder_type')}@{p.get('off')}" for p in c)) elif name == "f3": need(len(reach) == 2, f"want 2 reach points (b, a), got {len(reach)}") if reach: diff --git a/scripts/snap_inputs/fixtures/snapk2/f1/main.ts b/scripts/snap_inputs/fixtures/snapk2/f1/main.ts index 8fc1ec01d16..7a06fff393a 100644 --- a/scripts/snap_inputs/fixtures/snapk2/f1/main.ts +++ b/scripts/snap_inputs/fixtures/snapk2/f1/main.ts @@ -1,4 +1,6 @@ import { a } from "./m.ts"; // The entry reads argv too: a host value read live, never a STOP. const mine = process.argv.length; +// The census boundary (snap25 fixtures mark it the same way). +gc(); console.log("K2A_F1", a.length >= 2, mine >= 2); diff --git a/scripts/snap_inputs/fixtures/snapk2/f10/main.ts b/scripts/snap_inputs/fixtures/snapk2/f10/main.ts index ecf5926496c..8574d44ec98 100644 --- a/scripts/snap_inputs/fixtures/snapk2/f10/main.ts +++ b/scripts/snap_inputs/fixtures/snapk2/f10/main.ts @@ -1,2 +1,4 @@ import { p } from "./m.ts"; +// The census boundary (snap25 fixtures mark it the same way). +gc(); console.log("K2A_F10", typeof p, p.length > 0); diff --git a/scripts/snap_inputs/fixtures/snapk2/f2/main.ts b/scripts/snap_inputs/fixtures/snapk2/f2/main.ts index 414f7574223..a1d25970eef 100644 --- a/scripts/snap_inputs/fixtures/snapk2/f2/main.ts +++ b/scripts/snap_inputs/fixtures/snapk2/f2/main.ts @@ -5,4 +5,6 @@ const j0 = (globalThis as any).j; box.x = { tag: "entry" }; await import("./a.ts"); await import("./b.ts"); +// The census boundary (snap25 fixtures mark it the same way). +gc(); console.log("K2A_F2", j0, (globalThis as any).j, (globalThis as any).fromA); diff --git a/scripts/snap_inputs/fixtures/snapk2/f3/main.ts b/scripts/snap_inputs/fixtures/snapk2/f3/main.ts index bf6ed394e52..6eaeaba3585 100644 --- a/scripts/snap_inputs/fixtures/snapk2/f3/main.ts +++ b/scripts/snap_inputs/fixtures/snapk2/f3/main.ts @@ -3,4 +3,6 @@ if (process.argv[2] === "B") { await import("./b.ts"); } await import("./a.ts"); +// The census boundary (snap25 fixtures mark it the same way). +gc(); console.log("K2A_F3", (globalThis as any).seen); From b6901440384b00303d379cc2a042d813d69eefa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 14:29:53 +0000 Subject: [PATCH 13/22] SNAPK2b WIP: compare_literal site, stdout_style_bit funnel, open-frame and job-queue rows, runtime-pc STOP, worker-flag STOP --- crates/perry-codegen/src/expr/dispatch.rs | 2 + crates/perry-codegen/src/expr/mod.rs | 1 + .../src/expr/snap_compare_site.rs | 96 +++++++ .../src/runtime_decls/strings.rs | 1 + .../perry-runtime/src/error_stack_frames.rs | 4 +- crates/perry-runtime/src/promise/mod.rs | 7 + crates/perry-runtime/src/snap_census.rs | 27 +- .../src/snap_census/input_record.rs | 9 +- .../perry-runtime/src/snap_census/runner.rs | 245 +++++++++++++++++- crates/perry-runtime/src/tty.rs | 16 ++ crates/perry-runtime/src/util_style_text.rs | 9 +- 11 files changed, 400 insertions(+), 17 deletions(-) create mode 100644 crates/perry-codegen/src/expr/snap_compare_site.rs diff --git a/crates/perry-codegen/src/expr/dispatch.rs b/crates/perry-codegen/src/expr/dispatch.rs index 368e3032daa..9b11c94c1e4 100644 --- a/crates/perry-codegen/src/expr/dispatch.rs +++ b/crates/perry-codegen/src/expr/dispatch.rs @@ -31,6 +31,7 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { crate::expr::layout_read::run_enter(ctx, &run); let value = lower_expr_inner(ctx, expr)?; crate::expr::layout_read::run_exit(ctx, &run); + crate::expr::snap_compare_site::lowered(ctx, expr, &value); if let Some(desc) = crate::layout_desc::array_families::birth_layout(expr) { Ok(ctx.block().call( DOUBLE, @@ -54,6 +55,7 @@ fn lower_expr_inner(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // expression stores is a store site (`expr/snap_env_site.rs`). super::snap_env_site::note_store_parent(ctx, expr); super::snap_argv_site::note_parent(expr); + super::snap_compare_site::note_parent(expr); // #9856: under `--debug-symbols`, record this expression's source line // for the DWARF line table (`crate::debug_info`). No-op otherwise. crate::debug_info::mark_expr_location(ctx, expr); diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 89636b5bc28..2cbaa730f99 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -106,6 +106,7 @@ pub(crate) mod inline_birth; pub(crate) mod literal_chunks; mod literal_descriptor; pub(crate) mod snap_argv_site; +pub(crate) mod snap_compare_site; pub(crate) mod snap_env_site; /// A worker program's literal-descriptor IR, for the dialect reader's round trip. #[cfg(test)] diff --git a/crates/perry-codegen/src/expr/snap_compare_site.rs b/crates/perry-codegen/src/expr/snap_compare_site.rs new file mode 100644 index 00000000000..70616ceb67d --- /dev/null +++ b/crates/perry-codegen/src/expr/snap_compare_site.rs @@ -0,0 +1,96 @@ +//! SNAPK2b census: the `compare_literal` site kind (DESIGN27 §3), emitted in +//! instrumented compiles only (`PERRY_SNAP_CENSUS` set while compiling). An +//! ordinary compile emits nothing. +//! +//! An equality compare (`===`, `!==`, `==`, `!=`) calls +//! `js_snap_compare_literal(op, left, right, literal)` once both operands +//! are lowered, before the compare itself; `literal` has bit 0 (1) set when +//! the left (right) operand is a string literal. The runtime keeps a row only +//! when one operand is a host value or env string and the other a string: +//! the guard then holds the predicate, not the host value. A compare whose +//! operands a fast path lowers without `lower_expr` emits no hook, and the +//! read stays a value member. No table is emitted. + +use std::cell::RefCell; + +use perry_hir::{CompareOp, Expr}; + +use crate::types::{DOUBLE, I32}; + +use super::FnCtx; + +struct Site { + left: usize, + right: usize, + op: i32, + literal: i32, + values: [Option; 2], +} + +thread_local! { + static SITES: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// `expr` is about to be lowered: note an equality compare's two operands. +pub(crate) fn note_parent(expr: &Expr) { + if !super::snap_env_site::census_compile() { + return; + } + let Expr::Compare { op, left, right } = expr else { + return; + }; + let op = match op { + CompareOp::Eq => 0, + CompareOp::Ne => 1, + CompareOp::LooseEq => 2, + CompareOp::LooseNe => 3, + _ => return, + }; + let literal = i32::from(matches!(**left, Expr::String(_))) + | (i32::from(matches!(**right, Expr::String(_))) << 1); + SITES.with(|s| { + s.borrow_mut().push(Site { + left: &**left as *const Expr as usize, + right: &**right as *const Expr as usize, + op, + literal, + values: [None, None], + }) + }); +} + +/// `expr` was lowered to `value`: when it is an operand of a noted compare, +/// keep it; once both are kept, emit the hook. +pub(crate) fn lowered(ctx: &mut FnCtx<'_>, expr: &Expr, value: &str) { + if !super::snap_env_site::census_compile() { + return; + } + let addr = expr as *const Expr as usize; + let ready = SITES.with(|s| { + let mut s = s.borrow_mut(); + let i = s.iter().rposition(|t| { + (t.left == addr && t.values[0].is_none()) || (t.right == addr && t.values[1].is_none()) + })?; + let t = &mut s[i]; + let side = usize::from(!(t.left == addr && t.values[0].is_none())); + t.values[side] = Some(value.to_owned()); + if t.values.iter().all(Option::is_some) { + let t = s.swap_remove(i); + let [l, r] = t.values; + Some((t.op, l.unwrap(), r.unwrap(), t.literal)) + } else { + None + } + }); + if let Some((op, l, r, literal)) = ready { + ctx.block().call_void( + "js_snap_compare_literal", + &[ + (I32, &op.to_string()), + (DOUBLE, &l), + (DOUBLE, &r), + (I32, &literal.to_string()), + ], + ); + } +} diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index 55e37ff5955..acaedb6a37a 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -969,6 +969,7 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { if crate::expr::snap_env_site::census_compile() { module.declare_function("js_snap_env_site", VOID, &[I32]); module.declare_function("js_snap_argv_site", VOID, &[I32, I64]); + module.declare_function("js_snap_compare_literal", VOID, &[I32, DOUBLE, DOUBLE, I32]); module.declare_function("js_snap_require_edge", VOID, &[I32]); } // #1344: process.env.X = v / delete process.env.X. diff --git a/crates/perry-runtime/src/error_stack_frames.rs b/crates/perry-runtime/src/error_stack_frames.rs index 80c4d196bc9..74abd9d7474 100644 --- a/crates/perry-runtime/src/error_stack_frames.rs +++ b/crates/perry-runtime/src/error_stack_frames.rs @@ -283,7 +283,7 @@ mod walk { extern "C" fn js_text_anchor() {} #[cfg(all(target_arch = "x86_64", target_os = "linux"))] - fn js_text_bounds() -> (usize, usize) { + pub(super) fn js_text_bounds() -> (usize, usize) { unsafe extern "C" { static __start_perry_js_text: u8; static __stop_perry_js_text: u8; @@ -453,7 +453,7 @@ fn capture_encoded( 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); - crate::snap_census::pc_check(&pcs[..n]); + crate::snap_census::pc_check(&pcs[..n], walk::js_text_bounds()); if diag_enabled() { eprintln!( "[stackdiag] capture: frames={n} want={want} top={:#x} fp0={:#x} pcs={:x?}", diff --git a/crates/perry-runtime/src/promise/mod.rs b/crates/perry-runtime/src/promise/mod.rs index a3e6d38bd5b..26094340c66 100644 --- a/crates/perry-runtime/src/promise/mod.rs +++ b/crates/perry-runtime/src/promise/mod.rs @@ -729,6 +729,13 @@ pub(crate) static TASK_QUEUE: RefCell> } +/// SNAPK2b census: the promise jobs queued and not yet run (None while the +/// queue is being changed). +#[cfg(perry_gc_instruments)] +pub(crate) fn snap_pending_jobs() -> Option { + TASK_QUEUE.with(|q| q.try_borrow().ok().map(|q| q.len())) +} + pub(crate) fn enqueue_queue_microtask(callback: i64) { let scope = crate::gc::RuntimeHandleScope::new(); let callback_handle = diff --git a/crates/perry-runtime/src/snap_census.rs b/crates/perry-runtime/src/snap_census.rs index 7da6a6242f6..16b958b7286 100644 --- a/crates/perry-runtime/src/snap_census.rs +++ b/crates/perry-runtime/src/snap_census.rs @@ -656,6 +656,7 @@ pub(crate) enum Funnel { Parallelism = 5, Gc = 6, ExecArgv = 7, + StyleBit = 8, } #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] @@ -707,6 +708,22 @@ macro_rules! snap_funnel { }; } +/// SNAPK2b `compare_literal` site (`expr/snap_compare_site.rs`, instrumented +/// compiles only): an equality compare `op` (0 `===`, 1 `!==`, 2 `==`, +/// 3 `!=`) of `left` and `right`; `literal` bit 0/1: that side is a string +/// literal. +#[cfg(perry_gc_instruments)] +#[no_mangle] +pub extern "C" fn js_snap_compare_literal(op: i32, left: f64, right: f64, literal: i32) { + crate::effect!(pure); + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + runner::compare_literal(op, left.to_bits(), right.to_bits(), literal); + } + #[cfg(not(all(target_os = "linux", target_arch = "x86_64")))] + let _ = (op, left, right, literal); +} + /// Codegen calls this right before a `process.argv` read, in instrumented /// compiles only (`expr/snap_argv_site.rs`): `kind` 1 = the read is /// `process.argv.length`, 2 = `process.argv[index]` with a constant index, @@ -741,10 +758,17 @@ pub(crate) fn clock_input(operation: &str, value: u64) { } } +/// SNAPK2b: the `stdout_style_bit` funnel computed `bit` (`tty`: forced or +/// fd 1 a tty; `depth`: getColorDepth). +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +pub(crate) fn style_bit(bit: bool, tty: bool, depth: u8) { + runner::style_bit(bit, tty, depth); +} + /// SNAP25: every captured Error pc must lie in the executable text of an /// EXEC (non-PIE) image (`stack.pc_check` row). #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] -pub(crate) fn pc_check(pcs: &[usize]) { +pub(crate) fn pc_check(pcs: &[usize], js_text: (usize, usize)) { if runner::c25() && runner::inputs_on() && runner::on_main_thread() { let (exec, lo, hi) = runner::exe_text(); let out = pcs.iter().filter(|&&pc| pc < lo || pc >= hi).count(); @@ -753,6 +777,7 @@ pub(crate) fn pc_check(pcs: &[usize]) { "stack.pc_check", format!("exec={};out={out};n={};text={lo:x}-{hi:x}", exec as u8, pcs.len()), ); + runner::runtime_pcs(pcs, js_text); } } diff --git a/crates/perry-runtime/src/snap_census/input_record.rs b/crates/perry-runtime/src/snap_census/input_record.rs index 7f470259377..e2793b56514 100644 --- a/crates/perry-runtime/src/snap_census/input_record.rs +++ b/crates/perry-runtime/src/snap_census/input_record.rs @@ -126,7 +126,14 @@ pub(super) fn event(st: &mut State, op: &str, value: String) -> u64 { | "host_value.held" ) { - st.entry_rows.push((seq, op.to_owned())); + // SNAPK2b: the argv grain P-reach narrows (`init_enter`). + let label = match op { + "argv.read" if value.starts_with("escape") => "argv.escape", + "host_value" if value.starts_with("what=argv;") => "host_value.argv", + "compare_literal" if value.contains(";src=argv") => "compare_literal.argv", + _ => op, + }; + st.entry_rows.push((seq, label.to_owned())); } let mut row = format!("{seq}\t{ir}\t{init}\t"); for (i, f) in st.frames.iter().enumerate() { diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 034257e7c1d..0d05e3a71b8 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -826,6 +826,18 @@ struct State { /// P-reach (`PERRY_SNAP_PREACH=0` off): an init an entry reaches by /// `import()`/`require` names the entry's inputs read before it. p_reach_rule: bool, + /// SNAPK2b (DESIGN27 §1.1, §1.2, §1.5, §3), each with its off knob: + /// `compare_literal` predicate rows and the P-reach argv narrowing + /// (`PERRY_SNAP_CMPLIT=0`), the `stdout_style_bit` row + /// (`PERRY_SNAP_STYLE=0`), the job-queue check (`PERRY_SNAP_JOBQ=0`), + /// the open-frame row (`PERRY_SNAP_OPENF=0`), the runtime-pc STOP + /// (`PERRY_SNAP_RTPC=0`) and the worker-flag STOP (`PERRY_SNAP_WORKER=0`). + pub(super) cmplit_rule: bool, + pub(super) style_rule: bool, + jobq_rule: bool, + openf_rule: bool, + pub(super) rtpc_rule: bool, + worker_rule: bool, /// Words an entry read (P-entry (b)). er: WordSet, /// Words whose last writer is an entry (P-entry (a)). `w`/`wby` cannot @@ -1081,6 +1093,12 @@ unsafe fn start_census(out: String) { host_rule: !std::env::var("PERRY_SNAP_HOST").is_ok_and(|v| v == "0"), p_entry_rule: !std::env::var("PERRY_SNAP_PENTRY").is_ok_and(|v| v == "0"), p_reach_rule: !std::env::var("PERRY_SNAP_PREACH").is_ok_and(|v| v == "0"), + cmplit_rule: !std::env::var("PERRY_SNAP_CMPLIT").is_ok_and(|v| v == "0"), + style_rule: !std::env::var("PERRY_SNAP_STYLE").is_ok_and(|v| v == "0"), + jobq_rule: !std::env::var("PERRY_SNAP_JOBQ").is_ok_and(|v| v == "0"), + openf_rule: !std::env::var("PERRY_SNAP_OPENF").is_ok_and(|v| v == "0"), + rtpc_rule: !std::env::var("PERRY_SNAP_RTPC").is_ok_and(|v| v == "0"), + worker_rule: !std::env::var("PERRY_SNAP_WORKER").is_ok_and(|v| v == "0"), er: WordSet::new(24), ew: WordSet::new(24), p_entry_log: Vec::with_capacity(CHECK_FAIL_MAX), @@ -1396,14 +1414,20 @@ pub(super) fn init_enter(addr: usize) { && matches!(reach, Reach::DynImport | Reach::Require) && is_entry(st, parent_id)) .then(|| { - let members: Vec = st - .entry_rows - .drain(..) + let rows: Vec<(u64, String)> = st.entry_rows.drain(..).collect(); + // SNAPK2b (DESIGN27 §2, §3): when the entry compared argv strings + // with constants, those predicates are the members its argv escape + // (the copy dispatch reads) stood for; without one it stays whole-argv. + let narrowed = st.cmplit_rule && rows.iter().any(|(_, op)| op == "compare_literal.argv"); + let members: Vec = rows + .iter() + .filter(|(_, op)| !(narrowed && (op == "argv.escape" || op == "host_value.argv"))) .map(|(s, op)| format!("{s}:{op}")) .collect(); format!( - "reach_init={};addr={addr:x};members={}", + "reach_init={};addr={addr:x};argv={};members={}", st.inits.len(), + if narrowed { "predicate" } else { "whole" }, members.join(",") ) }); @@ -1457,6 +1481,7 @@ pub(super) fn init_enter(addr: usize) { if let Some(row) = reach_row { input_record::event(st, "p_reach", row); } + k2b_enter_rows(st); set_mode_for_top(st); } @@ -1584,7 +1609,19 @@ pub(super) fn entry_body() { /// The entry module's evaluation is next (`js_snap_entry_module`). pub(super) fn entry_module() { - state().entry_module_next = true; + let st = state(); + st.entry_module_next = true; + // Codegen emits this hook only for a program with worker_threads use + // (entry.rs `entry_in_init`): its init flags are thread-local, which an + // image cannot initialize (DESIGN27 §1.5), so the cut is 0. + if st.worker_rule && inputs_on() && on_main_thread() { + let _input_logging = input_record::Scope::enter(); + input_record::event( + st, + "worker_flag", + "program_has_worker=1;cut=0;policy=STOP".to_owned(), + ); + } } /// `object_meta_ensure` stores a fresh (all-zero) record into the owner's @@ -5055,6 +5092,7 @@ pub(super) fn funnel_name(id: u8) -> &'static str { 5 => "parallelism", 6 => "gc", 7 => "exec_argv", + 8 => "style_bit", _ => "none", } } @@ -5182,3 +5220,200 @@ pub(super) fn exe_text() -> (bool, usize, usize) { EXE_TEXT[2].load(Relaxed), ) } + +/// SNAPK2b runtime-pc STOP (DESIGN27 §1.1): an Error's captured pc is imaged +/// as (program function symbol, offset); one outside the JS text section is +/// runtime code, which the runner links as the `_instr` runtime and +/// production does not, so it STOPs at the capturing init. +/// `PERRY_SNAP_RTPC_JS=none` (diagnostic) empties the program range: every +/// pc is a runtime pc (F13's STOP arm). +pub(super) fn runtime_pcs(pcs: &[usize], (lo, hi): (usize, usize)) { + let st = state(); + if !st.rtpc_rule { + return; + } + let (lo, hi) = if std::env::var("PERRY_SNAP_RTPC_JS").is_ok_and(|v| v == "none") { + (0, 0) + } else { + (lo, hi) + }; + let rt: Vec = pcs + .iter() + .copied() + .filter(|&pc| pc < lo || pc >= hi) + .collect(); + let top = top_init(st); + let row = match rt.first() { + Some(pc) => format!( + "init={top};rt={};n={};pc={pc:x};js={lo:x}-{hi:x};policy=STOP", + rt.len(), + pcs.len() + ), + None => format!( + "init={top};rt=0;n={};js={lo:x}-{hi:x};policy=symbol", + pcs.len() + ), + }; + input_record::event(st, "stack.runtime_pc", row); +} + +/// SNAPK2b `stdout_style_bit` funnel row (DESIGN27 §3 member 6): the guard +/// member is the bit, not the env values or the tty probe it came from. +pub(super) fn style_bit(bit: bool, tty: bool, depth: u8) { + if !(inputs_on() && on_main_thread()) { + return; + } + let _input_logging = input_record::Scope::enter(); + let st = state(); + if !st.style_rule { + return; + } + let by = match top_init(st) { + 0 => "runtime", + t if is_entry(st, t) => "entry", + _ => "imaged", + }; + input_record::event( + st, + "stdout_style_bit", + format!( + "bit={};tty={};depth={depth};by={by};member=predicate", + bit as u8, tty as u8 + ), + ); +} + +/// The text of a string value (heap or inline), or None. +fn string_text(bits: u64) -> Option { + let v = crate::value::JSValue::from_bits(bits); + if v.is_short_string() { + let mut buf = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let n = v.short_string_to_buf(&mut buf); + return Some(String::from_utf8_lossy(&buf[..n]).into_owned()); + } + (bits >> 48 == 0x7fff).then(|| { + input_record::Value::input_value( + &((bits & 0x0000_ffff_ffff_ffff) as *mut crate::StringHeader), + ) + }) +} + +/// Where a string operand came from: a host value's birth (argv element, +/// execPath) or an env read's string, else None. +fn compare_source(st: &State, bits: u64) -> Option { + if bits >> 48 != 0x7fff { + return None; + } + let p = (bits & 0x0000_ffff_ffff_ffff) as usize; + let h = p.wrapping_sub(crate::gc::GC_HEADER_SIZE); + if let Some(&(_, _, w, s)) = st + .host_targets + .iter() + .find(|t| (h >= t.0 && h < t.1) || (p >= t.0 && p < t.1)) + { + return Some(format!("{}@{s}", host_name(w))); + } + st.env_obs + .iter() + .rev() + .find(|o| o.ptr != 0 && o.ptr == p) + .map(|o| format!("env:{}", o.name)) +} + +/// SNAPK2b `compare_literal` (DESIGN27 §3): an equality compare of a host +/// value or env string with another string is one predicate row; the +/// guard member is "the value compares so", not the value. P-reach narrows +/// an entry's argv escape to these rows (`init_enter`). +pub(super) fn compare_literal(op: i32, left: u64, right: u64, literal: i32) { + if !(c25() && inputs_on() && on_main_thread()) { + return; + } + let _input_logging = input_record::Scope::enter(); + let st = state(); + if !st.cmplit_rule { + return; + } + let (src, other, lit) = match (compare_source(st, left), compare_source(st, right)) { + (Some(s), None) => (s, right, literal & 2 != 0), + (None, Some(s)) => (s, left, literal & 1 != 0), + _ => return, + }; + let Some(text) = string_text(other) else { + return; + }; + let value = string_text(if other == right { left } else { right }); + let eq = value.as_deref() == Some(text.as_str()); + let result = if op & 1 == 0 { eq } else { !eq }; + let opn = ["===", "!==", "==", "!="][(op & 3) as usize]; + let by = match top_init(st) { + 0 => "runtime", + t if is_entry(st, t) => "entry", + _ => "imaged", + }; + input_record::event( + st, + "compare_literal", + format!( + "op={opn};literal={};lit_kind={};src={src};result={};by={by};member=predicate", + input_record::hex(text.as_bytes()), + if lit { "const" } else { "value" }, + result as u8 + ), + ); +} + +/// SNAPK2b checks at a candidate cut, `init_enter` of the init just pushed +/// (DESIGN27 §1.1, §1.2). Open frames: every open ancestor resumes at its +/// flag test (R1: static dependency or eager entry import, before the +/// parent's body) or at its `import()` (R2); K4 images their flags 0. A +/// require edge or a job frame below the cut has no such resume point +/// (STOP). Job queue: at the cut no promise job may be queued (a TLA +/// suspended before it, or any other job, would run before the cut's +/// continuation in the build run and after the adopted image in the real +/// one), so a queued job STOPs. +fn k2b_enter_rows(st: &mut State) { + if st.openf_rule && st.frames.len() >= 2 { + let mut kinds = Vec::with_capacity(st.frames.len() - 1); + let mut stop = false; + for w in st.frames.windows(2) { + let (a, c) = (w[0], w[1]); + let k = if a.kind == FrameKind::Job || c.kind == FrameKind::Job { + "job" + } else { + match st.inits[c.init as usize].reach { + Reach::EntryEager | Reach::StaticDep => "R1", + Reach::DynImport => "R2", + Reach::Require => "require", + } + }; + stop |= k == "job" || k == "require"; + kinds.push(format!("{}:{k}", a.init)); + } + input_record::event( + st, + "open_frames", + format!( + "init={};open={};flags=0;policy={}", + st.frames.last().map_or(0, |f| f.init), + kinds.join(","), + if stop { "STOP" } else { "resume" } + ), + ); + } + if st.jobq_rule { + match crate::promise::snap_pending_jobs() { + Some(0) => {} + n => { + input_record::event( + st, + "job_queue", + format!( + "init={};pending={};policy=STOP", + st.frames.last().map_or(0, |f| f.init), + n.map_or("busy".to_owned(), |n| n.to_string()) + ), + ); + } + } + } +} diff --git a/crates/perry-runtime/src/tty.rs b/crates/perry-runtime/src/tty.rs index eabd03e8153..c6923bbc583 100644 --- a/crates/perry-runtime/src/tty.rs +++ b/crates/perry-runtime/src/tty.rs @@ -632,6 +632,22 @@ fn color_depth_for_env(env: ColorEnv<'_>) -> u8 { color_depth_from_lookup(|name| env.get(name)) } +/// Whether text styled for `process.stdout` gets its escapes: node's +/// `shouldColorize(process.stdout)`. With `FORCE_COLOR` set it is +/// `getColorDepth() > 2`; otherwise stdout must be a tty and +/// `getColorDepth() > 2` (`NO_COLOR`, `NODE_DISABLE_COLORS` and +/// `TERM=dumb` give depth 1). One funnel: the SNAP guard computes the same +/// bit at startup that the run computes here (DESIGN27 §3 member 6). +pub(crate) fn stdout_style_bit() -> bool { + crate::snap_funnel!(StyleBit); + let depth = color_depth_for_env(ColorEnv::process()); + let tty = std::env::var_os("FORCE_COLOR").is_some() || is_tty_fd(1); + let bit = tty && depth > 2; + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + crate::snap_census::style_bit(bit, tty, depth); + bit +} + fn has_colors_for_depth(count: f64, depth: u8) -> bool { count <= 2_f64.powi(depth as i32) } diff --git a/crates/perry-runtime/src/util_style_text.rs b/crates/perry-runtime/src/util_style_text.rs index 716cd5dcdf1..d552f6b2586 100644 --- a/crates/perry-runtime/src/util_style_text.rs +++ b/crates/perry-runtime/src/util_style_text.rs @@ -474,14 +474,7 @@ fn should_style(options: f64) -> bool { if !validate_stream { return true; } - if let Some(force) = std::env::var_os("FORCE_COLOR") { - let force = force.to_string_lossy(); - return !force.is_empty() && force != "0" && force.to_ascii_lowercase() != "false"; - } - if std::env::var_os("NO_COLOR").is_some() || std::env::var_os("NODE_DISABLE_COLORS").is_some() { - return false; - } - crate::tty::is_tty_fd(1) + crate::tty::stdout_style_bit() } fn csi(code: i32) -> String { From 957e9da85e57a8a434ecb8a18d7f89af6ed529cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 14:35:02 +0000 Subject: [PATCH 14/22] fix(util): styleText colours by node getColorDepth (FORCE_COLOR="" colours, TERM=dumb does not) util.styleText with the default stream check now follows node's shouldColorize(process.stdout): with FORCE_COLOR set the text is styled when getColorDepth() > 2, whatever stdout is; otherwise stdout must be a tty and getColorDepth() > 2. The old check returned false for FORCE_COLOR="" (node colours: depth 16) and coloured a tty with TERM=dumb or no TERM (node: depth 2). The decision is tty::stdout_style_bit(), over the same colour-depth lookup as process.stdout.getColorDepth(). tests/parity/run_style_text_color_tests.sh compares stdout with node for FORCE_COLOR unset/""/0/1/2/3/false, NO_COLOR, NODE_DISABLE_COLORS, TERM=dumb, TERM=xterm-256color, no TERM and two FORCE_COLOR combinations, each under a pty and through a pipe. --- crates/perry-runtime/src/tty.rs | 13 ++++ crates/perry-runtime/src/util_style_text.rs | 9 +-- tests/parity/fixtures/style-text-color-env.ts | 10 +++ tests/parity/run_style_text_color_tests.sh | 71 +++++++++++++++++++ 4 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 tests/parity/fixtures/style-text-color-env.ts create mode 100755 tests/parity/run_style_text_color_tests.sh diff --git a/crates/perry-runtime/src/tty.rs b/crates/perry-runtime/src/tty.rs index e6a8671fb62..f5a41914268 100644 --- a/crates/perry-runtime/src/tty.rs +++ b/crates/perry-runtime/src/tty.rs @@ -570,6 +570,19 @@ fn color_depth_for_env(env: ColorEnv<'_>) -> u8 { color_depth_from_lookup(|name| env.get(name)) } +/// Whether text styled for `process.stdout` gets its escapes: node's +/// `shouldColorize(process.stdout)`. With `FORCE_COLOR` set it is +/// `getColorDepth() > 2`, whatever stdout is; otherwise stdout must be a tty +/// and `getColorDepth() > 2`. So `FORCE_COLOR=""` colours (depth 16), and +/// `NO_COLOR`, `NODE_DISABLE_COLORS` and `TERM=dumb` give depth 2 unless +/// `FORCE_COLOR` is set. +pub(crate) fn stdout_style_bit() -> bool { + let env = ColorEnv::process(); + let forced = env.get("FORCE_COLOR").is_some(); + let depth = color_depth_for_env(env); + (forced || is_tty_fd(1)) && depth > 2 +} + fn has_colors_for_depth(count: f64, depth: u8) -> bool { count <= 2_f64.powi(depth as i32) } diff --git a/crates/perry-runtime/src/util_style_text.rs b/crates/perry-runtime/src/util_style_text.rs index 716cd5dcdf1..d552f6b2586 100644 --- a/crates/perry-runtime/src/util_style_text.rs +++ b/crates/perry-runtime/src/util_style_text.rs @@ -474,14 +474,7 @@ fn should_style(options: f64) -> bool { if !validate_stream { return true; } - if let Some(force) = std::env::var_os("FORCE_COLOR") { - let force = force.to_string_lossy(); - return !force.is_empty() && force != "0" && force.to_ascii_lowercase() != "false"; - } - if std::env::var_os("NO_COLOR").is_some() || std::env::var_os("NODE_DISABLE_COLORS").is_some() { - return false; - } - crate::tty::is_tty_fd(1) + crate::tty::stdout_style_bit() } fn csi(code: i32) -> String { diff --git a/tests/parity/fixtures/style-text-color-env.ts b/tests/parity/fixtures/style-text-color-env.ts new file mode 100644 index 00000000000..d96cf3d104b --- /dev/null +++ b/tests/parity/fixtures/style-text-color-env.ts @@ -0,0 +1,10 @@ +// styleText's default stream check (node's shouldColorize(process.stdout)): +// run by run_style_text_color_tests.sh under a pty and through a pipe, with +// the colour environment variables set per case. Every line is one string, so +// console.log adds no colour of its own. +import { styleText } from "node:util"; + +console.log("isTTY " + String(process.stdout.isTTY === true)); +console.log("red " + JSON.stringify(styleText("red", "x"))); +console.log("bold-green " + JSON.stringify(styleText(["bold", "green"], "ok"))); +console.log("stream-check-off " + JSON.stringify(styleText("red", "x", { validateStream: false }))); diff --git a/tests/parity/run_style_text_color_tests.sh b/tests/parity/run_style_text_color_tests.sh new file mode 100755 index 00000000000..45b9f868cce --- /dev/null +++ b/tests/parity/run_style_text_color_tests.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# util.styleText colour decision against node: FORCE_COLOR, NO_COLOR, +# NODE_DISABLE_COLORS and TERM, with stdout a pty (script(1)) and a pipe. +# Usage: run_style_text_color_tests.sh (PERRY_BIN, NODE_BIN override the tools) +# Exit 1 when any case's stdout differs from node's. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +FIXTURE="$SCRIPT_DIR/fixtures/style-text-color-env.ts" +PERRY_BIN="${PERRY_BIN:-$ROOT_DIR/target/release/perry}" +NODE_BIN="${NODE_BIN:-node}" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +if ! script -q -e -c "printf ok" /dev/null 2>/dev/null | grep -q ok; then + echo "FAIL style-text color: script(1) (util-linux) cannot allocate a pty" + exit 1 +fi + +env PERRY_ALLOW_UNIMPLEMENTED=1 PERRY_NO_AUTO_OPTIMIZE="${PERRY_NO_AUTO_OPTIMIZE:-1}" \ + "$PERRY_BIN" "$FIXTURE" -o "$WORK/style" >"$WORK/compile.log" 2>&1 || { + echo "FAIL style-text color: perry compile failed (see $WORK/compile.log)" + trap - EXIT + exit 1 +} + +# One case per line: label, then the environment (empty = no colour variables). +CASES=( + "FORCE_COLOR-unset|TERM=xterm-256color" + "FORCE_COLOR=''|FORCE_COLOR= TERM=xterm-256color" + "FORCE_COLOR=0|FORCE_COLOR=0 TERM=xterm-256color" + "FORCE_COLOR=1|FORCE_COLOR=1 TERM=xterm-256color" + "FORCE_COLOR=2|FORCE_COLOR=2 TERM=xterm-256color" + "FORCE_COLOR=3|FORCE_COLOR=3 TERM=xterm-256color" + "FORCE_COLOR=false|FORCE_COLOR=false TERM=xterm-256color" + "NO_COLOR=1|NO_COLOR=1 TERM=xterm-256color" + "NODE_DISABLE_COLORS=1|NODE_DISABLE_COLORS=1 TERM=xterm-256color" + "TERM=dumb|TERM=dumb" + "TERM=xterm-256color|TERM=xterm-256color" + "TERM-unset|" + "FORCE_COLOR=1+NO_COLOR=1|FORCE_COLOR=1 NO_COLOR=1 TERM=xterm-256color" + "FORCE_COLOR=''+TERM=dumb|FORCE_COLOR= TERM=dumb" +) + +# A fixed, colour-free base environment: the case adds its own variables. +BASE="env -i PATH=/usr/bin:/bin HOME=$WORK" + +run_case() { # mode label envv cmd -> stdout file + local mode=$1 envv=$2 cmd=$3 out=$4 + if [[ $mode == tty ]]; then + script -q -e -c "$BASE $envv $cmd 2>/dev/null" /dev/null "$out" || true + else + bash -c "$BASE $envv $cmd 2>/dev/null | cat" "$out" || true + fi +} + +fail=0 +printf "%-28s %-5s %-6s %s\n" case mode result "node red line" +for c in "${CASES[@]}"; do + label=${c%%|*}; envv=${c#*|} + for mode in tty pipe; do + run_case $mode "$envv" "$NODE_BIN --experimental-strip-types $FIXTURE" "$WORK/node.out" + run_case $mode "$envv" "$WORK/style" "$WORK/perry.out" + if [[ -s $WORK/node.out ]] && cmp -s "$WORK/node.out" "$WORK/perry.out"; then r=PASS; else r=FAIL; fail=1; fi + printf "%-28s %-5s %-6s %s\n" "$label" $mode $r "$(grep '^red ' "$WORK/node.out" || echo '')" + if [[ $r == FAIL ]]; then diff "$WORK/node.out" "$WORK/perry.out" | sed 's/^/ /' || true; fi + done +done +if [[ $fail -eq 0 ]]; then echo "PASS style-text color"; else echo "FAIL style-text color"; exit 1; fi From 4afa54803f4b42fcafa2fed991e03477d72bc15d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 16:03:00 +0000 Subject: [PATCH 15/22] snap census: K2b fixtures and checker; compare_literal compares string text scripts/snap_inputs/checkk2b.py and fixtures/snapk2b/{f3n,f4,f5,f6,f7,f8, f12,f13,fw,f2c}: each fixture is red with its rule knob off and green with it on (f8 confirms its row only). compare_literal read a heap string through the K2a value encoding ("string:") but a short string as its text, so equal text in the two representations compared unequal and the row's literal held the encoding. string_text now returns the text for both. f12 queues a promise job at module scope that is still pending when the next module starts (node order: n sees m unset); a TLA module does not reach that state in perry, which runs it to completion before its sibling. f13 makes its Error in a recursive function at module scope: a module init body is not JS text, so an Error made there captures no frame; the checker now requires a non-empty capture. --- .../perry-runtime/src/snap_census/runner.rs | 14 +- scripts/snap_inputs/checkk2b.py | 126 ++++++++++++++++++ scripts/snap_inputs/fixtures/snapk2b/f12/m.ts | 3 + .../snap_inputs/fixtures/snapk2b/f12/main.ts | 4 + scripts/snap_inputs/fixtures/snapk2b/f12/n.ts | 2 + .../fixtures/snapk2b/f12/package.json | 1 + scripts/snap_inputs/fixtures/snapk2b/f13/m.ts | 7 + .../snap_inputs/fixtures/snapk2b/f13/main.ts | 4 + .../fixtures/snapk2b/f13/package.json | 1 + scripts/snap_inputs/fixtures/snapk2b/f2c/e.ts | 3 + .../snap_inputs/fixtures/snapk2b/f2c/main.ts | 7 + .../fixtures/snapk2b/f2c/package.json | 1 + scripts/snap_inputs/fixtures/snapk2b/f3n/a.ts | 2 + .../fixtures/snapk2b/f3n/dispatch.ts | 5 + .../snap_inputs/fixtures/snapk2b/f3n/main.ts | 11 ++ .../fixtures/snapk2b/f3n/package.json | 1 + scripts/snap_inputs/fixtures/snapk2b/f4/m.ts | 2 + .../snap_inputs/fixtures/snapk2b/f4/main.ts | 3 + .../fixtures/snapk2b/f4/package.json | 1 + scripts/snap_inputs/fixtures/snapk2b/f5/m.ts | 2 + .../snap_inputs/fixtures/snapk2b/f5/main.ts | 3 + .../fixtures/snapk2b/f5/package.json | 1 + scripts/snap_inputs/fixtures/snapk2b/f6/m.ts | 3 + .../snap_inputs/fixtures/snapk2b/f6/main.ts | 3 + .../fixtures/snapk2b/f6/package.json | 1 + scripts/snap_inputs/fixtures/snapk2b/f7/a.ts | 3 + scripts/snap_inputs/fixtures/snapk2b/f7/b.ts | 1 + .../snap_inputs/fixtures/snapk2b/f7/main.ts | 5 + .../fixtures/snapk2b/f7/package.json | 1 + scripts/snap_inputs/fixtures/snapk2b/f8/m.ts | 2 + .../snap_inputs/fixtures/snapk2b/f8/main.ts | 4 + .../fixtures/snapk2b/f8/package.json | 1 + .../snap_inputs/fixtures/snapk2b/fw/main.ts | 4 + .../fixtures/snapk2b/fw/package.json | 1 + 34 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 scripts/snap_inputs/checkk2b.py create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f12/m.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f12/main.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f12/n.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f12/package.json create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f13/m.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f13/main.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f13/package.json create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f2c/e.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f2c/main.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f2c/package.json create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f3n/a.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f3n/dispatch.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f3n/main.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f3n/package.json create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f4/m.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f4/main.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f4/package.json create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f5/m.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f5/main.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f5/package.json create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f6/m.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f6/main.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f6/package.json create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f7/a.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f7/b.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f7/main.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f7/package.json create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f8/m.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f8/main.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2b/f8/package.json create mode 100644 scripts/snap_inputs/fixtures/snapk2b/fw/main.ts create mode 100644 scripts/snap_inputs/fixtures/snapk2b/fw/package.json diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 0d05e3a71b8..85039a6e2fc 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -5291,11 +5291,15 @@ fn string_text(bits: u64) -> Option { let n = v.short_string_to_buf(&mut buf); return Some(String::from_utf8_lossy(&buf[..n]).into_owned()); } - (bits >> 48 == 0x7fff).then(|| { - input_record::Value::input_value( - &((bits & 0x0000_ffff_ffff_ffff) as *mut crate::StringHeader), - ) - }) + let p = (bits & 0x0000_ffff_ffff_ffff) as *const crate::StringHeader; + if bits >> 48 != 0x7fff || p.is_null() { + return None; + } + // The text itself, not the K2a value encoding: a short and a heap string + // with equal text compare equal, and the row holds the text. + let bytes = + unsafe { std::slice::from_raw_parts(p.add(1).cast::(), (*p).byte_len as usize) }; + Some(String::from_utf8_lossy(bytes).into_owned()) } /// Where a string operand came from: a host value's birth (argv element, diff --git a/scripts/snap_inputs/checkk2b.py b/scripts/snap_inputs/checkk2b.py new file mode 100644 index 00000000000..df3160c5d2e --- /dev/null +++ b/scripts/snap_inputs/checkk2b.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""SNAPK2b census fixtures (DESIGN27 §1.1, §1.2, §1.5, §3): checkk2b.py NAME REPORT. +Exit 0 when the census rows carry the K2b rule the fixture exercises. Each +fixture's rule knob (PERRY_SNAP_CMPLIT, _STYLE, _OPENF, _JOBQ, _RTPC, _WORKER, +_PENTRY, _ENV) off must make it fail. NAME variants pick the expected value: +f5s/f5l (TERM=screen/linux), f13 (on: rt=0) and f13s (PERRY_SNAP_RTPC_JS=none: +STOP).""" +import os +import sys +name, report = sys.argv[1:3] +rows = [l.rstrip("\n").split("\t") for l in open(report + ".inputs.tsv")][1:] + + +def dec(h): + try: + return bytes.fromhex(h).decode("utf8", "replace") + except ValueError: + return h + + +ops = [(int(r[0]), int(r[2]), r[4], dec(r[5])) for r in rows if len(r) > 6] +fails = [] +if not any(o == "boundary" for _, _, o, _ in ops): + fails.append("no boundary row (the run never reached gc())") + + +def need(c, msg): + if not c: + fails.append(msg) + + +def vals(op): + return [v for _, _, o, v in ops if o == op] + + +def kv(v): + return dict(p.split("=", 1) for p in v.split(";") if "=" in p) + + +def inits(op, pred=lambda v: True): + return {i for _, i, o, v in ops if o == op and pred(v)} + + +def entry_init(): + # The fixture's entry reads SNAPK2B_ENTRY; that row's init is the entry. + e = inits("env_get", lambda v: v.startswith('"SNAPK2B_ENTRY"')) + return next(iter(e)) if len(e) == 1 else None + + +cl = [kv(v) for v in vals("compare_literal")] +info = "" +if name == "f3n": + reach = [kv(v) for v in vals("p_reach")] + need(len(reach) == 1, f"want 1 reach point (a.ts), got {len(reach)}") + argv_cl = [c for c in cl if c["src"].startswith("argv@")] + need(len(argv_cl) == 2 and all(c["result"] == "0" and c["by"] == "entry" for c in argv_cl), + f"want two false entry argv compare_literal rows, got {argv_cl}") + need({dec(c["literal"]) for c in argv_cl} == {"--host-a", "--host-b"}, "sentinels not the literals") + if reach: + r = reach[0] + members = [m.split(":", 1)[1] for m in r.get("members", "").split(",") if ":" in m] + info = f"argv={r.get('argv')} members={members}" + need(r.get("argv") == "predicate", f"argv escape not narrowed: argv={r.get('argv')}") + need(members.count("compare_literal.argv") == 2, "members lack the two predicates") + need(not any(m in ("argv.escape", "host_value.argv") for m in members), "argv escape still a member") +elif name in ("f5s", "f5l"): + want = "1" if name == "f5s" else "0" + env = [c for c in cl if c["src"] == "env:TERM"] + info = f"rows={env}" + need(len(env) == 1, f"want one TERM compare_literal row, got {len(env)}") + need(all(dec(c["literal"]) == "linux" and c["result"] == want and c["by"] == "imaged" for c in env), + f"TERM row not literal=linux result={want} by=imaged") +elif name == "f6": + sb = [kv(v) for v in vals("stdout_style_bit")] + info = f"rows={sb}" + need(len(sb) == 1, f"want one stdout_style_bit row, got {len(sb)}") + need(all(r["bit"] == "0" and r["tty"] == "0" and r["by"] == "imaged" for r in sb), + "style row not bit=0 tty=0 by=imaged") +elif name == "f7": + of = [kv(v) for v in vals("open_frames")] + info = f"rows={of}" + both = [r for r in of if {k.split(":")[1] for k in r["open"].split(",")} == {"R1", "R2"}] + need(len(both) == 1 and both[0]["policy"] == "resume" and both[0]["flags"] == "0", + "no resume open_frames row with an R1 and an R2 ancestor") + need(not any(r["policy"] == "STOP" for r in of), "open_frames STOP") +elif name == "f12": + jq = [kv(v) for v in vals("job_queue")] + info = f"rows={jq}" + need(len(jq) >= 1 and all(r["policy"] == "STOP" for r in jq), "no job_queue STOP row") +elif name in ("f13", "f13s"): + rp = [kv(v) for v in vals("stack.runtime_pc")] + m_init = inits("stack.runtime_pc") + info = f"rows={rp}" + need(len(rp) >= 1, "no stack.runtime_pc row") + # An empty capture classifies nothing: the arm must hold JS frames. + need(all(int(r["n"]) > 0 for r in rp), "a capture with no frames") + if name == "f13": + need(all(r["rt"] == "0" and r["policy"] == "symbol" for r in rp), "a runtime pc in a JS stack") + else: + need(all(int(r["rt"]) > 0 and r["policy"] == "STOP" for r in rp), "no runtime-pc STOP") + need(0 not in m_init and all(r["init"] != "0" for r in rp), "capture outside an init") +elif name == "fw": + wf = [kv(v) for v in vals("worker_flag")] + info = f"rows={wf}" + need(len(wf) == 1 and wf[0]["cut"] == "0" and wf[0]["policy"] == "STOP", "no worker_flag STOP row") +elif name == "f2c": + e = entry_init() + c = [kv(v) for v in vals("p_entry") if kv(v).get("rule") == "c"] + info = f"entry={e} c=" + ",".join(f"{p.get('holder_init')}/{p.get('holder_type')}@{p.get('off')}" for p in c) + need(e is not None, "no entry env row") + need(len(c) == 1 and c[0]["policy"] == "STOP", f"want one (c) STOP holder, got {len(c)}") + need(all(p.get("holder_init") not in (None, "0", str(e)) for p in c), "(c) holder not the eager init") +elif name == "f4": + lines = open(report).read().splitlines() + [l for l in open(report + ".inputs.tsv")] + g = [l.split("\t") for l in lines if l.startswith("#guard\tSNAPK2B_UNSET\t")] + info = f"guards={g}" + need(any(x[2] == "" and x[5] == "store_unset" for x in g), "no store_unset guard for SNAPK2B_UNSET") +elif name == "f8": + e = entry_init() + u = inits("ids.uid") + info = f"entry={e} uid_inits={u}" + need(e is not None and len(u) == 1 and not (u & {0, e}), "ids.uid not read by the imaged init") +else: + fails.append("unknown fixture") +print(f"{name} {'PASS' if not fails else 'FAIL ' + '; '.join(fails)} | {info}"[:600]) +sys.exit(1 if fails else 0) diff --git a/scripts/snap_inputs/fixtures/snapk2b/f12/m.ts b/scripts/snap_inputs/fixtures/snapk2b/f12/m.ts new file mode 100644 index 00000000000..12495cc21fa --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f12/m.ts @@ -0,0 +1,3 @@ +// A promise job queued at module scope is still pending when n.ts starts. +Promise.resolve().then(() => { (globalThis as any).m = 1; }); +export {}; diff --git a/scripts/snap_inputs/fixtures/snapk2b/f12/main.ts b/scripts/snap_inputs/fixtures/snapk2b/f12/main.ts new file mode 100644 index 00000000000..947a5bf4a0a --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f12/main.ts @@ -0,0 +1,4 @@ +import "./m.ts"; +import "./n.ts"; +gc(); +console.log("K2B_F12", (globalThis as any).m, (globalThis as any).n); diff --git a/scripts/snap_inputs/fixtures/snapk2b/f12/n.ts b/scripts/snap_inputs/fixtures/snapk2b/f12/n.ts new file mode 100644 index 00000000000..864b59f4f64 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f12/n.ts @@ -0,0 +1,2 @@ +(globalThis as any).n = (globalThis as any).m === undefined ? "before" : "after"; +export {}; diff --git a/scripts/snap_inputs/fixtures/snapk2b/f12/package.json b/scripts/snap_inputs/fixtures/snapk2b/f12/package.json new file mode 100644 index 00000000000..28340bfda4f --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f12/package.json @@ -0,0 +1 @@ +{"name":"snapk2b-f12","type":"module"} diff --git a/scripts/snap_inputs/fixtures/snapk2b/f13/m.ts b/scripts/snap_inputs/fixtures/snapk2b/f13/m.ts new file mode 100644 index 00000000000..41b996caf67 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f13/m.ts @@ -0,0 +1,7 @@ +// An Error made two calls deep in a recursive JS function at module scope: +// the capture holds those function frames (a module init body is not a +// JS-text frame), and the classifier sorts their pcs. +function make(depth: number): Error { + return depth > 0 ? make(depth - 1) : new Error("k2b"); +} +export const e = make(2); diff --git a/scripts/snap_inputs/fixtures/snapk2b/f13/main.ts b/scripts/snap_inputs/fixtures/snapk2b/f13/main.ts new file mode 100644 index 00000000000..1335d172e75 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f13/main.ts @@ -0,0 +1,4 @@ +import { e } from "./m.ts"; +gc(); +// Reading .stack names the capturing function, so its body is JS text. +console.log("K2B_F13", e.message, String(e.stack).includes("make")); diff --git a/scripts/snap_inputs/fixtures/snapk2b/f13/package.json b/scripts/snap_inputs/fixtures/snapk2b/f13/package.json new file mode 100644 index 00000000000..6b1b4c26853 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f13/package.json @@ -0,0 +1 @@ +{"name":"snapk2b-f13","type":"module"} diff --git a/scripts/snap_inputs/fixtures/snapk2b/f2c/e.ts b/scripts/snap_inputs/fixtures/snapk2b/f2c/e.ts new file mode 100644 index 00000000000..238ae2d83aa --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f2c/e.ts @@ -0,0 +1,3 @@ +// An eager module object with properties past its inline slots (a spill). +export const box: any = {}; +for (let i = 0; i < 24; i++) box["k" + i] = i; diff --git a/scripts/snap_inputs/fixtures/snapk2b/f2c/main.ts b/scripts/snap_inputs/fixtures/snapk2b/f2c/main.ts new file mode 100644 index 00000000000..b4a3d1a9cc2 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f2c/main.ts @@ -0,0 +1,7 @@ +import { box } from "./e.ts"; +const tag = process.env.SNAPK2B_ENTRY; +// (c): an entry birth stored as a property value of the eager object, +// landing in its spill storage. +box.held = { tag: "entry" }; +gc(); +console.log("K2B_F2C", box.held.tag, tag ?? "-"); diff --git a/scripts/snap_inputs/fixtures/snapk2b/f2c/package.json b/scripts/snap_inputs/fixtures/snapk2b/f2c/package.json new file mode 100644 index 00000000000..00c94d89185 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f2c/package.json @@ -0,0 +1 @@ +{"name":"snapk2b-f2c","type":"module"} diff --git a/scripts/snap_inputs/fixtures/snapk2b/f3n/a.ts b/scripts/snap_inputs/fixtures/snapk2b/f3n/a.ts new file mode 100644 index 00000000000..c97ed23574c --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f3n/a.ts @@ -0,0 +1,2 @@ +(globalThis as any).seen = "a"; +export {}; diff --git a/scripts/snap_inputs/fixtures/snapk2b/f3n/dispatch.ts b/scripts/snap_inputs/fixtures/snapk2b/f3n/dispatch.ts new file mode 100644 index 00000000000..9e0013312cc --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f3n/dispatch.ts @@ -0,0 +1,5 @@ +export const HOST_A = "--host-a"; +export const HOST_B = "--host-b"; +export function isHost(a: string[]): boolean { + return a[0] === HOST_A || a[0] === HOST_B; +} diff --git a/scripts/snap_inputs/fixtures/snapk2b/f3n/main.ts b/scripts/snap_inputs/fixtures/snapk2b/f3n/main.ts new file mode 100644 index 00000000000..cf80b72a412 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f3n/main.ts @@ -0,0 +1,11 @@ +// dispatch shape (dispatch.ts:8,14): the entry copies argv, an imported +// function compares the copy's first element with two exported sentinels, +// and the entry reaches a.ts only when neither matches. +import { isHost } from "./dispatch.ts"; +const argv = process.argv.slice(2); +if (!isHost(argv)) { + await import("./a.ts"); +} +// The census boundary (snap25 fixtures mark it the same way). +gc(); +console.log("K2B_F3N", (globalThis as any).seen ?? "none"); diff --git a/scripts/snap_inputs/fixtures/snapk2b/f3n/package.json b/scripts/snap_inputs/fixtures/snapk2b/f3n/package.json new file mode 100644 index 00000000000..e78883d9741 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f3n/package.json @@ -0,0 +1 @@ +{"name":"snapk2b-f3n","type":"module"} diff --git a/scripts/snap_inputs/fixtures/snapk2b/f4/m.ts b/scripts/snap_inputs/fixtures/snapk2b/f4/m.ts new file mode 100644 index 00000000000..cb0232a349c --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f4/m.ts @@ -0,0 +1,2 @@ +// A store site of an unset variable: the imaged init keeps "unset". +export const v = process.env.SNAPK2B_UNSET; diff --git a/scripts/snap_inputs/fixtures/snapk2b/f4/main.ts b/scripts/snap_inputs/fixtures/snapk2b/f4/main.ts new file mode 100644 index 00000000000..73241760077 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f4/main.ts @@ -0,0 +1,3 @@ +import { v } from "./m.ts"; +gc(); +console.log("K2B_F4", v === undefined); diff --git a/scripts/snap_inputs/fixtures/snapk2b/f4/package.json b/scripts/snap_inputs/fixtures/snapk2b/f4/package.json new file mode 100644 index 00000000000..905d5b03167 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f4/package.json @@ -0,0 +1 @@ +{"name":"snapk2b-f4","type":"module"} diff --git a/scripts/snap_inputs/fixtures/snapk2b/f5/m.ts b/scripts/snap_inputs/fixtures/snapk2b/f5/m.ts new file mode 100644 index 00000000000..8f2e899cec7 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f5/m.ts @@ -0,0 +1,2 @@ +// The TERM predicate: the guard member is the comparison, not TERM. +export const t = process.env.TERM !== "linux"; diff --git a/scripts/snap_inputs/fixtures/snapk2b/f5/main.ts b/scripts/snap_inputs/fixtures/snapk2b/f5/main.ts new file mode 100644 index 00000000000..540912f0eeb --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f5/main.ts @@ -0,0 +1,3 @@ +import { t } from "./m.ts"; +gc(); +console.log("K2B_F5", t); diff --git a/scripts/snap_inputs/fixtures/snapk2b/f5/package.json b/scripts/snap_inputs/fixtures/snapk2b/f5/package.json new file mode 100644 index 00000000000..21f2c8c8c5b --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f5/package.json @@ -0,0 +1 @@ +{"name":"snapk2b-f5","type":"module"} diff --git a/scripts/snap_inputs/fixtures/snapk2b/f6/m.ts b/scripts/snap_inputs/fixtures/snapk2b/f6/m.ts new file mode 100644 index 00000000000..f1db330921c --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f6/m.ts @@ -0,0 +1,3 @@ +import { styleText } from "node:util"; +// Module-scope styleText: the imaged string depends on the style bit. +export const s = styleText("red", "x"); diff --git a/scripts/snap_inputs/fixtures/snapk2b/f6/main.ts b/scripts/snap_inputs/fixtures/snapk2b/f6/main.ts new file mode 100644 index 00000000000..acc6247f20a --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f6/main.ts @@ -0,0 +1,3 @@ +import { s } from "./m.ts"; +gc(); +console.log("K2B_F6", JSON.stringify(s)); diff --git a/scripts/snap_inputs/fixtures/snapk2b/f6/package.json b/scripts/snap_inputs/fixtures/snapk2b/f6/package.json new file mode 100644 index 00000000000..a6cc73df60e --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f6/package.json @@ -0,0 +1 @@ +{"name":"snapk2b-f6","type":"module"} diff --git a/scripts/snap_inputs/fixtures/snapk2b/f7/a.ts b/scripts/snap_inputs/fixtures/snapk2b/f7/a.ts new file mode 100644 index 00000000000..6ce11566018 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f7/a.ts @@ -0,0 +1,3 @@ +import { b } from "./b.ts"; +(globalThis as any).ab = "a" + b; +export {}; diff --git a/scripts/snap_inputs/fixtures/snapk2b/f7/b.ts b/scripts/snap_inputs/fixtures/snapk2b/f7/b.ts new file mode 100644 index 00000000000..59d1689930e --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f7/b.ts @@ -0,0 +1 @@ +export const b = "b"; diff --git a/scripts/snap_inputs/fixtures/snapk2b/f7/main.ts b/scripts/snap_inputs/fixtures/snapk2b/f7/main.ts new file mode 100644 index 00000000000..52a0fe44583 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f7/main.ts @@ -0,0 +1,5 @@ +// a.ts is reached by import() (R2) and imports b.ts statically (R1): at +// b.ts's init both the entry and a.ts are open frames. +await import("./a.ts"); +gc(); +console.log("K2B_F7", (globalThis as any).ab); diff --git a/scripts/snap_inputs/fixtures/snapk2b/f7/package.json b/scripts/snap_inputs/fixtures/snapk2b/f7/package.json new file mode 100644 index 00000000000..bb7281bdb37 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f7/package.json @@ -0,0 +1 @@ +{"name":"snapk2b-f7","type":"module"} diff --git a/scripts/snap_inputs/fixtures/snapk2b/f8/m.ts b/scripts/snap_inputs/fixtures/snapk2b/f8/m.ts new file mode 100644 index 00000000000..c89477996f8 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f8/m.ts @@ -0,0 +1,2 @@ +// A host id read by an imaged init. +export const uid = process.getuid!(); diff --git a/scripts/snap_inputs/fixtures/snapk2b/f8/main.ts b/scripts/snap_inputs/fixtures/snapk2b/f8/main.ts new file mode 100644 index 00000000000..e4ba2913b52 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f8/main.ts @@ -0,0 +1,4 @@ +const tag = process.env.SNAPK2B_ENTRY; +const { uid } = await import("./m.ts"); +gc(); +console.log("K2B_F8", typeof uid, tag ?? "-"); diff --git a/scripts/snap_inputs/fixtures/snapk2b/f8/package.json b/scripts/snap_inputs/fixtures/snapk2b/f8/package.json new file mode 100644 index 00000000000..b7cb2ed5838 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/f8/package.json @@ -0,0 +1 @@ +{"name":"snapk2b-f8","type":"module"} diff --git a/scripts/snap_inputs/fixtures/snapk2b/fw/main.ts b/scripts/snap_inputs/fixtures/snapk2b/fw/main.ts new file mode 100644 index 00000000000..794d3c1e7a6 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/fw/main.ts @@ -0,0 +1,4 @@ +// worker_threads use: init flags are thread-local, no image (cut 0). +import { isMainThread } from "node:worker_threads"; +gc(); +console.log("K2B_FW", isMainThread); diff --git a/scripts/snap_inputs/fixtures/snapk2b/fw/package.json b/scripts/snap_inputs/fixtures/snapk2b/fw/package.json new file mode 100644 index 00000000000..c0106f0d99c --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2b/fw/package.json @@ -0,0 +1 @@ +{"name":"snapk2b-fw","type":"module"} From 3aec8ffb82b917721c0947f5c098feebe4d5ec86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 17:01:04 +0000 Subject: [PATCH 16/22] snap census: compare_literal hook from the compare lowering itself The compare_literal census hook kept a thread-local list of compare sites keyed by Expr address. A compare whose numeric-literal or typeof operand is lowered without lower_expr never completed its entry; once a codegen thread moved to the next unit, an expression at the freed literal's address emitted js_snap_compare_literal with the earlier function's register (Supabase: precise_roots native-root lowering panic in an unrelated closure). expr::compare::lower now emits the hook from its own re-read operand values, right where it compares them. Fast paths (typeof, numeric literal, chained and typed compares) emit no row: the read stays a value member. No site list remains. The new test compiles a module of fast-path compares, then a module with no compare, and asserts the second emits no hook (33 stray hooks on the old code). --- crates/perry-codegen/src/expr/compare.rs | 7 ++ crates/perry-codegen/src/expr/dispatch.rs | 2 - .../src/expr/snap_compare_site.rs | 112 ++++++----------- .../src/expr/snap_compare_site_tests.rs | 114 ++++++++++++++++++ 4 files changed, 159 insertions(+), 76 deletions(-) create mode 100644 crates/perry-codegen/src/expr/snap_compare_site_tests.rs diff --git a/crates/perry-codegen/src/expr/compare.rs b/crates/perry-codegen/src/expr/compare.rs index e0769082b64..592e18aaff5 100644 --- a/crates/perry-codegen/src/expr/compare.rs +++ b/crates/perry-codegen/src/expr/compare.rs @@ -1437,6 +1437,13 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { }; let l = operand_value(ctx, &left_literal); let r = operand_value(ctx, &right_literal); + // SNAPK2b census (instrumented compiles only): the hook reads + // the two values this compare reads, here, where they are + // live. A numeric-literal operand is never a string, so that + // compare keeps no row and emits none. + if left_literal.is_none() && right_literal.is_none() { + super::snap_compare_site::emit(ctx, *op, left, right, &l, &r); + } // BigInt comparison fast path: NaN-tagged BIGINT_TAG values // are unordered under fcmp (NaN), so `a > b` on two bigints // always returns false. Route through js_bigint_cmp which diff --git a/crates/perry-codegen/src/expr/dispatch.rs b/crates/perry-codegen/src/expr/dispatch.rs index 9b11c94c1e4..368e3032daa 100644 --- a/crates/perry-codegen/src/expr/dispatch.rs +++ b/crates/perry-codegen/src/expr/dispatch.rs @@ -31,7 +31,6 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { crate::expr::layout_read::run_enter(ctx, &run); let value = lower_expr_inner(ctx, expr)?; crate::expr::layout_read::run_exit(ctx, &run); - crate::expr::snap_compare_site::lowered(ctx, expr, &value); if let Some(desc) = crate::layout_desc::array_families::birth_layout(expr) { Ok(ctx.block().call( DOUBLE, @@ -55,7 +54,6 @@ fn lower_expr_inner(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // expression stores is a store site (`expr/snap_env_site.rs`). super::snap_env_site::note_store_parent(ctx, expr); super::snap_argv_site::note_parent(expr); - super::snap_compare_site::note_parent(expr); // #9856: under `--debug-symbols`, record this expression's source line // for the DWARF line table (`crate::debug_info`). No-op otherwise. crate::debug_info::mark_expr_location(ctx, expr); diff --git a/crates/perry-codegen/src/expr/snap_compare_site.rs b/crates/perry-codegen/src/expr/snap_compare_site.rs index 70616ceb67d..63b2a6aa808 100644 --- a/crates/perry-codegen/src/expr/snap_compare_site.rs +++ b/crates/perry-codegen/src/expr/snap_compare_site.rs @@ -2,16 +2,19 @@ //! instrumented compiles only (`PERRY_SNAP_CENSUS` set while compiling). An //! ordinary compile emits nothing. //! -//! An equality compare (`===`, `!==`, `==`, `!=`) calls -//! `js_snap_compare_literal(op, left, right, literal)` once both operands -//! are lowered, before the compare itself; `literal` has bit 0 (1) set when -//! the left (right) operand is a string literal. The runtime keeps a row only -//! when one operand is a host value or env string and the other a string: -//! the guard then holds the predicate, not the host value. A compare whose -//! operands a fast path lowers without `lower_expr` emits no hook, and the -//! read stays a value member. No table is emitted. - -use std::cell::RefCell; +//! The generic equality compare (`===`, `!==`, `==`, `!=` in +//! `expr::compare::lower`) calls [`emit`] with the two operand values it is +//! about to compare, so the hook reads exactly the values the compare reads, +//! in the function and block that compares them. `js_snap_compare_literal(op, +//! left, right, literal)` runs before the compare itself; `literal` has bit 0 +//! (1) set when the left (right) operand is a string literal. The runtime keeps +//! a row only when one operand is a host value or env string and the other a +//! string: the guard then holds the predicate, not the host value. +//! +//! A compare a fast path lowers (`typeof x === "…"`, a numeric literal operand, +//! a typed or chained compare) never reaches [`emit`] and emits no row, so the +//! read stays a whole-value member. Nothing is kept between calls and no table +//! is emitted. use perry_hir::{CompareOp, Expr}; @@ -19,26 +22,16 @@ use crate::types::{DOUBLE, I32}; use super::FnCtx; -struct Site { - left: usize, - right: usize, - op: i32, - literal: i32, - values: [Option; 2], -} - -thread_local! { - static SITES: RefCell> = const { RefCell::new(Vec::new()) }; -} - -/// `expr` is about to be lowered: note an equality compare's two operands. -pub(crate) fn note_parent(expr: &Expr) { - if !super::snap_env_site::census_compile() { - return; - } - let Expr::Compare { op, left, right } = expr else { - return; - }; +/// Emit the census hook for the equality compare `left op right` whose +/// operands the caller has lowered to the JS values `l` and `r`. +pub(crate) fn emit( + ctx: &mut FnCtx<'_>, + op: CompareOp, + left: &Expr, + right: &Expr, + l: &str, + r: &str, +) { let op = match op { CompareOp::Eq => 0, CompareOp::Ne => 1, @@ -46,51 +39,22 @@ pub(crate) fn note_parent(expr: &Expr) { CompareOp::LooseNe => 3, _ => return, }; - let literal = i32::from(matches!(**left, Expr::String(_))) - | (i32::from(matches!(**right, Expr::String(_))) << 1); - SITES.with(|s| { - s.borrow_mut().push(Site { - left: &**left as *const Expr as usize, - right: &**right as *const Expr as usize, - op, - literal, - values: [None, None], - }) - }); -} - -/// `expr` was lowered to `value`: when it is an operand of a noted compare, -/// keep it; once both are kept, emit the hook. -pub(crate) fn lowered(ctx: &mut FnCtx<'_>, expr: &Expr, value: &str) { if !super::snap_env_site::census_compile() { return; } - let addr = expr as *const Expr as usize; - let ready = SITES.with(|s| { - let mut s = s.borrow_mut(); - let i = s.iter().rposition(|t| { - (t.left == addr && t.values[0].is_none()) || (t.right == addr && t.values[1].is_none()) - })?; - let t = &mut s[i]; - let side = usize::from(!(t.left == addr && t.values[0].is_none())); - t.values[side] = Some(value.to_owned()); - if t.values.iter().all(Option::is_some) { - let t = s.swap_remove(i); - let [l, r] = t.values; - Some((t.op, l.unwrap(), r.unwrap(), t.literal)) - } else { - None - } - }); - if let Some((op, l, r, literal)) = ready { - ctx.block().call_void( - "js_snap_compare_literal", - &[ - (I32, &op.to_string()), - (DOUBLE, &l), - (DOUBLE, &r), - (I32, &literal.to_string()), - ], - ); - } + let literal = i32::from(matches!(left, Expr::String(_))) + | (i32::from(matches!(right, Expr::String(_))) << 1); + ctx.block().call_void( + "js_snap_compare_literal", + &[ + (I32, &op.to_string()), + (DOUBLE, l), + (DOUBLE, r), + (I32, &literal.to_string()), + ], + ); } + +#[cfg(test)] +#[path = "snap_compare_site_tests.rs"] +mod tests; diff --git a/crates/perry-codegen/src/expr/snap_compare_site_tests.rs b/crates/perry-codegen/src/expr/snap_compare_site_tests.rs new file mode 100644 index 00000000000..e8aae8679b2 --- /dev/null +++ b/crates/perry-codegen/src/expr/snap_compare_site_tests.rs @@ -0,0 +1,114 @@ +//! SNAPK2c: the `compare_literal` hook reads only its own compare's operand +//! values. 4afa54803f kept an address-keyed site list across functions: a +//! compare whose numeric-literal or `typeof` operand is lowered without +//! `lower_expr` left a half-filled entry behind, and a later module's +//! expression at the freed literal's address emitted the hook with the first +//! module's register (Supabase: precise_roots.rs panic in an unrelated closure). + +use perry_hir::types::Type; +use perry_hir::{BinaryOp, CompareOp, Expr, Module, Stmt}; + +fn ir_of(m: &Module) -> String { + String::from_utf8( + crate::compile_module( + m, + crate::CompileOptions { + emit_ir_only: true, + is_entry_module: true, + target: Some("x86_64-unknown-linux-gnu".into()), + ..Default::default() + }, + ) + .unwrap(), + ) + .unwrap() +} + +fn local(id: u32, ty: Type, init: Expr) -> Stmt { + Stmt::Let { + id, + name: format!("k2c_{id}"), + ty, + mutable: true, + init: Some(init), + } +} + +fn cmp(op: CompareOp, left: Expr, right: Expr) -> Expr { + Expr::Compare { + op, + left: Box::new(left), + right: Box::new(right), + } +} + +fn hooks(ir: &str) -> usize { + ir.matches("call void @js_snap_compare_literal(").count() +} + +/// Module A: equality compares lowered by the numeric-literal and `typeof` +/// fast paths (no hook). Module B: no equality compare at all, built after A +/// is dropped so its nodes reuse A's freed addresses. +#[test] +fn compare_literal_hook_never_crosses_compares() { + std::env::set_var("PERRY_SNAP_CENSUS", "1"); + for round in 0..8 { + let mut a = Module::new("k2c_a"); + a.init.push(local(0, Type::Any, Expr::Undefined)); + a.init.push(local(1, Type::Any, Expr::Undefined)); + for i in 0..48u32 { + let lit = cmp(CompareOp::Eq, Expr::LocalGet(0), Expr::Number(1.0)); + let ty = cmp( + CompareOp::Eq, + Expr::TypeOf(Box::new(Expr::LocalGet(1))), + Expr::String("string".into()), + ); + a.init.push(local(10 + 2 * i, Type::Any, lit)); + a.init.push(local(11 + 2 * i, Type::Any, ty)); + } + let ir_a = ir_of(&a); + assert_eq!(hooks(&ir_a), 0, "fast-path compares emit no hook: {ir_a}"); + drop(a); + let mut b = Module::new("k2c_b"); + b.init.push(local(0, Type::Any, Expr::Undefined)); + b.init.push(local(1, Type::Any, Expr::Undefined)); + for i in 0..96u32 { + let sum = Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(0)), + right: Box::new(Expr::LocalGet(1)), + }; + b.init.push(local(10 + i, Type::Any, sum)); + } + let ir_b = ir_of(&b); + assert_eq!( + hooks(&ir_b), + 0, + "round {round}: B has no equality compare: {ir_b}" + ); + } + // The generic compare emits one hook over its own two operand values. + let mut c = Module::new("k2c_c"); + c.init.push(local(0, Type::Any, Expr::Undefined)); + c.init.push(local( + 1, + Type::Any, + cmp( + CompareOp::Ne, + Expr::LocalGet(0), + Expr::String("linux".into()), + ), + )); + let ir_c = ir_of(&c); + std::env::remove_var("PERRY_SNAP_CENSUS"); + assert_eq!(hooks(&ir_c), 1, "{ir_c}"); + let call = ir_c + .lines() + .find(|l| l.contains("@js_snap_compare_literal(")) + .unwrap(); + assert!(call.contains("(i32 1, double "), "op !== : {call}"); + assert!( + call.trim_end().ends_with("i32 2)"), + "right is the literal: {call}" + ); +} From 71710bc7077c0aacfffdc5540e5d453dd6d2e836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 17:35:38 +0000 Subject: [PATCH 17/22] snap census: compare hook test reads the call line, not the declaration --- crates/perry-codegen/src/expr/snap_compare_site_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/perry-codegen/src/expr/snap_compare_site_tests.rs b/crates/perry-codegen/src/expr/snap_compare_site_tests.rs index e8aae8679b2..8cf1fd3ff2e 100644 --- a/crates/perry-codegen/src/expr/snap_compare_site_tests.rs +++ b/crates/perry-codegen/src/expr/snap_compare_site_tests.rs @@ -104,7 +104,7 @@ fn compare_literal_hook_never_crosses_compares() { assert_eq!(hooks(&ir_c), 1, "{ir_c}"); let call = ir_c .lines() - .find(|l| l.contains("@js_snap_compare_literal(")) + .find(|l| l.contains("call void @js_snap_compare_literal(")) .unwrap(); assert!(call.contains("(i32 1, double "), "op !== : {call}"); assert!( From 5e114c7aa7ffd5f5bcfc654c789275319b54fee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 23:28:48 +0000 Subject: [PATCH 18/22] snap census: env/argv read sites classify against their own parent The process.env / process.argv census arms looked their parent up in site lists keyed by node address, so a later module whose nodes reused freed addresses read stale entries. The lists are gone: FnCtx keeps the two innermost expressions being lowered (installed by lower_expr and by a module-global let, restored on every return), and each read classifies itself against its own parent. The scalar-replacement paths of lower_let lower literal operands with the literal installed as their parent. Test env_argv_kinds_never_cross_reads. --- crates/perry-codegen/src/codegen/closure.rs | 1 + crates/perry-codegen/src/codegen/entry.rs | 1 + .../src/codegen/entry/module_init.rs | 1 + crates/perry-codegen/src/codegen/function.rs | 1 + crates/perry-codegen/src/codegen/method.rs | 1 + .../src/codegen/method_static.rs | 1 + crates/perry-codegen/src/expr/dispatch.rs | 11 +- crates/perry-codegen/src/expr/mod.rs | 5 + .../perry-codegen/src/expr/snap_argv_site.rs | 57 ++---- .../perry-codegen/src/expr/snap_env_site.rs | 172 ++++++++++++------ .../src/expr/snap_env_site_tests.rs | 151 +++++++++++++++ .../perry-codegen/src/stmt/let_scalar_new.rs | 20 +- crates/perry-codegen/src/stmt/let_stmt.rs | 26 ++- crates/perry-codegen/src/stmt/mod.rs | 3 +- 14 files changed, 339 insertions(+), 112 deletions(-) create mode 100644 crates/perry-codegen/src/expr/snap_env_site_tests.rs diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 4d791c9ab00..5796b3fb5c6 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -1150,6 +1150,7 @@ pub(super) fn compile_closure( current_block: 0, discard_expr_value: false, discard_this_expr: false, + snap_lowering: Default::default(), literal_chunk_fill: false, truthy_call_result_requested: false, pending_truthy_call_result: None, diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 29fd54becff..1c273ac9f81 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -879,6 +879,7 @@ pub(super) fn compile_module_entry( current_block: 0, discard_expr_value: false, discard_this_expr: false, + snap_lowering: Default::default(), literal_chunk_fill: false, truthy_call_result_requested: false, pending_truthy_call_result: None, diff --git a/crates/perry-codegen/src/codegen/entry/module_init.rs b/crates/perry-codegen/src/codegen/entry/module_init.rs index e91fb95d956..1975c80a606 100644 --- a/crates/perry-codegen/src/codegen/entry/module_init.rs +++ b/crates/perry-codegen/src/codegen/entry/module_init.rs @@ -306,6 +306,7 @@ pub(super) fn compile_module_init( current_block: 0, discard_expr_value: false, discard_this_expr: false, + snap_lowering: Default::default(), literal_chunk_fill: false, truthy_call_result_requested: false, pending_truthy_call_result: None, diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 637b066b827..d94e23a50bd 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -1399,6 +1399,7 @@ pub(super) fn compile_function( current_block: 0, discard_expr_value: false, discard_this_expr: false, + snap_lowering: Default::default(), literal_chunk_fill: false, truthy_call_result_requested: false, pending_truthy_call_result: None, diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index 46657588a54..13728aaaa9c 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -684,6 +684,7 @@ pub(super) fn compile_method( current_block: 0, discard_expr_value: false, discard_this_expr: false, + snap_lowering: Default::default(), literal_chunk_fill: false, truthy_call_result_requested: false, pending_truthy_call_result: None, diff --git a/crates/perry-codegen/src/codegen/method_static.rs b/crates/perry-codegen/src/codegen/method_static.rs index 8a9d8b6c989..0c6db1d1745 100644 --- a/crates/perry-codegen/src/codegen/method_static.rs +++ b/crates/perry-codegen/src/codegen/method_static.rs @@ -282,6 +282,7 @@ pub(in crate::codegen) fn compile_static_method( current_block: 0, discard_expr_value: false, discard_this_expr: false, + snap_lowering: Default::default(), literal_chunk_fill: false, truthy_call_result_requested: false, pending_truthy_call_result: None, diff --git a/crates/perry-codegen/src/expr/dispatch.rs b/crates/perry-codegen/src/expr/dispatch.rs index 368e3032daa..25a63f4374a 100644 --- a/crates/perry-codegen/src/expr/dispatch.rs +++ b/crates/perry-codegen/src/expr/dispatch.rs @@ -29,7 +29,12 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // What the expression does to the receiver run in flight. let run = crate::expr::layout_read::run_transparency(expr); crate::expr::layout_read::run_enter(ctx, &run); - let value = lower_expr_inner(ctx, expr)?; + // SNAP census frame: `expr` is the current node for the extent of its + // own lowering, restored on every return (`expr/snap_env_site.rs`). + let snap_frame = super::snap_env_site::enter(ctx, expr); + let value = lower_expr_inner(ctx, expr); + ctx.snap_lowering = snap_frame; + let value = value?; crate::expr::layout_read::run_exit(ctx, &run); if let Some(desc) = crate::layout_desc::array_families::birth_layout(expr) { Ok(ctx.block().call( @@ -50,10 +55,6 @@ fn lower_expr_inner(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // one. Handlers that care receive it as an argument, because they consult // it after lowering their operands — by which point the field is gone. let value_discarded = std::mem::take(&mut ctx.discard_this_expr); - // SNAP census (instrumented compiles only): an env read whose value this - // expression stores is a store site (`expr/snap_env_site.rs`). - super::snap_env_site::note_store_parent(ctx, expr); - super::snap_argv_site::note_parent(expr); // #9856: under `--debug-symbols`, record this expression's source line // for the DWARF line table (`crate::debug_info`). No-op otherwise. crate::debug_info::mark_expr_location(ctx, expr); diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 2cbaa730f99..fb4b98a8ca0 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -524,6 +524,11 @@ pub(crate) struct FnCtx<'a> { /// reading the field, because they consult it *after* lowering their /// operands, by which point the field has been taken again. pub discard_this_expr: bool, + /// SNAP census frame: the two innermost expressions `lower_expr` is + /// lowering, saved and restored by each lowering. A `process.env` / + /// `process.argv` read takes its census kind from its own parent + /// (`snap_env_site`, `snap_argv_site`). + pub snap_lowering: snap_env_site::Lowering, /// A chunked literal birth's own arguments are being lowered /// (`literal_chunks`): a chunked argument is the birth's `undefined` /// fill, and anywhere else it is an invariant violation. diff --git a/crates/perry-codegen/src/expr/snap_argv_site.rs b/crates/perry-codegen/src/expr/snap_argv_site.rs index ba5fa8feab6..4e34f78e2fd 100644 --- a/crates/perry-codegen/src/expr/snap_argv_site.rs +++ b/crates/perry-codegen/src/expr/snap_argv_site.rs @@ -6,9 +6,9 @@ //! an element read (kind 2, the index is the call's second argument; //! `process.execPath` is a metadata property, not an argv read), and every other use lets the //! whole array escape (kind 0). Codegen calls `js_snap_argv_site(kind, index)` -//! right before `js_process_argv_value`; no table is emitted. - -use std::cell::RefCell; +//! right before `js_process_argv_value`; no table is emitted. The read takes +//! its grain from its own parent, the expression whose lowering is lowering +//! it (the census lowering frame, `snap_env_site::Lowering`). use perry_hir::Expr; @@ -16,36 +16,19 @@ use crate::types::{I32, I64}; use super::FnCtx; -thread_local! { - /// `process.argv` expressions (by address) whose parent reads one grain. - static SITES: RefCell> = const { RefCell::new(Vec::new()) }; -} - -/// `expr` is about to be lowered: note the grain of a direct `process.argv` child. -pub(crate) fn note_parent(expr: &Expr) { - if !super::snap_env_site::census_compile() { - return; - } - let site = match expr { +/// The grain `parent` reads of its direct `process.argv` child `argv`. +fn grain(parent: &Expr, argv: &Expr) -> Option<(i32, i64)> { + match parent { Expr::PropertyGet { object, property, .. - } if matches!(**object, Expr::ProcessArgv) && property == "length" => { - Some((&**object, 1, 0)) - } - Expr::IndexGet { object, index, .. } if matches!(**object, Expr::ProcessArgv) => { - match **index { - Expr::Number(n) if n >= 0.0 && n.fract() == 0.0 && n < 1e9 => { - Some((&**object, 2, n as i64)) - } - // Literal indices that fit an integer lower as Expr::Integer. - Expr::Integer(n) if n >= 0 => Some((&**object, 2, n)), - _ => None, - } - } + } if std::ptr::eq(&**object, argv) && property == "length" => Some((1, 0)), + Expr::IndexGet { object, index, .. } if std::ptr::eq(&**object, argv) => match **index { + Expr::Number(n) if n >= 0.0 && n.fract() == 0.0 && n < 1e9 => Some((2, n as i64)), + // Literal indices that fit an integer lower as Expr::Integer. + Expr::Integer(n) if n >= 0 => Some((2, n)), + _ => None, + }, _ => None, - }; - if let Some((child, kind, index)) = site { - SITES.with(|s| s.borrow_mut().push((child as *const Expr as usize, kind, index))); } } @@ -54,17 +37,9 @@ pub(crate) fn emit(ctx: &mut FnCtx<'_>, expr: &Expr) { if !super::snap_env_site::census_compile() { return; } - let addr = expr as *const Expr as usize; - let (kind, index) = SITES.with(|s| { - let mut s = s.borrow_mut(); - match s.iter().position(|&(a, _, _)| a == addr) { - Some(i) => { - let (_, k, x) = s.swap_remove(i); - (k, x) - } - None => (0, 0), - } - }); + let (kind, index) = super::snap_env_site::parent_of(ctx, expr) + .and_then(|parent| grain(parent, expr)) + .unwrap_or((0, 0)); ctx.block().call_void( "js_snap_argv_site", &[(I32, &kind.to_string()), (I64, &index.to_string())], diff --git a/crates/perry-codegen/src/expr/snap_env_site.rs b/crates/perry-codegen/src/expr/snap_env_site.rs index e00a3998d92..33dc168d063 100644 --- a/crates/perry-codegen/src/expr/snap_env_site.rs +++ b/crates/perry-codegen/src/expr/snap_env_site.rs @@ -10,8 +10,13 @@ //! calls `js_snap_env_site(kind)` right before the read's runtime call; the //! runtime keeps the kind for that frame's next `js_getenv`. No table is //! emitted: the kind is the call's argument. - -use std::cell::RefCell; +//! +//! The read classifies itself against its own parent: [`Lowering`] holds the +//! two innermost expressions `lower_expr` is lowering, installed for the +//! extent of each lowering and restored when it returns, so the parent seen +//! by a read is the expression whose lowering is lowering it, and nothing +//! outlives that lowering. `process.argv` reads use the same frame +//! (`snap_argv_site`). use perry_hir::Expr; @@ -23,63 +28,119 @@ use super::FnCtx; const OBSERVE: i32 = 0; const STORE: i32 = 1; -thread_local! { - /// Env-read expressions (by address) whose parent stores their value, - /// noted when the parent is lowered and taken when the read is. - static STORE_SITES: RefCell> = const { RefCell::new(Vec::new()) }; +/// One node of the census lowering frame. +#[derive(Clone, Copy, Default)] +enum Node { + #[default] + None, + /// An expression whose lowering is in flight. + Expr(*const Expr), + /// A module-global `let`/`const` binding whose initializer is this + /// expression is being lowered. + StoredInit(*const Expr), +} + +/// The census lowering frame: `[parent, current]` of the innermost +/// `lower_expr`. A field of the function's [`FnCtx`], saved and restored by +/// every lowering ([`enter`]), never a list. +#[derive(Clone, Copy, Default)] +pub(crate) struct Lowering { + parent: Node, + current: Node, +} + +impl Lowering { + /// The parent of `expr` when `expr` is the expression being lowered. + fn parent_of(&self, expr: &Expr) -> Option<&Expr> { + let Node::Expr(current) = self.current else { + return None; + }; + if !std::ptr::eq(current, expr) { + return None; + } + match self.parent { + // SAFETY: `parent` was installed as `current` by the enclosing + // `lower_expr(ctx, parent)` call, whose `&Expr` borrow is live + // for the whole lowering of its operands; that call restores the + // frame before it returns, so the pointer is never read after + // the borrow ends. + Node::Expr(p) => Some(unsafe { &*p }), + _ => None, + } + } + + /// `expr`, being lowered, is the initializer of a module-global binding. + fn is_stored_init(&self, expr: &Expr) -> bool { + matches!(self.current, Node::Expr(c) if std::ptr::eq(c, expr)) + && matches!(self.parent, Node::StoredInit(i) if std::ptr::eq(i, expr)) + } } pub(crate) fn census_compile() -> bool { std::env::var_os("PERRY_SNAP_CENSUS").is_some_and(|v| !v.is_empty()) } -fn is_env_read(e: &Expr) -> bool { - matches!(e, Expr::EnvGet(_) | Expr::EnvGetDynamic(_)) +/// `lower_expr(ctx, expr)` starts: `expr` becomes the current node. Returns +/// the frame to restore when it ends. +#[inline] +pub(crate) fn enter(ctx: &mut FnCtx<'_>, expr: &Expr) -> Lowering { + let saved = ctx.snap_lowering; + ctx.snap_lowering = Lowering { + parent: saved.current, + current: Node::Expr(expr), + }; + saved } -fn note<'e>(children: impl Iterator + Clone) { - if children.clone().any(is_env_read) && census_compile() { - STORE_SITES.with(|s| { - let mut s = s.borrow_mut(); - for c in children.filter(|c| is_env_read(c)) { - s.push(c as *const Expr as usize); - } - }); +/// `f` lowers one operand of `parent` on a path that lowers `parent`'s +/// operands directly instead of through `lower_expr(ctx, parent)` (scalar +/// replacement of an array or object literal binding): `parent` is the +/// current node for the extent of `f`, as `lower_expr(ctx, parent)` would +/// install it, and the frame is restored when `f` returns. +pub(crate) fn operand_of<'a, R>( + ctx: &mut FnCtx<'a>, + parent: &Expr, + f: impl FnOnce(&mut FnCtx<'a>) -> R, +) -> R { + let saved = enter(ctx, parent); + let result = f(ctx); + ctx.snap_lowering = saved; + result +} + +/// A `let`/`const` binding `id` with initializer `init` is about to be +/// lowered. Returns the frame to restore once it is. +pub(crate) fn enter_let(ctx: &mut FnCtx<'_>, id: u32, init: Option<&Expr>) -> Lowering { + let saved = ctx.snap_lowering; + if let Some(init) = init.filter(|_| ctx.module_globals.contains_key(&id)) { + ctx.snap_lowering = Lowering { + parent: Node::None, + current: Node::StoredInit(init), + }; } + saved } -/// `expr` is about to be lowered: when it stores the value of a direct -/// env-read child, that child is a store site. -pub(crate) fn note_store_parent(ctx: &FnCtx<'_>, expr: &Expr) { - match expr { - Expr::GlobalSet(_, v) => note(std::iter::once(&**v)), - Expr::LocalSet(id, v) if ctx.module_globals.contains_key(id) => note(std::iter::once(&**v)), - Expr::PropertySet { value, .. } | Expr::IndexSet { value, .. } => { - note(std::iter::once(&**value)) - } - Expr::Object(fields) => note(fields.iter().map(|(_, v)| v)), +/// `parent` stores the value of its direct child `child`. +fn stores(ctx: &FnCtx<'_>, parent: &Expr, child: &Expr) -> bool { + let is = |e: &Expr| std::ptr::eq(e, child); + match parent { + Expr::GlobalSet(_, v) => is(&**v), + Expr::LocalSet(id, v) => ctx.module_globals.contains_key(id) && is(&**v), + Expr::PropertySet { value, .. } | Expr::IndexSet { value, .. } => is(&**value), + Expr::Object(fields) => fields.iter().any(|(_, v)| is(v)), // A plain object literal lowers to `new __AnonShape_*(values...)`: // the literal shape has no constructor, its values are its fields. Expr::New { class_name, args, .. - } if ctx - .classes - .get(class_name) - .is_some_and(|c| c.is_literal_shape()) => - { - note(args.iter()) - } - Expr::Array(elems, _) => note(elems.iter()), - _ => {} - } -} - -/// A `let`/`const` binding `id` with initializer `init` is being lowered. -pub(crate) fn note_let(ctx: &FnCtx<'_>, id: u32, init: Option<&Expr>) { - if let Some(init) = init { - if ctx.module_globals.contains_key(&id) { - note(std::iter::once(init)); + } => { + ctx.classes + .get(class_name) + .is_some_and(|c| c.is_literal_shape()) + && args.iter().any(|a| is(a)) } + Expr::Array(elems, _) => elems.iter().any(|e| is(e)), + _ => false, } } @@ -88,18 +149,21 @@ pub(crate) fn emit(ctx: &mut FnCtx<'_>, expr: &Expr) { if !census_compile() { return; } - let addr = expr as *const Expr as usize; - let store = STORE_SITES.with(|s| { - let mut s = s.borrow_mut(); - match s.iter().position(|&a| a == addr) { - Some(i) => { - s.swap_remove(i); - true - } - None => false, - } - }); + let frame = ctx.snap_lowering; + let store = frame.is_stored_init(expr) + || frame + .parent_of(expr) + .is_some_and(|parent| stores(ctx, parent, expr)); let kind = if store { STORE } else { OBSERVE }; ctx.block() .call_void("js_snap_env_site", &[(I32, &kind.to_string())]); } + +/// The parent of the `process.argv` read `expr` being lowered (`snap_argv_site`). +pub(crate) fn parent_of<'f>(ctx: &'f FnCtx<'_>, expr: &Expr) -> Option<&'f Expr> { + ctx.snap_lowering.parent_of(expr) +} + +#[cfg(test)] +#[path = "snap_env_site_tests.rs"] +mod tests; diff --git a/crates/perry-codegen/src/expr/snap_env_site_tests.rs b/crates/perry-codegen/src/expr/snap_env_site_tests.rs new file mode 100644 index 00000000000..776138939ac --- /dev/null +++ b/crates/perry-codegen/src/expr/snap_env_site_tests.rs @@ -0,0 +1,151 @@ +//! SNAPK2d: the `process.env` / `process.argv` census kinds come from the +//! read's own enclosing lowering. 71710bc707 kept address-keyed lists of +//! store / grain sites, noted when the parent was lowered and taken when the +//! read was. A parent lowered without its read (a materialised receiver is +//! re-read, not re-lowered) left its entry behind, and a later module's read +//! at the freed address took the earlier module's kind. + +use perry_hir::types::Type; +use perry_hir::{BinaryOp, Expr, Module, Stmt}; + +fn ir_of(m: &Module) -> String { + String::from_utf8( + crate::compile_module( + m, + crate::CompileOptions { + emit_ir_only: true, + is_entry_module: true, + target: Some("x86_64-unknown-linux-gnu".into()), + ..Default::default() + }, + ) + .unwrap(), + ) + .unwrap() +} + +fn local(id: u32, init: Expr) -> Stmt { + Stmt::Let { + id, + name: format!("k2d_{id}"), + ty: Type::Any, + mutable: true, + init: Some(init), + } +} + +fn env() -> Expr { + Expr::EnvGet("K2D".into()) +} + +fn argv_length() -> Expr { + Expr::PropertyGet { + object: Box::new(Expr::ProcessArgv), + property: "length".into(), + byte_offset: 0, + byte_end: 0, + } +} + +fn argv_at(i: i64) -> Expr { + Expr::IndexGet { + object: Box::new(Expr::ProcessArgv), + index: Box::new(Expr::Integer(i)), + byte_offset: 0, + byte_end: 0, + element_offset: 0, + } +} + +/// `receiver.at(0)`: a folded builtin whose receiver is materialised once and +/// re-read by both arms of its own-override guard. +fn at0(receiver: Expr) -> Expr { + Expr::ArrayAt { + array: Box::new(receiver), + index: Box::new(Expr::Integer(0)), + } +} + +fn concat(e: Expr) -> Expr { + Expr::Binary { + op: BinaryOp::Add, + left: Box::new(e), + right: Box::new(Expr::String(String::new())), + } +} + +/// `receiver.has(0)` as a folded `Map` method: the same materialised receiver. +fn has0(receiver: Expr) -> Expr { + Expr::MapHas { + map: Box::new(receiver), + key: Box::new(Expr::Integer(0)), + } +} + +/// `(kind, index)` of every census call of `hook` in `ir`, in order. +fn kinds(ir: &str, hook: &str) -> Vec { + let call = format!("call void @{hook}("); + ir.lines() + .filter_map(|l| l.split_once(&call).map(|(_, a)| a.trim_end().to_owned())) + .collect() +} + +/// Module A: store / grain parents that are materialised receivers. Module B, +/// built after A is dropped so its nodes reuse A's freed addresses: only +/// observe env reads and whole-array argv reads. +#[test] +fn env_argv_kinds_never_cross_reads() { + std::env::set_var("PERRY_SNAP_CENSUS", "1"); + for round in 0..8 { + let mut a = Module::new("k2d_a"); + for i in 0..16u32 { + a.init + .push(local(6 * i, at0(Expr::Array(vec![env()], None)))); + a.init.push(local(6 * i + 1, at0(argv_length()))); + a.init.push(local(6 * i + 2, at0(argv_at(1)))); + a.init + .push(local(6 * i + 3, has0(Expr::Array(vec![env()], None)))); + a.init.push(local(6 * i + 4, has0(argv_length()))); + a.init.push(local(6 * i + 5, has0(argv_at(1)))); + } + let ir_a = ir_of(&a); + drop(a); + assert!(!kinds(&ir_a, "js_snap_env_site").is_empty(), "{ir_a}"); + let mut b = Module::new("k2d_b"); + for i in 0..96u32 { + b.init.push(local(2 * i, concat(env()))); + b.init.push(local(2 * i + 1, concat(Expr::ProcessArgv))); + } + let ir_b = ir_of(&b); + let env_b = kinds(&ir_b, "js_snap_env_site"); + let argv_b = kinds(&ir_b, "js_snap_argv_site"); + assert_eq!(env_b.len(), 96, "round {round}: one hook per env read"); + assert_eq!(argv_b.len(), 96, "round {round}: one hook per argv read"); + let stray_env = env_b.iter().filter(|k| *k != "i32 0)").count(); + let stray_argv = argv_b.iter().filter(|k| *k != "i32 0, i64 0)").count(); + assert_eq!( + (stray_env, stray_argv), + (0, 0), + "round {round}: B has only observe env reads and escaping argv reads" + ); + } + // The kinds still follow the read's own parent. + let mut c = Module::new("k2d_c"); + c.init + .push(local(0, Expr::Array(vec![env(), concat(env())], None))); + c.init.push(local(1, argv_length())); + c.init.push(local(2, argv_at(2))); + c.init.push(local(3, concat(Expr::ProcessArgv))); + let ir_c = ir_of(&c); + std::env::remove_var("PERRY_SNAP_CENSUS"); + assert_eq!( + kinds(&ir_c, "js_snap_env_site"), + ["i32 1)", "i32 0)"], + "array element stores, operand observes: {ir_c}" + ); + assert_eq!( + kinds(&ir_c, "js_snap_argv_site"), + ["i32 1, i64 0)", "i32 2, i64 2)", "i32 0, i64 0)"], + "length, element 2, escape: {ir_c}" + ); +} diff --git a/crates/perry-codegen/src/stmt/let_scalar_new.rs b/crates/perry-codegen/src/stmt/let_scalar_new.rs index e2674dc0db0..48483e0723c 100644 --- a/crates/perry-codegen/src/stmt/let_scalar_new.rs +++ b/crates/perry-codegen/src/stmt/let_scalar_new.rs @@ -28,9 +28,11 @@ pub(super) fn try_lower_scalar_replaced_new( // no fields populated. The call must go through the standard // heap-allocation path so `lower_new` emits the cross-module // `___constructor` call. - if let Some(perry_hir::Expr::New { - class_name, args, .. - }) = init + if let Some( + literal @ perry_hir::Expr::New { + class_name, args, .. + }, + ) = init { let is_imported = ctx.imported_class_ctors.contains_key(class_name); if ctx.non_escaping_news.contains_key(&id) && !is_imported { @@ -103,10 +105,18 @@ pub(super) fn try_lower_scalar_replaced_new( .and_then(|fields| fields.get(fname)) .cloned() }); - if slot.is_none() && lower_unused_expr(ctx, arg)? { + // SNAP census: the argument's parent is the literal. + let unused = slot.is_none() + && crate::expr::snap_env_site::operand_of(ctx, literal, |ctx| { + lower_unused_expr(ctx, arg) + })?; + if unused { continue; } - let arg_val = lower_expr(ctx, arg)?; + let arg_val = + crate::expr::snap_env_site::operand_of(ctx, literal, |ctx| { + lower_expr(ctx, arg) + })?; if let Some(slot) = slot { ctx.block().store(DOUBLE, &arg_val, &slot); // #6968: anonymous-shape scalar replacement stores diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index 81195908578..aee5fb04868 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -775,7 +775,7 @@ pub(crate) fn lower_let( // load directly from the corresponding slot. See the // `collect_non_escaping_arrays` pass in collectors.rs for the // escape criteria. - if let Some(perry_hir::Expr::Array(elements, _)) = init { + if let Some(literal @ perry_hir::Expr::Array(elements, _)) = init { if ctx.non_escaping_arrays.contains_key(&id) { let n = elements.len(); let mut slots: Vec = Vec::with_capacity(n); @@ -802,10 +802,17 @@ pub(crate) fn lower_let( .unwrap_or_default(); for (i, elem) in elements.iter().enumerate() { let index_is_observed = used_indices.contains(&(i as u32)); - if !index_is_observed && lower_unused_expr(ctx, elem)? { + // SNAP census: the element's parent is the literal. + let unused = !index_is_observed + && crate::expr::snap_env_site::operand_of(ctx, literal, |ctx| { + lower_unused_expr(ctx, elem) + })?; + if unused { continue; } - let v = lower_expr(ctx, elem)?; + let v = crate::expr::snap_env_site::operand_of(ctx, literal, |ctx| { + lower_expr(ctx, elem) + })?; ctx.block().store(DOUBLE, &v, &slots[i]); // #6968: same rooting hole as the object-literal fields — // the element alloca is the only reference to a heap value @@ -867,7 +874,7 @@ pub(crate) fn lower_let( // unique field; PropertyGet/Set already resolve through // `ctx.scalar_replaced`, so no additional read path is needed. // See `collect_non_escaping_object_literals` in collectors.rs. - if let Some(perry_hir::Expr::Object(props)) = init { + if let Some(literal @ perry_hir::Expr::Object(props)) = init { if let Some(field_order) = ctx.non_escaping_object_literals.get(&id).cloned() { let existing = ctx.scalar_replaced.get(&id).cloned(); let mut field_slots: std::collections::HashMap = @@ -887,10 +894,17 @@ pub(crate) fn lower_let( .cloned() .unwrap_or_default(); for (key, value_expr) in props { - if !used_fields.contains(key) && lower_unused_expr(ctx, value_expr)? { + // SNAP census: the field value's parent is the literal. + let unused = !used_fields.contains(key) + && crate::expr::snap_env_site::operand_of(ctx, literal, |ctx| { + lower_unused_expr(ctx, value_expr) + })?; + if unused { continue; } - let v = lower_expr(ctx, value_expr)?; + let v = crate::expr::snap_env_site::operand_of(ctx, literal, |ctx| { + lower_expr(ctx, value_expr) + })?; if let Some(slot) = field_slots.get(key).cloned() { ctx.block().store(DOUBLE, &v, &slot); // #6968: the field alloca is this heap value's only diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index b570040aa81..aa2ba7c7591 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -640,14 +640,15 @@ fn lower_stmt_inner(ctx: &mut FnCtx<'_>, stmt: &Stmt) -> Result<()> { { ctx.typed_element_locals.insert(*id); } - crate::expr::snap_env_site::note_let(ctx, *id, init.as_ref()); // A binding a later loop pushes into births its `[]` with the // larger append reserve (`collectors::loop_pushed_arrays`). The // flag is taken by that birth; clear it if another path bound // the value without one. ctx.pushed_array_birth = ctx.loop_pushed_arrays.reserve.contains(id) && matches!(init, Some(perry_hir::Expr::Array(elems, _)) if elems.is_empty()); + let snap_frame = crate::expr::snap_env_site::enter_let(ctx, *id, init.as_ref()); let lowered = lower_let(ctx, *id, name, init.as_ref(), ty, *mutable); + ctx.snap_lowering = snap_frame; ctx.pushed_array_birth = false; lowered?; // Object model step 1: a family prototype's birth. From 4aa757b17c85bfc50b0949d757b92eb435232e8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 10 Nov 2026 13:39:40 +0000 Subject: [PATCH 19/22] snap census: the walker lock opens an unwind funnel; a native read inside it does not walk the stack A census native event (clock_gettime from mimalloc's arena free, reached from decode_fde_row inside find_handler) captured its stack through the same eh_walker, whose lock the thread already held: the Supabase census run hung at the ws buffer-util throw (init 272). locked_walker returns a WalkerLock that holds the census Unwind funnel in the instrumented cohort; native_event skips the capture inside it and the row carries funnel=unwind. Production builds are unchanged (the funnel field is instrumented-only). --- crates/perry-runtime/src/eh_walker.rs | 39 +++++++++++++++- crates/perry-runtime/src/snap_census.rs | 7 +++ .../src/snap_census/input_record.rs | 44 ++++++++++++++++--- .../perry-runtime/src/snap_census/runner.rs | 5 +++ 4 files changed, 88 insertions(+), 7 deletions(-) diff --git a/crates/perry-runtime/src/eh_walker.rs b/crates/perry-runtime/src/eh_walker.rs index 3b4ca66a4e9..52c09e5f285 100644 --- a/crates/perry-runtime/src/eh_walker.rs +++ b/crates/perry-runtime/src/eh_walker.rs @@ -807,10 +807,45 @@ fn walker() -> Option<&'static Mutex> { /// unloaded since the index was built, rebuild it (and drop every cached /// site, which referred to the old images) before any frame is read. A /// range of an unloaded object is never walked. -fn locked_walker() -> Option> { +fn locked_walker() -> Option { let mut guard = walker()?.lock().ok()?; guard.refresh(); - Some(guard) + Some(WalkerLock { + guard, + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + _funnel: crate::snap_census::funnel_enter(crate::snap_census::Funnel::Unwind), + }) +} + +/// The walker held by this thread. In the instrumented cohort it also holds +/// the census `Unwind` funnel, so a native read the walker itself causes (an +/// allocation inside the walk reading the clock) is classed as the walker's +/// and never walks the stack again under the lock. Fields drop in order: the +/// lock is released before the funnel closes. +struct WalkerLock { + guard: std::sync::MutexGuard<'static, Walker>, + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + _funnel: crate::snap_census::FunnelScope, +} + +impl std::ops::Deref for WalkerLock { + type Target = Walker; + fn deref(&self) -> &Walker { + &self.guard + } +} + +impl std::ops::DerefMut for WalkerLock { + fn deref_mut(&mut self) -> &mut Walker { + &mut self.guard + } +} + +/// Run `f` while this thread holds the walker (census unit tests). +#[cfg(all(test, perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +pub(crate) fn with_walker_locked(f: impl FnOnce() -> R) -> R { + let _held = locked_walker().expect("walker"); + f() } impl Walker { diff --git a/crates/perry-runtime/src/snap_census.rs b/crates/perry-runtime/src/snap_census.rs index 16b958b7286..a1ed92d884e 100644 --- a/crates/perry-runtime/src/snap_census.rs +++ b/crates/perry-runtime/src/snap_census.rs @@ -657,8 +657,15 @@ pub(crate) enum Funnel { Gc = 6, ExecArgv = 7, StyleBit = 8, + /// The exception/stack walker holds its lock (`eh_walker::locked_walker`): + /// a native read here is the walker's own (its allocator's clock), and a + /// stack capture would take the lock this thread already holds. + Unwind = 9, } +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +pub(crate) use runner::FunnelScope; + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] #[inline(always)] pub(crate) fn funnel_enter(f: Funnel) -> runner::FunnelScope { diff --git a/crates/perry-runtime/src/snap_census/input_record.rs b/crates/perry-runtime/src/snap_census/input_record.rs index e2793b56514..df7cc289784 100644 --- a/crates/perry-runtime/src/snap_census/input_record.rs +++ b/crates/perry-runtime/src/snap_census/input_record.rs @@ -282,11 +282,7 @@ pub(super) fn native_event(st: &mut State, op: &str, value: std::fmt::Arguments< if super::funnel() != 0 && !op.starts_with("getenv") { let _ = write!(text, ";funnel={}", super::funnel_name(super::funnel())); } - let mut pcs = [0usize; crate::error::MAX_CAPTURED_FRAMES]; - let n = crate::error::capture_ips(&mut pcs); - for (to, from) in six.iter_mut().zip(pcs[..n].iter().skip(1)) { - *to = *from; - } + row_pcs(&mut six); } let mut row = SignalRow { bytes: [0; 131072], @@ -537,9 +533,47 @@ impl Value for *mut crate::ArrayHeader { } } +/// The six return addresses above the native read, or none inside the +/// walker funnel: this thread holds the walker's lock there, and a capture +/// (which walks through the same walker) would wait on it forever (SNAPK3: +/// js_throw -> find_handler -> decode_fde_row -> mimalloc arena free -> +/// clock_gettime -> here). The row keeps `funnel=unwind`, its class. +fn row_pcs(six: &mut [usize; 6]) { + if super::funnel() == crate::snap_census::Funnel::Unwind as u8 { + return; + } + let mut pcs = [0usize; crate::error::MAX_CAPTURED_FRAMES]; + let n = crate::error::capture_ips(&mut pcs); + for (to, from) in six.iter_mut().zip(pcs[..n].iter().skip(1)) { + *to = *from; + } +} + #[cfg(test)] mod tests { use super::*; + + /// A native read inside the walker funnel returns without walking. + /// Red (capture regardless of the funnel): the capture waits on the + /// walker lock this thread holds, and the receiver times out. + #[test] + fn row_pcs_inside_walker_lock_returns() { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + crate::eh_walker::with_walker_locked(|| { + let unwind = crate::snap_census::Funnel::Unwind as u8; + let prev = super::super::funnel_set_for_test(unwind); + let mut six = [0usize; 6]; + row_pcs(&mut six); + super::super::funnel_set_for_test(prev); + let _ = tx.send(six); + }); + }); + let six = rx + .recv_timeout(std::time::Duration::from_secs(20)) + .expect("row_pcs re-entered the walker lock"); + assert_eq!(six, [0; 6]); + } #[test] fn exact_scalar_and_absence_values() { assert_ne!(0.0f64.input_value(), (-0.0f64).input_value()); diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 85039a6e2fc..4145376bcaa 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -5083,6 +5083,10 @@ static ARGV_SITE: std::cell::Cell<(i32, i64)> = std::cell::Cell::new((-1, 0)); pub(super) fn funnel() -> u8 { FUNNEL.get() } +#[cfg(test)] +pub(super) fn funnel_set_for_test(id: u8) -> u8 { + FUNNEL.replace(id) +} pub(super) fn funnel_name(id: u8) -> &'static str { match id { 1 => "exec_path", @@ -5093,6 +5097,7 @@ pub(super) fn funnel_name(id: u8) -> &'static str { 6 => "gc", 7 => "exec_argv", 8 => "style_bit", + 9 => "unwind", _ => "none", } } From a3e210e17c2009503d18eeb3bc07b16245ad2da4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 10 Nov 2026 20:06:38 +0000 Subject: [PATCH 20/22] snap census: a job_queue row names each pending job (kind, callback code, queueing init) The K3 Supabase census stops at init 1033 on job_queue (pending=1 at every later boundary), but the row only counted the jobs. Each row now lists the queued jobs in queue order as kind:owner:code: the Task variant, the init that queued the job (the snapshot's snap_init, set at enqueue) and the callback's JsFunctionInfo code address (0 for a reaction without a callback). Read from the task itself; instrumented runtime only. --- crates/perry-runtime/src/promise/mod.rs | 63 +++++++++++++++++++ .../src/promise/snap_pending_job_tests.rs | 60 ++++++++++++++++++ .../perry-runtime/src/snap_census/runner.rs | 25 +++++++- scripts/snap_inputs/checkk2b.py | 7 +++ 4 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 crates/perry-runtime/src/promise/snap_pending_job_tests.rs diff --git a/crates/perry-runtime/src/promise/mod.rs b/crates/perry-runtime/src/promise/mod.rs index 26094340c66..a6d8b0a12af 100644 --- a/crates/perry-runtime/src/promise/mod.rs +++ b/crates/perry-runtime/src/promise/mod.rs @@ -41,6 +41,8 @@ mod rejection_gc_tests; mod rejection_loop_tests; #[cfg(test)] mod resolving_function_kind_tests; +#[cfg(all(test, perry_gc_instruments))] +mod snap_pending_job_tests; pub mod scanners; mod settled_step; pub use settled_step::js_async_step_resume_now; @@ -736,6 +738,67 @@ pub(crate) fn snap_pending_jobs() -> Option { TASK_QUEUE.with(|q| q.try_borrow().ok().map(|q| q.len())) } +/// SNAPK3c census: one queued job as the census names it, read from the job +/// itself: its kind, its callback's code address (`JsFunctionInfo::code`; 0 +/// for a reaction with no callback, which only settles its `next`) and the +/// init that queued it (`AsyncContextSnapshot::snap_init`, set at enqueue). +#[cfg(perry_gc_instruments)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct SnapPendingJob { + pub kind: &'static str, + pub code: usize, + pub owner: u32, +} + +/// SNAPK3c census: the queued jobs in queue order (None while the queue is +/// being changed). +#[cfg(perry_gc_instruments)] +pub(crate) fn snap_pending_job_list() -> Option> { + TASK_QUEUE.with(|q| { + q.try_borrow() + .ok() + .map(|q| q.iter().map(snap_pending_job).collect()) + }) +} + +#[cfg(perry_gc_instruments)] +fn snap_pending_job(task: &Task) -> SnapPendingJob { + fn code(callback: ClosurePtr) -> usize { + if callback.is_null() { + return 0; + } + let info = unsafe { (*callback).info }; + if info.is_null() { + 0 + } else { + unsafe { (*info).code as usize } + } + } + let (kind, callback, owner) = match task { + Task::Promise(promise, _, is_fulfilled, context) => { + let callback = if promise.is_null() { + ptr::null() + } else if *is_fulfilled { + unsafe { (**promise).on_fulfilled } + } else { + unsafe { (**promise).on_rejected } + }; + ("promise", callback, context.snap_init) + } + Task::PromiseAll(_, _, _, context) => ("all", ptr::null(), context.snap_init), + Task::Inline(callback, _, _, _, context) => ("inline", *callback, context.snap_init), + Task::AsyncStep(callback, _, _, _, context, ..) => ("step", *callback, context.snap_init), + Task::Microtask { + callback, context, .. + } => ("microtask", *callback, context.snap_init), + }; + SnapPendingJob { + kind, + code: code(callback), + owner, + } +} + pub(crate) fn enqueue_queue_microtask(callback: i64) { let scope = crate::gc::RuntimeHandleScope::new(); let callback_handle = diff --git a/crates/perry-runtime/src/promise/snap_pending_job_tests.rs b/crates/perry-runtime/src/promise/snap_pending_job_tests.rs new file mode 100644 index 00000000000..0059c29f038 --- /dev/null +++ b/crates/perry-runtime/src/promise/snap_pending_job_tests.rs @@ -0,0 +1,60 @@ +//! SNAPK3c: the census names each queued job by its kind, its callback's +//! code address and the init that queued it (`snap_pending_job_list`). +use super::*; + +extern "C" fn job_a( + _closure: *const crate::closure::ClosureHeader, + _this: crate::closure::JsThis, + _v: f64, +) -> f64 { + 1.0 +} + +extern "C" fn job_b( + _closure: *const crate::closure::ClosureHeader, + _this: crate::closure::JsThis, + _v: f64, +) -> f64 { + 2.0 +} + +#[test] +fn pending_jobs_name_kind_callback_and_queueing_init() { + let info_a = crate::fn_info!(job_a, 1); + let info_b = crate::fn_info!(job_b, 1); + let code_a = unsafe { (*info_a).code as usize }; + let code_b = unsafe { (*info_b).code as usize }; + assert_ne!(code_a, code_b); + let cb_a = crate::closure::js_closure_alloc(info_a, 0) as ClosurePtr; + let cb_b = crate::closure::js_closure_alloc(info_b, 0) as ClosurePtr; + let undefined = f64::from_bits(crate::value::TAG_UNDEFINED); + let mut first = capture_context(); + first.snap_init = 7; + let mut second = capture_context(); + second.snap_init = 11; + let mut third = capture_context(); + third.snap_init = 12; + TASK_QUEUE.with(|q| { + let mut q = q.borrow_mut(); + q.clear(); + q.push_back(Task::Inline(cb_a, undefined, ptr::null_mut(), true, first)); + q.push_back(Task::Microtask { + callback: cb_b, + context: second, + async_id: 0, + trigger_async_id: 0, + resource: undefined, + }); + q.push_back(Task::Inline(ptr::null(), undefined, ptr::null_mut(), true, third)); + }); + let jobs = snap_pending_job_list().expect("queue not borrowed"); + TASK_QUEUE.with(|q| q.borrow_mut().clear()); + assert_eq!( + jobs, + vec![ + SnapPendingJob { kind: "inline", code: code_a, owner: 7 }, + SnapPendingJob { kind: "microtask", code: code_b, owner: 11 }, + SnapPendingJob { kind: "inline", code: 0, owner: 12 }, + ] + ); +} diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 4145376bcaa..a9119c6fc2f 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -5371,6 +5371,26 @@ pub(super) fn compare_literal(op: i32, left: u64, right: u64, literal: i32) { ); } +/// SNAPK3c: the `jobs=` field of a `job_queue` row, one +/// `kind:owner:code` entry per queued job in queue order (`owner` = the init +/// that queued it, `code` = the callback's code address in hex, 0 for a +/// reaction without a callback). The first 64 jobs; `+N` counts the rest. +fn pending_jobs_field() -> String { + const SHOWN: usize = 64; + let Some(jobs) = crate::promise::snap_pending_job_list() else { + return "busy".to_owned(); + }; + let mut out: Vec = jobs + .iter() + .take(SHOWN) + .map(|j| format!("{}:{}:{:x}", j.kind, j.owner, j.code)) + .collect(); + if jobs.len() > SHOWN { + out.push(format!("+{}", jobs.len() - SHOWN)); + } + out.join(",") +} + /// SNAPK2b checks at a candidate cut, `init_enter` of the init just pushed /// (DESIGN27 §1.1, §1.2). Open frames: every open ancestor resumes at its /// flag test (R1: static dependency or eager entry import, before the @@ -5417,9 +5437,10 @@ fn k2b_enter_rows(st: &mut State) { st, "job_queue", format!( - "init={};pending={};policy=STOP", + "init={};pending={};policy=STOP;jobs={}", st.frames.last().map_or(0, |f| f.init), - n.map_or("busy".to_owned(), |n| n.to_string()) + n.map_or("busy".to_owned(), |n| n.to_string()), + pending_jobs_field() ), ); } diff --git a/scripts/snap_inputs/checkk2b.py b/scripts/snap_inputs/checkk2b.py index df3160c5d2e..c44a2637ed3 100644 --- a/scripts/snap_inputs/checkk2b.py +++ b/scripts/snap_inputs/checkk2b.py @@ -87,6 +87,13 @@ def entry_init(): jq = [kv(v) for v in vals("job_queue")] info = f"rows={jq}" need(len(jq) >= 1 and all(r["policy"] == "STOP" for r in jq), "no job_queue STOP row") + # SNAPK3c: each row names its jobs (kind:owner:code); f12's one job is the + # m.ts reaction, queued by an init before the row's, with a callback. + for r in jq: + js = [j.split(":") for j in r.get("jobs", "").split(",") if j and not j.startswith("+")] + need(r.get("pending") == str(len(js)), "job_queue jobs= does not list every pending job") + need(all(len(j) == 3 and int(j[1]) not in (0, int(r["init"])) and int(j[2], 16) != 0 for j in js), + "a job without its queueing init or callback") elif name in ("f13", "f13s"): rp = [kv(v) for v in vals("stack.runtime_pc")] m_init = inits("stack.runtime_pc") From 49656e89f606af2c7a05ba23539e230f3e317ce3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 11 Nov 2026 09:15:37 +0000 Subject: [PATCH 21/22] changelog: snap census fragments (env/argv sites, walker lock, pending jobs) --- changelog.d/00000-snap-census-env-argv-sites.md | 1 + changelog.d/00000-snap-census-pending-jobs.md | 1 + changelog.d/00000-snap-census-walker-lock.md | 1 + 3 files changed, 3 insertions(+) create mode 100644 changelog.d/00000-snap-census-env-argv-sites.md create mode 100644 changelog.d/00000-snap-census-pending-jobs.md create mode 100644 changelog.d/00000-snap-census-walker-lock.md diff --git a/changelog.d/00000-snap-census-env-argv-sites.md b/changelog.d/00000-snap-census-env-argv-sites.md new file mode 100644 index 00000000000..8556497c7a4 --- /dev/null +++ b/changelog.d/00000-snap-census-env-argv-sites.md @@ -0,0 +1 @@ +snap census: process.env/process.argv read sites classify themselves against their own parent expression, not address-keyed site lists, so a later module can no longer read a stale site kind; this also covers literal operands on the scalar-replacement path of let. diff --git a/changelog.d/00000-snap-census-pending-jobs.md b/changelog.d/00000-snap-census-pending-jobs.md new file mode 100644 index 00000000000..33520dc7ebd --- /dev/null +++ b/changelog.d/00000-snap-census-pending-jobs.md @@ -0,0 +1 @@ +snap census: a job_queue row names each pending job (task kind, queueing init and callback code), so the census says which job is still pending at every later boundary; instrumented runtime only. diff --git a/changelog.d/00000-snap-census-walker-lock.md b/changelog.d/00000-snap-census-walker-lock.md new file mode 100644 index 00000000000..ba034db138b --- /dev/null +++ b/changelog.d/00000-snap-census-walker-lock.md @@ -0,0 +1 @@ +snap census: the walker lock opens the census unwind funnel, so a native event raised while a throw holds the unwinder lock no longer walks the stack through the same lock; the instrumented Supabase census run no longer hangs at the ws buffer-util throw. From ab53399cf800371a7baf97253b543a39080be5f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 11 Nov 2026 13:00:57 +0100 Subject: [PATCH 22/22] changelog: number the PR fragments --- ...nsus-env-argv-sites.md => 14449-snap-census-env-argv-sites.md} | 0 ...p-census-pending-jobs.md => 14449-snap-census-pending-jobs.md} | 0 ...nap-census-walker-lock.md => 14449-snap-census-walker-lock.md} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{00000-snap-census-env-argv-sites.md => 14449-snap-census-env-argv-sites.md} (100%) rename changelog.d/{00000-snap-census-pending-jobs.md => 14449-snap-census-pending-jobs.md} (100%) rename changelog.d/{00000-snap-census-walker-lock.md => 14449-snap-census-walker-lock.md} (100%) diff --git a/changelog.d/00000-snap-census-env-argv-sites.md b/changelog.d/14449-snap-census-env-argv-sites.md similarity index 100% rename from changelog.d/00000-snap-census-env-argv-sites.md rename to changelog.d/14449-snap-census-env-argv-sites.md diff --git a/changelog.d/00000-snap-census-pending-jobs.md b/changelog.d/14449-snap-census-pending-jobs.md similarity index 100% rename from changelog.d/00000-snap-census-pending-jobs.md rename to changelog.d/14449-snap-census-pending-jobs.md diff --git a/changelog.d/00000-snap-census-walker-lock.md b/changelog.d/14449-snap-census-walker-lock.md similarity index 100% rename from changelog.d/00000-snap-census-walker-lock.md rename to changelog.d/14449-snap-census-walker-lock.md