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/changelog.d/14449-snap-census-env-argv-sites.md b/changelog.d/14449-snap-census-env-argv-sites.md new file mode 100644 index 00000000000..8556497c7a4 --- /dev/null +++ b/changelog.d/14449-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/14449-snap-census-pending-jobs.md b/changelog.d/14449-snap-census-pending-jobs.md new file mode 100644 index 00000000000..33520dc7ebd --- /dev/null +++ b/changelog.d/14449-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/14449-snap-census-walker-lock.md b/changelog.d/14449-snap-census-walker-lock.md new file mode 100644 index 00000000000..ba034db138b --- /dev/null +++ b/changelog.d/14449-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. diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index fdbc661e6db..eacb77c3351 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -1140,6 +1140,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 255874daadc..e98ee3d0b56 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -918,6 +918,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 10adb9c19cb..900d268b38a 100644 --- a/crates/perry-codegen/src/codegen/entry/module_init.rs +++ b/crates/perry-codegen/src/codegen/entry/module_init.rs @@ -318,6 +318,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 8b6b6a2d0b0..abcd0da8194 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -1411,6 +1411,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 2d9a3ecda9d..35f4bc115be 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -708,6 +708,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 5782be7ce9f..36d79cac397 100644 --- a/crates/perry-codegen/src/codegen/method_static.rs +++ b/crates/perry-codegen/src/codegen/method_static.rs @@ -283,6 +283,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/compare.rs b/crates/perry-codegen/src/expr/compare.rs index 5d32690332e..e461361b3bd 100644 --- a/crates/perry-codegen/src/expr/compare.rs +++ b/crates/perry-codegen/src/expr/compare.rs @@ -1382,6 +1382,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); + } // Value records' check mode (DESIGN-p4l4 L4). crate::expr::record_witness::compare(ctx, op, left, right, &l, &r); // BigInt comparison fast path: NaN-tagged BIGINT_TAG values diff --git a/crates/perry-codegen/src/expr/dispatch.rs b/crates/perry-codegen/src/expr/dispatch.rs index a7c62453b5f..91e9530cbdf 100644 --- a/crates/perry-codegen/src/expr/dispatch.rs +++ b/crates/perry-codegen/src/expr/dispatch.rs @@ -30,7 +30,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( @@ -51,9 +56,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); // #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 4b1292ead52..6926328bd43 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -106,6 +106,8 @@ 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_compare_site; pub(crate) mod rest_elems; pub(crate) mod rest_fields; pub(crate) mod snap_env_site; @@ -534,6 +536,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 new file mode 100644 index 00000000000..4e34f78e2fd --- /dev/null +++ b/crates/perry-codegen/src/expr/snap_argv_site.rs @@ -0,0 +1,47 @@ +//! 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. 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; + +use crate::types::{I32, I64}; + +use super::FnCtx; + +/// 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 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, + } +} + +/// 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 (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_compare_site.rs b/crates/perry-codegen/src/expr/snap_compare_site.rs new file mode 100644 index 00000000000..63b2a6aa808 --- /dev/null +++ b/crates/perry-codegen/src/expr/snap_compare_site.rs @@ -0,0 +1,60 @@ +//! 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. +//! +//! 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}; + +use crate::types::{DOUBLE, I32}; + +use super::FnCtx; + +/// 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, + CompareOp::LooseEq => 2, + CompareOp::LooseNe => 3, + _ => return, + }; + if !super::snap_env_site::census_compile() { + return; + } + 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..8cf1fd3ff2e --- /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("call void @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}" + ); +} 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/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index 12a4be6299a..1dcb2a34405 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -976,6 +976,8 @@ 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_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-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 b7bed97578f..8dd2933864c 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 cc06023b4c7..ea41469a1e3 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -675,14 +675,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. 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/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/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/eh_walker.rs b/crates/perry-runtime/src/eh_walker.rs index f5dcd6eb1b5..79272b091a9 100644 --- a/crates/perry-runtime/src/eh_walker.rs +++ b/crates/perry-runtime/src/eh_walker.rs @@ -812,10 +812,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/error_stack_frames.rs b/crates/perry-runtime/src/error_stack_frames.rs index 97e8a157180..4876e07675e 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; @@ -448,6 +448,29 @@ 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); + 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?}", + 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 +924,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 +977,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/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/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 dd2bc43d60c..31ebef18dda 100644 --- a/crates/perry-runtime/src/math.rs +++ b/crates/perry-runtime/src/math.rs @@ -436,6 +436,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/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 d5000c35b91..6f76bc9a652 100644 --- a/crates/perry-runtime/src/navigator.rs +++ b/crates/perry-runtime/src/navigator.rs @@ -80,6 +80,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() @@ -241,9 +255,23 @@ extern "C" fn get_hardware_concurrency( /// Node `getAvailableParallelism()`: the recommended parallelism (>= 1). fn available_parallelism() -> f64 { crate::effect!(host_read, parallelism); - std::thread::available_parallelism() - .map(|n| n.get() as f64) - .unwrap_or(1.0) + // The affinity mask and cpu cgroup reads are the Parallelism funnel; the + // value is a recorded host input (SNAP C2/C3). + 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) + } } /// `userAgent`: `"Node.js/"`, a constant. 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/object/shapes.rs b/crates/perry-runtime/src/object/shapes.rs index baeeb98e336..c9340feaf3f 100644 --- a/crates/perry-runtime/src/object/shapes.rs +++ b/crates/perry-runtime/src/object/shapes.rs @@ -2471,7 +2471,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 @@ -2745,6 +2749,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() } @@ -7424,3 +7430,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..9ea0082e19d --- /dev/null +++ b/crates/perry-runtime/src/object/shapes_snap22.rs @@ -0,0 +1,58 @@ +//! 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 _scope = crate::snap_census::record_scope(); + 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/os.rs b/crates/perry-runtime/src/os.rs index 4fc6fd2a165..ade12f6fb58 100644 --- a/crates/perry-runtime/src/os.rs +++ b/crates/perry-runtime/src/os.rs @@ -549,12 +549,26 @@ 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() } /// 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 +594,23 @@ 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"))] + { + 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) = + crate::object::native_namespace_prop_override_get("process", "argv") + { + return value; + } + } + crate::value::js_nanbox_pointer(js_process_argv() as i64) + }); + crate::snap_census::argv_read(value); + return value; + } + 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 +631,54 @@ 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::snap_host_value!(1); + 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 +738,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 +779,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 +871,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 +906,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.rs b/crates/perry-runtime/src/process.rs index 7db5eb49903..16752b65f46 100644 --- a/crates/perry-runtime/src/process.rs +++ b/crates/perry-runtime/src/process.rs @@ -424,6 +424,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()); @@ -470,6 +471,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)] @@ -869,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/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 5920ba892d8..598700e18f3 100644 --- a/crates/perry-runtime/src/process/env_misc.rs +++ b/crates/perry-runtime/src/process/env_misc.rs @@ -1487,6 +1487,10 @@ pub(crate) fn refresh_process_env(value: f64) -> f64 { .into_iter() .map(|(name, value)| (env_cache_name_for_set(&name), value)) .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); + } let names: std::collections::HashSet<_> = vars.iter().map(|(name, _)| name.as_str()).collect(); with_env_cache_mutation(|| { let keys = scope.root_raw_mut_ptr(crate::object::js_object_keys( diff --git a/crates/perry-runtime/src/promise/mod.rs b/crates/perry-runtime/src/promise/mod.rs index a3e6d38bd5b..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; @@ -729,6 +731,74 @@ 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())) +} + +/// 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.rs b/crates/perry-runtime/src/snap_census.rs index f17bf5e7808..a1ed92d884e 100644 --- a/crates/perry-runtime/src/snap_census.rs +++ b/crates/perry-runtime/src/snap_census.rs @@ -642,6 +642,152 @@ 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, + 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 { + 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] +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); + }; +} + +/// 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, +/// 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); + } +} + +/// 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], 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(); + 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()), + ); + runner::runtime_pcs(pcs, js_text); + } +} + /// 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 @@ -681,3 +827,59 @@ 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::inputs_on() || !runner::on_main_thread() { + return call(); + } + let before_errno = unsafe { *libc::__errno_location() }; + let token = { + let _scope = runner::input_record::Scope::enter(); + 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); + { + let _scope = runner::input_record::Scope::enter(); + 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::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::inputs_on() && runner::on_main_thread() { + let _scope = runner::input_record::Scope::enter(); + 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)*) => {}; } + +#[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 new file mode 100644 index 00000000000..df7cc289784 --- /dev/null +++ b/crates/perry-runtime/src/snap_census/input_record.rs @@ -0,0 +1,599 @@ +//! Ordered execution-prefix evidence, streamed through native storage. +use super::{read_ir, top_init, State}; +use std::fmt::Write as _; + +pub(super) struct Record { + pub seq: u64, + pub writes: Vec>, + pub watching: bool, + pub watch_enabled: bool, + pub call_depth: u32, + pub allocated_bytes: u64, + fd: i32, +} +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(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; + 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; + } + 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" + ) + { + // 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() { + 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, + -1 as libc::c_int, + seq as libc::off_t, + 0x5321 as libc::c_int, + ); + *libc::__errno_location() = saved; + } + seq +} +pub(super) fn watch(st: &State) -> bool { + let id = top_init(st) as usize; + 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) +} + +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) { + 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 { + 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; + 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 six = [0usize; 6]; + if super::c25() { + if super::funnel() != 0 && !op.starts_with("getenv") { + let _ = write!(text, ";funnel={}", super::funnel_name(super::funnel())); + } + row_pcs(&mut six); + } + 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}")?; + } + 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 { + 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) 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(()) + } +} +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() + } + 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") && fields.get(4) != Some(&"getenv.program") { + 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, + )); + } + Ok(rows) +} +/// SNAP25: env names read inside a program-input funnel (`getenv.program`): +/// guard members whatever their prefix. +pub(super) fn program_env_names(st: &State) -> 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 { + 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 }; + 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 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(','); + } + 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 + } +} + +/// 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()); + 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 b571c871608..d795bcd4445 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}; @@ -83,11 +85,62 @@ 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 } +// Native helper threads are visible in the syscall companion, but must not +// borrow the main thread's census buffers. +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) } +} + +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 @@ -430,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)] @@ -450,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). @@ -488,6 +546,7 @@ impl PageMap { } struct State { + inputs: input_record::Record, out: String, inits: Vec, frames: Vec, @@ -755,6 +814,44 @@ 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, + /// 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 + /// 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)>, + /// 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. @@ -827,6 +924,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), @@ -992,6 +1090,21 @@ 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"), + 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), + 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, @@ -1006,6 +1119,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. @@ -1021,6 +1135,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))); @@ -1059,8 +1174,28 @@ 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(); 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; + 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(); } @@ -1130,6 +1265,7 @@ fn blank_init(addr: usize, parent: u32, reach: Reach) -> Init { faults: 0, skipped: false, entry_module: false, + p_entry_hit: false, } } @@ -1226,7 +1362,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) }; } @@ -1236,6 +1374,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(); @@ -1266,6 +1405,32 @@ pub(super) fn init_enter(addr: usize) { } else { Reach::DynImport }; + // 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)) + .then(|| { + 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};argv={};members={}", + st.inits.len(), + if narrowed { "predicate" } else { "whole" }, + members.join(",") + ) + }); st.seq += 1; let id = st.inits.len() as u32; assert!((id as usize) < MAX_INITS, "snap census: too many inits"); @@ -1311,12 +1476,20 @@ 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}")); + if let Some(row) = reach_row { + input_record::event(st, "p_reach", row); + } + k2b_enter_rows(st); set_mode_for_top(st); } 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}")); let Some(f) = st.frames.pop() else { return; }; @@ -1340,6 +1513,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 { @@ -1347,6 +1521,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) { @@ -1357,6 +1533,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)); } @@ -1376,17 +1553,21 @@ 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 { 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 _input_logging = input_record::Scope::enter(); 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(); @@ -1395,6 +1576,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; @@ -1416,6 +1598,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, @@ -1426,13 +1609,26 @@ 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 /// 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; @@ -1443,6 +1639,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; @@ -1478,6 +1675,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; @@ -1505,11 +1703,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; @@ -1523,6 +1723,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; @@ -1545,6 +1746,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 { @@ -1563,8 +1765,39 @@ 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; + } else { + st.inputs.call_depth -= 1; + } + set_mode_for_top(st); +} + +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 + && st.inputs.call_depth == 0 + && input_top != 0 + && st.inputs.writes.get(input_top).is_some_and(Option::is_none) + { + input_record::remember_write(st, 0, 0); + st.inputs.watching = false; + unsafe { + apply_protection(st); + } + } + + input_record::event(st, "effect", format!("{class:?}:{what}")); if st.sabotage_env && what == "env" { return; } @@ -1636,7 +1869,9 @@ 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 // locations: an earlier birth there and what members of D did to its // words are gone. @@ -1650,6 +1885,8 @@ 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); + 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 { @@ -1669,13 +1906,19 @@ 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; } 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; @@ -1688,6 +1931,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 @@ -1702,10 +1946,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); @@ -1725,11 +1966,12 @@ 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)) }; } } 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 { @@ -1801,6 +2043,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,7 +2105,15 @@ unsafe fn for_each_tracked_range(st: &State, mut f: impl FnMut(usize, usize)) { } unsafe fn apply_protection(st: &mut State) { - if st.top_mode { + if input_record::watch(st) { + for_each_tracked_range(st, |b, e| { + libc::mprotect(b as *mut _, e - b, libc::PROT_READ); + }); + return; + } + // 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); }); @@ -2100,6 +2355,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; @@ -2142,7 +2398,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); @@ -2179,7 +2444,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; } @@ -2212,7 +2481,12 @@ 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; + // 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); @@ -2231,20 +2505,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; } @@ -2291,7 +2570,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 @@ -2327,6 +2628,13 @@ 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; + *watch_ended = true; + } + } if st.transp_globals.contains(word as u64) { st.transparent_hits += 1; return; @@ -2526,6 +2834,34 @@ 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.ew.insert(w); + } else { + st.er.insert(w); + } + } else { + let rule = if st.inits[top].p_entry_hit { + None + } else if write { + st.er.contains(w).then_some(b'b') + } else { + 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; + 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 @@ -2622,6 +2958,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(); } @@ -2638,19 +2975,37 @@ pub unsafe extern "C" fn getenv(name: *const libc::c_char) -> *mut libc::c_char e = e.add(1); } } - if on() { + 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); - 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)); + 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))); + } else { + input_record::native_event( + st, + op, + format_args!( + "{}={}", + input_record::Hex(key), + input_record::Hex(std::ffi::CStr::from_ptr(found).to_bytes()) + ), + ); } } + if inputs_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); + } + *libc::__errno_location() = saved_errno; found } @@ -2665,10 +3020,12 @@ 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; } + input_record::event(st, "boundary", at.to_owned()); charge(st); st.seq += 1; st.boundary_seq = st.seq; @@ -2682,6 +3039,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() { @@ -2743,6 +3101,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; @@ -2848,6 +3207,11 @@ 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; let Ok(mut f) = std::fs::File::create(&st.out) else { return; @@ -3813,6 +4177,245 @@ 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(); + // 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)) { + 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() { + 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) { + held += 1; + if seen.insert((b.addr, k)) && rows.len() < CHECK_FAIL_MAX { + row( + &mut rows, + k, + format!( + "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), + ); + } + } + } + } + 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) } @@ -3840,11 +4443,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"); @@ -3866,6 +4471,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)..] @@ -3886,7 +4492,9 @@ 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); let site = st.env_site.take().filter(|s| s.0 == top).map(|s| s.1); if !st.env_rule { @@ -3918,7 +4526,9 @@ 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 { return host(Effect::HostRead, "env"); } @@ -3985,6 +4595,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; @@ -4225,8 +4836,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; } @@ -4297,3 +4909,545 @@ 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 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 { + input_record::native_event( + state(), + "native.clock_gettime", + format_args!("clock={clock};rc={rc};errno={saved_errno}"), + ); + } + } + *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 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 { + input_record::native_event( + state(), + "native.gettimeofday", + format_args!("rc={rc};errno={saved_errno}"), + ); + } + } + *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 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 +} + +// 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 inputs_on() && on_main_thread() { + let _input_logging = input_record::Scope::enter(); + let key = if name.is_null() { + b"".as_slice() + } 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!("{}{was}", input_record::Hex(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 inputs_on() && on_main_thread() { + let _input_logging = input_record::Scope::enter(); + let key = if name.is_null() { + b"".as_slice() + } else { + std::ffi::CStr::from_ptr(name).to_bytes() + }; + let val = if value.is_null() { + b"".as_slice() + } else { + std::ffi::CStr::from_ptr(value).to_bytes() + }; + input_record::native_event( + state(), + "native.setenv", + format_args!( + "{}={};overwrite={overwrite}{}", + input_record::Hex(key), + input_record::Hex(val), + if !c25() { + "" + } else if getenv(name).is_null() { + ";was=unset" + } else { + ";was=set" + } + ), + ); + 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) +} + +// --------------------------------------------------------------------------- +// 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() +} +#[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", + 2 => "stack_maps", + 3 => "stack_bounds", + 4 => "parse", + 5 => "parallelism", + 6 => "gc", + 7 => "exec_argv", + 8 => "style_bit", + 9 => "unwind", + _ => "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), + ) +} + +/// 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()); + } + 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, +/// 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 + ), + ); +} + +/// 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 +/// 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;jobs={}", + st.frames.last().map_or(0, |f| f.init), + n.map_or("busy".to_owned(), |n| n.to_string()), + pending_jobs_field() + ), + ); + } + } + } +} 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/crates/perry-runtime/src/tty.rs b/crates/perry-runtime/src/tty.rs index af5df12474c..660129251de 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 } @@ -575,12 +637,18 @@ fn color_depth_for_env(env: ColorEnv<'_>) -> u8 { /// `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. +/// `FORCE_COLOR` is set. 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 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 + let tty = forced || 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 { @@ -908,6 +976,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"; @@ -925,6 +996,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/src/crypto/random.rs b/crates/perry-stdlib/src/crypto/random.rs index 507e5b479b6..75c16ba0d17 100644 --- a/crates/perry-stdlib/src/crypto/random.rs +++ b/crates/perry-stdlib/src/crypto/random.rs @@ -55,6 +55,34 @@ fn validate_random_bytes_size(size: f64) -> usize { pub extern "C" fn js_crypto_random_bytes_buffer( size: f64, ) -> *mut perry_runtime::buffer::BufferHeader { + perry_runtime::census_inputs! { + 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())); + 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::() + .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 +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())); + 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::() @@ -115,11 +148,32 @@ 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 { + perry_runtime::census_inputs! { + 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); + 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) + }, + ); + } + 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); + 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) @@ -133,6 +187,30 @@ 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 { + perry_runtime::census_inputs! { + 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(); + 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) + }, + ); + } + perry_runtime::effect!(host_read, random); let uuid = if validate_random_uuid_options(options_bits) { perry_uuid::v4_uncached() @@ -142,6 +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(); + 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) } @@ -153,9 +233,29 @@ 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 { + perry_runtime::census_inputs! { + 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(); + 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) + }, + ); + } + perry_runtime::effect!(host_read, random); let uuid = perry_uuid::v7(); let uuid_bytes = uuid.as_bytes(); + 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) } @@ -252,6 +352,34 @@ 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 { + perry_runtime::census_inputs! { + 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 +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]); + 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 diff --git a/scripts/snap_inputs/README.md b/scripts/snap_inputs/README.md new file mode 100644 index 00000000000..0cc65ad891a --- /dev/null +++ b/scripts/snap_inputs/README.md @@ -0,0 +1,71 @@ +# 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. + +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. + +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/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/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'])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/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/checkk2.py b/scripts/snap_inputs/checkk2.py new file mode 100644 index 00000000000..b92c7e344fb --- /dev/null +++ b/scripts/snap_inputs/checkk2.py @@ -0,0 +1,75 @@ +#!/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 = [] +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) + + +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}") + # (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: + 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/checkk2b.py b/scripts/snap_inputs/checkk2b.py new file mode 100644 index 00000000000..c44a2637ed3 --- /dev/null +++ b/scripts/snap_inputs/checkk2b.py @@ -0,0 +1,133 @@ +#!/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") + # 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") + 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/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/allinputs/inputs.ts b/scripts/snap_inputs/fixtures/allinputs/inputs.ts new file mode 100644 index 00000000000..5317e117e99 --- /dev/null +++ b/scripts/snap_inputs/fixtures/allinputs/inputs.ts @@ -0,0 +1,19 @@ +import { readFileSync } from "node:fs"; +import { randomBytes, randomUUID } from "node:crypto"; +import { isatty } from "node:tty"; +import { performance } from "node:perf_hooks"; +const env = process.env; +const envNames = Object.keys(env); +const values = [ + process.env.SNAP21_SENTINEL, "SNAP21_ABSENT" in env, envNames.length, + process.argv.join("|"), process.cwd(), navigator.language, + new Intl.DateTimeFormat().resolvedOptions().timeZone, + isatty(0), isatty(1), isatty(2), process.stdout.columns, + process.getuid(), process.getgid(), process.pid, process.ppid, + Date.now(), performance.now(), process.hrtime.bigint().toString(), + Math.random(), randomBytes(4).toString("hex"), randomUUID(), + readFileSync("package.json","utf8"), readFileSync("inputs.ts","utf8"), + new Error("SNAP21").stack, +]; +console.log("INPUTS21", values.length); +export { values }; diff --git a/scripts/snap_inputs/fixtures/allinputs/main.ts b/scripts/snap_inputs/fixtures/allinputs/main.ts new file mode 100644 index 00000000000..96adfc9142d --- /dev/null +++ b/scripts/snap_inputs/fixtures/allinputs/main.ts @@ -0,0 +1,3 @@ +await import("./inputs.ts"); +gc(); +console.log("DONE21"); diff --git a/scripts/snap_inputs/fixtures/allinputs/package.json b/scripts/snap_inputs/fixtures/allinputs/package.json new file mode 100644 index 00000000000..d0bd4117f85 --- /dev/null +++ b/scripts/snap_inputs/fixtures/allinputs/package.json @@ -0,0 +1 @@ +{"name":"snap21-host-input-fixture","type":"module"} 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/net/main.ts b/scripts/snap_inputs/fixtures/net/main.ts new file mode 100644 index 00000000000..8abacd34ff1 --- /dev/null +++ b/scripts/snap_inputs/fixtures/net/main.ts @@ -0,0 +1,3 @@ +await import("./network.ts"); +gc(); +console.log("NET21DONE"); diff --git a/scripts/snap_inputs/fixtures/net/network.ts b/scripts/snap_inputs/fixtures/net/network.ts new file mode 100644 index 00000000000..c39935b9517 --- /dev/null +++ b/scripts/snap_inputs/fixtures/net/network.ts @@ -0,0 +1,6 @@ +import { createServer } from "node:net"; +const server=createServer(); +server.listen(0,"127.0.0.1"); +const address=server.address(); +server.close(); +export { address }; 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/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/fixtures/r1/leaf.ts b/scripts/snap_inputs/fixtures/r1/leaf.ts new file mode 100644 index 00000000000..1e08e59e3b7 --- /dev/null +++ b/scripts/snap_inputs/fixtures/r1/leaf.ts @@ -0,0 +1 @@ +export const value = Number(process.env.SNAP21_SENTINEL ?? "7"); diff --git a/scripts/snap_inputs/fixtures/r1/main.ts b/scripts/snap_inputs/fixtures/r1/main.ts new file mode 100644 index 00000000000..e3a23f8db2a --- /dev/null +++ b/scripts/snap_inputs/fixtures/r1/main.ts @@ -0,0 +1,3 @@ +await import("./parent.ts"); +gc(); +console.log("R1DONE"); diff --git a/scripts/snap_inputs/fixtures/r1/parent.ts b/scripts/snap_inputs/fixtures/r1/parent.ts new file mode 100644 index 00000000000..f3e852f74aa --- /dev/null +++ b/scripts/snap_inputs/fixtures/r1/parent.ts @@ -0,0 +1,2 @@ +import { value } from "./leaf.ts"; +export const answer=value+1; diff --git a/scripts/snap_inputs/fixtures/r2/leaf.ts b/scripts/snap_inputs/fixtures/r2/leaf.ts new file mode 100644 index 00000000000..400295fab26 --- /dev/null +++ b/scripts/snap_inputs/fixtures/r2/leaf.ts @@ -0,0 +1 @@ +export const value=process.argv.length; diff --git a/scripts/snap_inputs/fixtures/r2/main.ts b/scripts/snap_inputs/fixtures/r2/main.ts new file mode 100644 index 00000000000..4ea6f89e31b --- /dev/null +++ b/scripts/snap_inputs/fixtures/r2/main.ts @@ -0,0 +1,3 @@ +await import("./parent.ts"); +gc(); +console.log("R2DONE", globalThis.snap21count); diff --git a/scripts/snap_inputs/fixtures/r2/parent.ts b/scripts/snap_inputs/fixtures/r2/parent.ts new file mode 100644 index 00000000000..3dbd30f076e --- /dev/null +++ b/scripts/snap_inputs/fixtures/r2/parent.ts @@ -0,0 +1,3 @@ +globalThis.snap21count = (globalThis.snap21count ?? 0)+1; +await import("./leaf.ts"); +globalThis.snap21count += 10; diff --git a/scripts/snap_inputs/fixtures/replay/main.ts b/scripts/snap_inputs/fixtures/replay/main.ts new file mode 100644 index 00000000000..926503d7bf0 --- /dev/null +++ b/scripts/snap_inputs/fixtures/replay/main.ts @@ -0,0 +1,15 @@ +// Counterexample component model: deliberately seed the heap at an unsafe +// child boundary. This does not implement or exercise an image loader. +const wrong=process.env.SNAP21_UNSAFE_REPLAY; +let childDone=wrong!==undefined; +let childRuns=childDone?1:0; +const output:string[]=wrong==="R1"?["before"]:[]; +let durableCell=wrong==="R2"?1:0; +function child(){if(childDone)return;childDone=true;childRuns++;} +function bodyStartedParent(){output.push("before");child();output.push("after");} +function writeParent(){const guarded=process.env.SNAP21_REPLAY_VALUE;durableCell++;child();return guarded;} +if(wrong==="R1" || wrong===undefined)bodyStartedParent(); +if(wrong==="R2" || wrong===undefined)writeParent(); +const valid=childRuns===1 && output.join(",")===(wrong==="R2"?"":"before,after") && durableCell===(wrong==="R1"?0:1); +console.log(valid?"EQUIVALENT":"REPEATED_EFFECT",childRuns,output.join(","),durableCell); +if(!valid)process.exit(1); 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/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..7a06fff393a --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2/f1/main.ts @@ -0,0 +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/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..8574d44ec98 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2/f10/main.ts @@ -0,0 +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/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..a1d25970eef --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2/f2/main.ts @@ -0,0 +1,10 @@ +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"); +// 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/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..6eaeaba3585 --- /dev/null +++ b/scripts/snap_inputs/fixtures/snapk2/f3/main.ts @@ -0,0 +1,8 @@ +// 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"); +// The census boundary (snap25 fixtures mark it the same way). +gc(); +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"} 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"} diff --git a/scripts/snap_inputs/fixtures/stop/main.ts b/scripts/snap_inputs/fixtures/stop/main.ts new file mode 100644 index 00000000000..0e35a3eb7cf --- /dev/null +++ b/scripts/snap_inputs/fixtures/stop/main.ts @@ -0,0 +1,4 @@ +await import("./pid.ts"); +gc(); +console.log(globalThis.snap21pid===process.pid ? "fresh" : "STALE"); +if(globalThis.snap21pid!==process.pid) process.exit(1); diff --git a/scripts/snap_inputs/fixtures/stop/pid.ts b/scripts/snap_inputs/fixtures/stop/pid.ts new file mode 100644 index 00000000000..37abc3c6c49 --- /dev/null +++ b/scripts/snap_inputs/fixtures/stop/pid.ts @@ -0,0 +1 @@ +globalThis.snap21pid = process.env.SNAP21_FORCE_IMAGED_PID ? Number(process.env.SNAP21_FORCE_IMAGED_PID) : process.pid; 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" 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 new file mode 100644 index 00000000000..70b1aaaf18b --- /dev/null +++ b/scripts/snap_inputs/trace.py @@ -0,0 +1,414 @@ +#!/usr/bin/env python3 +"""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, subprocess, hashlib +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): + C.set_errno(0) + r=L.ptrace(req,pid,C.c_void_p(addr),C.c_void_p(data)) + if r==-1 and C.get_errno(): + 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(),"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") + 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 + 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} + 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) + 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_","js_vm_")) or fields[2] in ("js_eval_in_scope","js_function_constructor","js_function_new"): + 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(">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 + breakpoints.clear();returns.clear();audit_names.clear() + install_probes(tid) + 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 + # Optional raw stack evidence for source audit. These words are not a + # backtrace and never establish an input's eligibility or pointer shape. + 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 + 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: + entry["path"]=string(tid,a[0]); argv=[]; index=0 + while True: + item=memory(tid,a[1]+8*index,8) + 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 + entry["argv"]=argv + envp=[];index=0 + while True: + item=memory(tid,a[2]+8*index,8) + 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 + entry["environment"]=envp + 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==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]==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" + 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 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 + # 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) + 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) if a[0] else None + e["timezone"]=blob(tid,a[1],8) if a[1] else None + 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 in (13,14): + e["old_signal_state"]=blob(tid,a[2],32 if nr==13 else a[3]) if a[2] else None + elif nr==131:e["old_signal_stack"]=blob(tid,a[1],24) if a[1] else None + elif nr in (137,138):e["struct"]=blob(tid,a[1],120) + elif nr==158: + if a[0] in (0x1003,0x1004):e["tls_pointer"]=blob(tid,a[1],8) + elif nr in (23,270): + length=((a[0]+63)//64)*8 + e["fdsets"]=[blob(tid,address,length) if address else None for address in a[1:4]] + e["timeout"]=blob(tid,a[4],16) if a[4] else None + elif nr==61: + e["wait_status"]=blob(tid,a[1],4) if a[1] else None + e["usage"]=blob(tid,a[3],144) if a[3] 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,5,9,11,19,25,37):e["struct"]=blob(tid,a[1],4) + elif a[0] not in (1,3,4,6,7,8,10,12,13,14,15,17,18,20,21,22,23,24,26,27,28,29,30,31,32,33,34,35,36,38,39,41,42,43,44,45,46,47,50,51,52,53,54,55,56,57,58,59,63,64,65,66,67,68,0x53564d41,0x59616d61): + e["unsupported_payload"]=True + 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) + if nr==271 and a[2]:e["timeout"]=blob(tid,a[2],16) + 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==47:e["message"]=message(tid,a[1],ret) + elif nr in (299,307): + messages=[] + for index in range(ret): + item=a[1]+index*64 + length=memory(tid,item+56,4) + if length is None:messages.append({"unreadable_mmsghdr":item});continue + messages.append(message(tid,item,struct.unpack("I",length)[0])) + e["messages"]=messages + 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 + 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): + pass + elif event == 0 and sig != signal.SIGSTOP: + information=C.create_string_buffer(128) + ptrace(0x4202,tid,0,C.addressof(information)) + out.write(json.dumps({"kind":"signal","tid":tid,"signal":sig,"ir":ir(active[tid]["perf"]), + "siginfo_hex":information.raw.hex()})+"\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()) +