From abe1d4766b41a763d3be9c26a1297575e59c6c86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 29 Oct 2026 15:14:57 +0100 Subject: [PATCH 01/84] fix(compile): pnpm isolated stores, wrong-target Node-API addons, static text imports Found compiling the Supabase CLI (pnpm 12 workspace, global virtual store) from source. - Node-API: a package's prebuilt `.node` for another platform is not part of this target's graph. napi-rs loaders `require` every platform's binary in turn; Node throws at runtime for the wrong one and the loader catches it. The edge is now left unresolved (so it throws the same way) instead of failing the compile as "not approved" / "no addon matching target". - Node-API: a platform payload is approved through the allowlisted wrapper copy the walk entered, resolving the declared dependency from the wrapper's side. pnpm's isolated store keeps the wrapper outside every directory above the payload, so listing `@napi-rs/keyring` never covered `@napi-rs/keyring-darwin-arm64`. - Static `import s from "./Dockerfile" with { type: "text" }` reads any file as a text module (Bun's text loader); only recognized extensions did. - `compilePackages` wildcard enumerates pnpm's `.pnpm` store (hidden hoist and per-package entries) as it does bun's `.bun`. Without it a direct dependency compiled from TypeScript source while its transitive dependency stayed on published JS lacking source-only exports (`tldts` importing the const enum `FLAG` from `tldts-core`). --- .../src/commands/compile/asset_manifest.rs | 2 +- .../src/commands/compile/collect_modules.rs | 19 +++- .../collect_modules/import_meta_require.rs | 17 ++-- .../compile/collect_modules/native_addon.rs | 87 ++++++++++++++++- .../collect_modules/native_addon/discovery.rs | 94 +++++++++++++++++++ .../commands/compile/collect_modules/tests.rs | 6 +- .../compile/collect_modules_helpers.rs | 19 ++++ crates/perry/src/commands/compile/resolve.rs | 28 ++++++ .../resolve/package_enumeration_tests.rs | 18 ++++ crates/perry/src/commands/compile/types.rs | 11 +++ .../tests/issue_5223_text_asset_imports.rs | 34 ++++++- 11 files changed, 316 insertions(+), 19 deletions(-) diff --git a/crates/perry/src/commands/compile/asset_manifest.rs b/crates/perry/src/commands/compile/asset_manifest.rs index e217c9e7e22..e33e8614ec7 100644 --- a/crates/perry/src/commands/compile/asset_manifest.rs +++ b/crates/perry/src/commands/compile/asset_manifest.rs @@ -48,7 +48,7 @@ pub(super) fn write( } let kind = if path.extension().and_then(|ext| ext.to_str()) == Some("json") { Some("json") - } else if is_recognized_text_asset(path) { + } else if is_recognized_text_asset(path) || ctx.text_loader_asset_paths.contains(path) { Some("text") } else { None diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index 917d993da16..9028c941826 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -17,6 +17,7 @@ use std::path::{Path, PathBuf}; use super::collect_modules_helpers::{ file_loader_import_sources, imported_file_asset_name, looks_like_generated_module, + text_loader_import_sources, }; use crate::commands::progress::{ProgressSnapshot, VerboseProgress}; use crate::OutputFormat; @@ -66,7 +67,9 @@ use import_helpers::{ }; use import_meta_require::rewrite_import_meta_require_addons; pub(super) use native_addon::{collect_declared_addons, package_has_unsupported_node_addon}; -use native_addon::{collect_or_refuse_node_addon, refuse_compile_package_native_addon}; +use native_addon::{ + collect_or_refuse_node_addon, note_native_addon_wrapper, refuse_compile_package_native_addon, +}; use parse_error::annotate_parse_error; pub(super) use source_prep::{PreparedSources, WalkSources}; #[cfg(test)] @@ -182,7 +185,8 @@ fn collect_module_one( // A `.node` member is a sidecar native module, never UTF-8 source. Record // an approved entry and stop this discovery frame before any JS/TS parser // or source reader sees its bytes. - if collect_or_refuse_node_addon(ctx, &canonical)? { + note_native_addon_wrapper(ctx, &canonical); + if collect_or_refuse_node_addon(ctx, &canonical, target)? { return Ok(ModuleDiscovery { finish: None, children: pending, @@ -196,7 +200,8 @@ fn collect_module_one( // extension is read verbatim and synthesized into a native module whose // default export is the file contents as a JS string (see the text branch // below, mirroring the JSON-module path). - let is_text_asset = is_recognized_text_asset(&canonical); + let is_text_asset = + is_recognized_text_asset(&canonical) || ctx.text_loader_asset_paths.contains(&canonical); // Bun's `{ type: "file" }` loader exposes a virtual path string while // keeping the imported bytes out of the TypeScript parser. The importer // registers this canonical path before the child frame is visited. @@ -442,7 +447,8 @@ fn collect_module_one( // execution uses only the authenticated sidecar id. Run before the // Bun virtual-literal asset scan so a `.node` call target ships once // in the sidecar rather than also being embedded as inert data. - let raw_source = rewrite_import_meta_require_addons(&raw_source, &canonical, ctx)?; + let raw_source = + rewrite_import_meta_require_addons(&raw_source, &canonical, ctx, target)?; register_bunfs_literal_assets(&raw_source, ctx); // CJS wrapping consumes literal `require()` sites and replaces them // with generated loader calls. Queue native targets before that @@ -621,6 +627,7 @@ fn collect_module_one( let native_namespace_module = native_esm_namespace::rewrite_static(ast_module, &ctx.cache_dir)?; let ast_module = native_namespace_module.as_ref().unwrap_or(ast_module); let file_loader_sources = file_loader_import_sources(ast_module); + let text_loader_sources = text_loader_import_sources(ast_module); let source_file_path = canonical.to_string_lossy().to_string(); if !perry_hir::source_remap::is_covered(&canonical) { eprintln!( @@ -1409,6 +1416,7 @@ fn collect_module_one( continue; } let uses_file_loader = file_loader_sources.contains(&import.source); + let uses_text_loader = text_loader_sources.contains(&import.source); progress.record(ProgressSnapshot { stage: "resolve-import", module_path: Some(&canonical), @@ -1609,6 +1617,9 @@ fn collect_module_one( { let resolved_path = resolved.canonical_path; let source_path = resolved.source_path; + if uses_text_loader { + ctx.text_loader_asset_paths.insert(resolved_path.clone()); + } if uses_file_loader { let name = ctx .file_loader_asset_names diff --git a/crates/perry/src/commands/compile/collect_modules/import_meta_require.rs b/crates/perry/src/commands/compile/collect_modules/import_meta_require.rs index 9d4119e8924..770413b8261 100644 --- a/crates/perry/src/commands/compile/collect_modules/import_meta_require.rs +++ b/crates/perry/src/commands/compile/collect_modules/import_meta_require.rs @@ -16,7 +16,7 @@ use swc_ecma_ast::Pass; use swc_ecma_transforms_base::resolver; use swc_ecma_visit::{Visit, VisitWith}; -use super::native_addon::collect_node_addon_request; +use super::native_addon::{collect_node_addon_request, is_other_target_package_addon}; use super::static_require_transform::resolve_static_require; use super::CompilationContext; @@ -279,6 +279,7 @@ pub(super) fn rewrite_import_meta_require_addons( source: &str, module_path: &Path, ctx: &mut CompilationContext, + compile_target: Option<&str>, ) -> Result { if !import_meta_require_may_apply(source) { return Ok(source.to_string()); @@ -352,7 +353,9 @@ pub(super) fn rewrite_import_meta_require_addons( } continue; }; - if target.extension().and_then(|extension| extension.to_str()) != Some("node") { + if target.extension().and_then(|extension| extension.to_str()) != Some("node") + || is_other_target_package_addon(ctx, &target, compile_target) + { continue; } let Some(logical_id) = collect_node_addon_request(ctx, &target)? else { @@ -429,7 +432,7 @@ const b = load("./native/addon.node"); const c = load("/$bunfs/root/native/addon.node"); const d = import.meta.require(new URL("./native/addon.node", import.meta.url).pathname); "#; - let rewritten = rewrite_import_meta_require_addons(source, &entry, &mut ctx).unwrap(); + let rewritten = rewrite_import_meta_require_addons(source, &entry, &mut ctx, None).unwrap(); assert_eq!(rewritten.matches(".dlopen(").count(), 4, "{rewritten}"); assert_eq!( rewritten.matches("\"$project/native/addon.node\"").count(), @@ -447,6 +450,7 @@ const d = import.meta.require(new URL("./native/addon.node", import.meta.url).pa "const load = import.meta.require; load(process.env.ADDON_PATH);", &entry, &mut ctx, + None, ) .unwrap_err() .to_string(); @@ -460,7 +464,7 @@ const d = import.meta.require(new URL("./native/addon.node", import.meta.url).pa let entry = dir.path().join(relative_entry); let source = "import.meta.require(process.argv[2]); import.meta['require'](choosePath());"; assert_eq!( - rewrite_import_meta_require_addons(source, &entry, &mut ctx).unwrap(), + rewrite_import_meta_require_addons(source, &entry, &mut ctx, None).unwrap(), source ); assert!(ctx.native_addons.is_empty()); @@ -472,7 +476,7 @@ const d = import.meta.require(new URL("./native/addon.node", import.meta.url).pa let entry = dir.path().join(relative_entry); let source = r#"let load = import.meta.require; load = other; load("./native/addon.node");"#; - let rewritten = rewrite_import_meta_require_addons(source, &entry, &mut ctx).unwrap(); + let rewritten = rewrite_import_meta_require_addons(source, &entry, &mut ctx, None).unwrap(); assert_eq!(rewritten, source); assert!(ctx.native_addons.is_empty()); } @@ -485,6 +489,7 @@ const d = import.meta.require(new URL("./native/addon.node", import.meta.url).pa r#"const URL = CustomURL; const load = import.meta.require; load(new URL("./native/addon.node", import.meta.url).pathname);"#, &entry, &mut ctx, + None, ) .unwrap_err() .to_string(); @@ -504,7 +509,7 @@ function unrelated(load) { return load("./native/addon.node"); } "#; - let rewritten = rewrite_import_meta_require_addons(source, &entry, &mut ctx).unwrap(); + let rewritten = rewrite_import_meta_require_addons(source, &entry, &mut ctx, None).unwrap(); assert_eq!(rewritten.matches(".dlopen(").count(), 1, "{rewritten}"); assert!( rewritten.contains("return load(\"./native/addon.node\");\n}"), diff --git a/crates/perry/src/commands/compile/collect_modules/native_addon.rs b/crates/perry/src/commands/compile/collect_modules/native_addon.rs index 086e844e95b..4e7ae83e869 100644 --- a/crates/perry/src/commands/compile/collect_modules/native_addon.rs +++ b/crates/perry/src/commands/compile/collect_modules/native_addon.rs @@ -65,6 +65,13 @@ fn package_path(node_modules: &std::path::Path, name: &str) -> PathBuf { /// platform payload dependency. This covers napi-rs layouts such as /// `@napi-rs/foo` -> `@napi-rs/foo-win32-x64-msvc` without letting an /// unrelated transitive package inherit the permission. +/// +/// The wrapper is looked up from the payload's enclosing `node_modules` +/// (npm/yarn layouts) and, failing that, among the wrapper copies the walk +/// entered, resolving the declared dependency from the wrapper's side. An +/// isolated store (the pnpm global virtual store) keeps the wrapper outside +/// every directory above the payload; only the wrapper's own `node_modules` +/// links it. fn approved_owner_package( ctx: &CompilationContext, package_root: &std::path::Path, @@ -98,7 +105,60 @@ fn approved_owner_package( } } } - None + let payload = package_root.canonicalize().ok()?; + ctx.native_addon_wrapper_roots.iter().find_map(|wrapper| { + declared_dependencies(wrapper) + .iter() + .any(|dependency| { + dependency == actual_name + && resolve_package_from(wrapper, dependency).as_deref() + == Some(payload.as_path()) + }) + .then(|| package_name_from_package_json(wrapper)) + .flatten() + }) +} + +/// Record the package root of a walked module when it is an allowlisted +/// Node-API wrapper, so its platform payloads can be approved through it. +pub(super) fn note_native_addon_wrapper(ctx: &mut CompilationContext, module: &std::path::Path) { + if ctx.native_addon_packages.is_empty() { + return; + } + let Some(root) = nearest_package_root(module) else { + return; + }; + if ctx.native_addon_wrapper_roots.contains(&root) { + return; + } + if package_name_from_package_json(&root) + .is_some_and(|name| ctx.native_addon_packages.contains(&name)) + { + ctx.native_addon_wrapper_roots.insert(root); + } +} + +/// The canonical root of package `name` as Node resolves it from package +/// root `from`: the nearest `node_modules/` at or above it. +fn resolve_package_from(from: &std::path::Path, name: &str) -> Option { + from.ancestors() + .map(|ancestor| package_path(&ancestor.join("node_modules"), name)) + .find(|candidate| candidate.join("package.json").is_file()) + .and_then(|candidate| candidate.canonicalize().ok()) +} + +fn declared_dependencies(package_root: &std::path::Path) -> Vec { + let Some(manifest) = fs::read_to_string(package_root.join("package.json")) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + else { + return Vec::new(); + }; + ["dependencies", "optionalDependencies"] + .iter() + .filter_map(|section| manifest.get(section).and_then(|value| value.as_object())) + .flat_map(|dependencies| dependencies.keys().cloned()) + .collect() } fn normalized_relative(path: &std::path::Path) -> String { @@ -240,17 +300,40 @@ pub(super) fn collect_node_addon_request( pub(super) fn collect_or_refuse_node_addon( ctx: &mut CompilationContext, canonical: &std::path::Path, + target: Option<&str>, ) -> Result { if canonical.extension().and_then(|ext| ext.to_str()) == Some("node") - && nearest_package_root(canonical) + && (nearest_package_root(canonical) .as_deref() .is_some_and(|root| parcel_facade_without_approval(ctx, root)) + || is_other_target_package_addon(ctx, canonical, target)) { return Ok(true); } collect_node_addon_request(ctx, canonical).map(|request| request.is_some()) } +/// A package's prebuilt binary for another platform is not part of this +/// target's graph. napi-rs and prebuild loaders name every platform's binary +/// in literal `require` calls and try each in turn; under Node the +/// wrong-platform load throws at runtime and the loader catches it. Such an +/// edge is left unresolved, so its `require` throws the same way. Exact +/// project paths (`perry.nativeAddonPaths`) keep their target check. +pub(super) fn is_other_target_package_addon( + ctx: &CompilationContext, + canonical: &std::path::Path, + target: Option<&str>, +) -> bool { + if ctx.native_addon_paths.contains_key(canonical) + || !nearest_package_root(canonical) + .as_deref() + .is_some_and(path_is_inside_node_modules) + { + return false; + } + discovery::is_other_target_addon(canonical, target) +} + fn parcel_facade_without_approval(ctx: &CompilationContext, root: &std::path::Path) -> bool { package_is_parcel_watcher_facade(root) && package_name_from_package_json(root) diff --git a/crates/perry/src/commands/compile/collect_modules/native_addon/discovery.rs b/crates/perry/src/commands/compile/collect_modules/native_addon/discovery.rs index f095e5d7801..c0939c0b169 100644 --- a/crates/perry/src/commands/compile/collect_modules/native_addon/discovery.rs +++ b/crates/perry/src/commands/compile/collect_modules/native_addon/discovery.rs @@ -114,6 +114,19 @@ fn addon_files(root: &Path, dir: &Path, output: &mut Vec) -> Result<()> Ok(()) } +/// True when `path` is a Node-API binary built for a known platform other +/// than the compile target. A file that cannot be inspected is not claimed, +/// so the regular validation reports it. +pub(super) fn is_other_target_addon(path: &Path, target: Option<&str>) -> bool { + let Some(triple) = crate::commands::compile::rust_target_triple(target) + .or_else(crate::commands::compile::host_target_triple) + else { + return false; + }; + (triple.starts_with("aarch64-") || triple.starts_with("x86_64-")) + && matches!(matches_target(path, triple), Ok(false)) +} + fn matches_target(path: &Path, triple: &str) -> Result { let bytes = fs::read(path)?; let file = object::File::parse(&*bytes) @@ -210,6 +223,87 @@ mod tests { .contains("unsupported symbol")); } + #[test] + fn other_target_payload_edge_is_left_to_runtime() { + // A napi-rs loader names every platform's binary; the x64 one is not + // part of an aarch64 graph, approved or not, and records nothing. + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let payload = root.join("node_modules/demo-darwin-x64"); + addon(&payload.join("demo.node"), false, "_napi_create_int32"); + fs::write( + payload.join("package.json"), + r#"{"name":"demo-darwin-x64","version":"1"}"#, + ) + .unwrap(); + let mut ctx = CompilationContext::new(root.clone()); + let binary = payload.join("demo.node"); + assert!( + super::super::collect_or_refuse_node_addon(&mut ctx, &binary, Some("macos")).unwrap() + ); + assert!(ctx.native_addons.is_empty()); + // A binary for the target itself is still subject to the policy. + let native = root.join("node_modules/demo-darwin-arm64"); + addon(&native.join("demo.node"), true, "_napi_create_int32"); + fs::write( + native.join("package.json"), + r#"{"name":"demo-darwin-arm64","version":"1"}"#, + ) + .unwrap(); + let binary = native.join("demo.node"); + assert!( + super::super::collect_or_refuse_node_addon(&mut ctx, &binary, Some("macos")) + .unwrap_err() + .to_string() + .contains("perry.nativeAddons") + ); + } + + #[cfg(unix)] + #[test] + fn isolated_store_payload_inherits_the_wrapper_opt_in() { + // pnpm's global virtual store: the wrapper and each payload live in + // separate store directories; only the wrapper's own `node_modules` + // links the payload, and the project links the wrapper. + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let store = root.join("store"); + let wrapper = store.join("demo/1/node_modules/@demo/addon"); + let payload = store.join("demo-darwin-arm64/1/node_modules/@demo/addon-darwin-arm64"); + fs::create_dir_all(&wrapper).unwrap(); + addon(&payload.join("addon.node"), true, "_napi_create_int32"); + fs::write( + wrapper.join("package.json"), + r#"{"name":"@demo/addon","optionalDependencies":{"@demo/addon-darwin-arm64":"1"}}"#, + ) + .unwrap(); + fs::write( + payload.join("package.json"), + r#"{"name":"@demo/addon-darwin-arm64","version":"1"}"#, + ) + .unwrap(); + std::os::unix::fs::symlink(&payload, wrapper.with_file_name("addon-darwin-arm64")).unwrap(); + fs::write(wrapper.join("index.js"), "").unwrap(); + + let mut ctx = CompilationContext::new(root.join("app")); + ctx.native_addon_packages.insert("@demo/addon".into()); + let binary = payload.join("addon.node"); + // Unwalked, the wrapper grants nothing. + assert!( + super::super::collect_or_refuse_node_addon(&mut ctx, &binary, Some("macos")) + .unwrap_err() + .to_string() + .contains("perry.nativeAddons") + ); + // The walk enters the wrapper's loader, then reaches the payload. + super::super::note_native_addon_wrapper(&mut ctx, &wrapper.join("index.js")); + assert!( + super::super::collect_or_refuse_node_addon(&mut ctx, &binary, Some("macos")).unwrap() + ); + let record = &ctx.native_addons["@demo/addon-darwin-arm64/addon.node"]; + assert_eq!(record.package, "@demo/addon"); + } + #[test] fn declared_project_paths_are_exact_and_target_checked() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/perry/src/commands/compile/collect_modules/tests.rs b/crates/perry/src/commands/compile/collect_modules/tests.rs index bb6aa1d66fe..1262bdc2257 100644 --- a/crates/perry/src/commands/compile/collect_modules/tests.rs +++ b/crates/perry/src/commands/compile/collect_modules/tests.rs @@ -26,7 +26,7 @@ fn approved_node_addon_is_recorded_as_a_relocatable_graph_member() { let addon = addon.canonicalize().unwrap(); let mut denied = CompilationContext::new(dir.path().to_path_buf()); - let error = super::native_addon::collect_or_refuse_node_addon(&mut denied, &addon) + let error = super::native_addon::collect_or_refuse_node_addon(&mut denied, &addon, None) .expect_err("unlisted addon must be denied"); assert!(error.to_string().contains("perry.nativeAddons")); @@ -34,7 +34,7 @@ fn approved_node_addon_is_recorded_as_a_relocatable_graph_member() { allowed .native_addon_packages .insert("demo-addon".to_string()); - assert!(super::native_addon::collect_or_refuse_node_addon(&mut allowed, &addon).unwrap()); + assert!(super::native_addon::collect_or_refuse_node_addon(&mut allowed, &addon, None).unwrap()); let record = allowed .native_addons .get("demo-addon/binding.node") @@ -71,7 +71,7 @@ fn platform_payload_inherits_only_a_declared_wrapper_opt_in() { let mut ctx = CompilationContext::new(dir.path().to_path_buf()); ctx.native_addon_packages.insert("@demo/addon".to_string()); - assert!(super::native_addon::collect_or_refuse_node_addon(&mut ctx, &addon).unwrap()); + assert!(super::native_addon::collect_or_refuse_node_addon(&mut ctx, &addon, None).unwrap()); let record = ctx .native_addons .get("@demo/addon-win32-x64-msvc/addon.node") diff --git a/crates/perry/src/commands/compile/collect_modules_helpers.rs b/crates/perry/src/commands/compile/collect_modules_helpers.rs index ebc0fff0dfb..c7c316a8779 100644 --- a/crates/perry/src/commands/compile/collect_modules_helpers.rs +++ b/crates/perry/src/commands/compile/collect_modules_helpers.rs @@ -63,6 +63,25 @@ fn requests_asset_path(attributes: &swc_ecma_ast::ObjectLit) -> bool { .is_some_and(|kind| matches!(kind, "file" | "wasm")) } +/// Static imports read as text: `import s from "./Dockerfile" with { type: "text" }`. +pub(super) fn text_loader_import_sources(module: &swc_ecma_ast::Module) -> HashSet { + use swc_ecma_ast::{ModuleDecl, ModuleItem}; + module + .body + .iter() + .filter_map(|item| { + let ModuleItem::ModuleDecl(ModuleDecl::Import(import)) = item else { + return None; + }; + object_string_property(import.with.as_deref()?, "type") + .and_then(|value| value.value.as_str()) + .is_some_and(|kind| kind == "text") + .then(|| import.src.value.as_str().map(str::to_owned)) + .flatten() + }) + .collect() +} + /// Return imports that request an embedded asset path. This includes static /// Bun file-loader imports and dynamic import options used by OpenCode: /// diff --git a/crates/perry/src/commands/compile/resolve.rs b/crates/perry/src/commands/compile/resolve.rs index c3f3cbf5732..04b35da073a 100644 --- a/crates/perry/src/commands/compile/resolve.rs +++ b/crates/perry/src/commands/compile/resolve.rs @@ -362,6 +362,7 @@ pub(super) fn enumerate_installed_package_roots( for nm in ancestor_node_modules_dirs(project_root) { collect_packages_in_node_modules(&nm, &mut out, &mut visited); collect_packages_in_bun_store(&nm, &mut out, &mut visited); + collect_packages_in_pnpm_store(&nm, &mut out, &mut visited); } // Symlinks and recursively discovered nested packages can expose the same // physical root more than once. Keep its first occurrence, preserving the @@ -399,6 +400,33 @@ fn collect_packages_in_bun_store( } } +/// pnpm's isolated layout links only a workspace package's direct +/// dependencies into its `node_modules`; transitive ones are reachable through +/// `node_modules/.pnpm`, either its hidden-hoist `node_modules` (the only +/// entry when pnpm links packages from a global virtual store) or +/// `.pnpm/@/node_modules`. Without them the +/// wildcard compiles a package from TypeScript source while its transitive +/// dependency stays on published JavaScript, which lacks source-only exports +/// such as a `const enum` (`tldts` importing `FLAG` from `tldts-core`). +fn collect_packages_in_pnpm_store( + node_modules: &Path, + out: &mut HashMap>, + visited: &mut HashSet, +) { + let pnpm_dir = node_modules.join(".pnpm"); + let entries = match fs::read_dir(&pnpm_dir) { + Ok(e) => e, + Err(_) => return, + }; + collect_packages_in_node_modules(&pnpm_dir.join("node_modules"), out, visited); + for entry in entries.flatten() { + let nested = entry.path().join("node_modules"); + if nested.is_dir() { + collect_packages_in_node_modules(&nested, out, visited); + } + } +} + /// Walk a single `node_modules` directory, recording each package name and /// recursing into any nested `node_modules` it contains. /// diff --git a/crates/perry/src/commands/compile/resolve/package_enumeration_tests.rs b/crates/perry/src/commands/compile/resolve/package_enumeration_tests.rs index b791b775a81..2e88d7ca396 100644 --- a/crates/perry/src/commands/compile/resolve/package_enumeration_tests.rs +++ b/crates/perry/src/commands/compile/resolve/package_enumeration_tests.rs @@ -63,3 +63,21 @@ fn ancestor_package_roots_deduplicate_physical_paths() { let roots = enumerate_installed_package_roots(&root.join("z/member")); assert_eq!(roots["dup"], vec![physical.canonicalize().unwrap()]); } + +#[test] +fn pnpm_store_transitive_packages_are_enumerated() { + // pnpm links a member's direct dependencies only; transitive ones live + // behind `.pnpm`, in the hidden hoist or per-package store entries. + let fixture = tempfile::tempdir().unwrap(); + let root = fixture.path(); + let nm = root.join("node_modules"); + fs::create_dir_all(nm.join("direct")).unwrap(); + fs::create_dir_all(nm.join(".pnpm/node_modules/hoisted-core")).unwrap(); + fs::create_dir_all(nm.join(".pnpm/@scope+deep@1.0.0/node_modules/@scope/deep")).unwrap(); + fs::write(nm.join(".pnpm/lock.yaml"), "").unwrap(); + + let found = enumerate_installed_packages(root); + assert!(found.contains("direct"), "{found:?}"); + assert!(found.contains("hoisted-core"), "{found:?}"); + assert!(found.contains("@scope/deep"), "{found:?}"); +} diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index 8d867151a28..c623ffb7f2e 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -808,6 +808,11 @@ pub struct CompilationContext { /// automatic wasm imports also register bytes there but must still lower /// their executable adapter on a later metadata pass. pub file_loader_asset_paths: HashSet, + /// Canonical paths a static import read with `{ type: "text" }`. Such a + /// file becomes a text module (default export = its contents) whatever + /// its extension, as with Bun (`import s from "./Dockerfile" with + /// { type: "text" }`). + pub text_loader_asset_paths: HashSet, /// Stable `$perryfs` names chosen for generated asset-module members. /// These are keyed by canonical source path so the ordinary file-loader /// collection path can reuse them without knowing how the import arose. @@ -856,6 +861,10 @@ pub struct CompilationContext { pub compile_package_dirs: BTreeSet, /// Compile package roots already checked for unsupported Node native addon markers. pub checked_compile_package_native_addon_roots: HashSet, + /// Canonical roots of `perry.nativeAddons` packages the walk entered. A + /// platform payload is approved through the wrapper copy that loads it, + /// which an isolated store (pnpm) keeps outside the payload's ancestors. + pub native_addon_wrapper_roots: BTreeSet, /// #1680 (Phase 2 of #1677): build-time codegen steps declared in the /// host `package.json` `perry.codegen`. Each is a shell command run /// (in `codegen_dir`) before module collection, so a codegen library @@ -1406,6 +1415,7 @@ impl CompilationContext { embedded_assets: Vec::new(), bunfs_root: None, file_loader_asset_paths: HashSet::new(), + text_loader_asset_paths: HashSet::new(), file_loader_asset_names: HashMap::new(), generated_asset_modules: BTreeMap::new(), module_aliases: BTreeMap::new(), @@ -1418,6 +1428,7 @@ impl CompilationContext { app_metadata: perry_codegen::AppMetadata::default(), compile_package_dirs: BTreeSet::new(), checked_compile_package_native_addon_roots: HashSet::new(), + native_addon_wrapper_roots: BTreeSet::new(), codegen_steps: Vec::new(), codegen_dir: None, type_checker: None, diff --git a/crates/perry/tests/issue_5223_text_asset_imports.rs b/crates/perry/tests/issue_5223_text_asset_imports.rs index bcab45796be..7b62bd393f2 100644 --- a/crates/perry/tests/issue_5223_text_asset_imports.rs +++ b/crates/perry/tests/issue_5223_text_asset_imports.rs @@ -4,9 +4,9 @@ //! Fix: a recognized text-asset extension (`.txt`, `.sql`, `.md`, `.html`, //! `.htm`, `.css`, `.graphql`, `.gql`, `.glsl`, `.vert`, `.frag`) is read //! verbatim and synthesized into a native module whose default export is the -//! file contents as a JS string — mirroring the existing JSON-module path. The -//! trigger is the extension; an explicit `with { type: "text" }` attribute is -//! parsed and tolerated but is not (yet) itself the trigger. `.wasm` is out of +//! file contents as a JS string — mirroring the existing JSON-module path. A +//! static import with `with { type: "text" }` selects the same loader for any +//! file, extensionless or source alike (Bun's text loader). `.wasm` is out of //! scope. use std::path::PathBuf; @@ -146,6 +146,34 @@ process.stdout.write(note.trim()); assert_eq!(stdout, "attribute-form works"); } +#[test] +fn type_text_attribute_reads_any_file_as_text() { + // The attribute alone selects the text loader: an extensionless file and a + // TypeScript file are both read verbatim instead of being parsed. + let dir = tempfile::tempdir().expect("tempdir"); + let dockerfile = "FROM postgres:17\nRUN echo \"hi\"\n"; + let snippet = "export const x: number = 1;\n"; + std::fs::write(dir.path().join("Dockerfile"), dockerfile).expect("write Dockerfile"); + std::fs::write(dir.path().join("snippet.ts"), snippet).expect("write snippet"); + std::fs::write( + dir.path().join("main.ts"), + r#" +import dockerfile from "./Dockerfile" with { type: "text" }; +import snippet from "./snippet.ts" with { type: "text" }; +process.stdout.write(JSON.stringify(dockerfile) + "\n" + JSON.stringify(snippet) + "\n"); +"#, + ) + .expect("write main"); + + let stdout = compile_and_run(dir.path()); + let expected = format!( + "{}\n{}\n", + serde_json::to_string(dockerfile).unwrap(), + serde_json::to_string(snippet).unwrap() + ); + assert_eq!(stdout, expected); +} + #[test] fn json_import_still_works() { // No-regression: JSON-module imports continue to parse to a value. From 8178b4ad152a649c0698bf6d4a19165a1225408e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 29 Oct 2026 21:47:33 +0100 Subject: [PATCH 02/84] feat(compile): named imports of CommonJS modules with opaque exports A CommonJS module whose `module.exports = ...` value its source does not spell out (a UMD `factory(...)`, an identifier, a `require`) exports every name the program's static importers ask of it, as a snapshot of that `module.exports` property: Bun and bundlers bind these imports, Node rejects them. Perry already resolves packages' `module` entries the way bundlers do, and those entries are written for this interop (`@libpg-query/parser/wasm/index.js` imports `pg_query` from its UMD `proto.js`). The names are a program-wide fact: after the first walk, the named-export check's edges give the names missing on opaque targets (`CompilationContext::cjs_demanded_exports`), and the existing second walk wraps those modules with them (`export const X = _cjs.X`, the binding the wrap already emits for the names it reads). Only modules whose demand changed are prepared again. Modules whose exports are fully visible (`exports.X = ...`, `module.exports = { ... }`) keep the static error. --- .../perry/src/commands/compile/bootstrap.rs | 6 + .../compile/bootstrap/named_exports.rs | 98 ++++++---- .../compile/cjs_wrap/extract_exports.rs | 18 ++ .../src/commands/compile/cjs_wrap/mod.rs | 1 + .../compile/cjs_wrap/native_addon_tests.rs | 3 + .../compile/cjs_wrap/opaque_exports_tests.rs | 52 ++++++ .../src/commands/compile/cjs_wrap/wrap.rs | 30 ++++ .../compile/collect_modules/source_prep.rs | 27 ++- .../compile/program_facts/outlined_tests.rs | 1 + crates/perry/src/commands/compile/types.rs | 8 + .../perry/tests/cjs_opaque_named_imports.rs | 167 ++++++++++++++++++ 11 files changed, 376 insertions(+), 35 deletions(-) create mode 100644 crates/perry/src/commands/compile/cjs_wrap/opaque_exports_tests.rs create mode 100644 crates/perry/tests/cjs_opaque_named_imports.rs diff --git a/crates/perry/src/commands/compile/bootstrap.rs b/crates/perry/src/commands/compile/bootstrap.rs index fa307af78e9..a483195d3ac 100644 --- a/crates/perry/src/commands/compile/bootstrap.rs +++ b/crates/perry/src/commands/compile/bootstrap.rs @@ -263,10 +263,16 @@ pub(super) fn rerun_collect_with_class_field_types( // were lowered. The set is kept (it is already the program-wide union), so // the re-walk lowers every module against the final set and cannot grow it. let patched_builtins_rerun = ctx.patched_builtins_grew_after_lower; + // A named import of an opaque CommonJS module is exported by the module's + // wrap, which the first walk prepared before every importer was known. + let demand = named_exports::opaque_cjs_demand(ctx); + let demand_rerun = demand != ctx.cjs_demanded_exports; + ctx.cjs_demanded_exports = demand; if field_map.is_empty() && accessor_map.is_empty() && !ctx.solid_client_recollect && !patched_builtins_rerun + && !demand_rerun { return Ok(()); } diff --git a/crates/perry/src/commands/compile/bootstrap/named_exports.rs b/crates/perry/src/commands/compile/bootstrap/named_exports.rs index db4d0b7e40d..b24bd268047 100644 --- a/crates/perry/src/commands/compile/bootstrap/named_exports.rs +++ b/crates/perry/src/commands/compile/bootstrap/named_exports.rs @@ -1,10 +1,11 @@ +use super::super::cjs_wrap::{is_commonjs, module_exports_is_opaque}; use super::CompilationContext; use anyhow::{bail, Result}; use perry_hir::walker::walk_expr_children; use perry_hir::{ Class, Decorator, Export, Expr, Function, ImportSpecifier, Module, ModuleKind, Stmt, }; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::path::{Path, PathBuf}; #[derive(Clone, Copy, PartialEq, Eq)] @@ -204,6 +205,68 @@ fn package_name(source: &str) -> &str { /// available, instead of inventing a perry_fn symbol that fails in the linker. pub(super) fn enforce(ctx: &mut CompilationContext) -> Result<()> { enforce_resolved_bare_imports(ctx)?; + for (importer, source, target, name, local) in static_named_edges(ctx) { + if availability(ctx, Path::new(&target), &name, &mut HashSet::new()) == Availability::Absent + { + // Ambient native-library declarations have no executable HIR export. + // The later import router binds manifest symbols (including ergonomic + // aliases), rejects ambiguous aliases, and prefers real TS wrappers. + // Let that router validate only names actually backed by this import's + // manifest; unrelated missing exports must still fail here. + let manifest_backed = ctx.native_libraries.iter().any(|library| { + library.module == source + && library.functions.iter().any(|function| { + function.name == name + || super::super::resolve::ergonomic_export_alias( + &library.module, + &function.name, + ) + .as_deref() + == Some(name.as_str()) + }) + }); + if manifest_backed { + continue; + } + bail!( + "The requested module '{}' does not provide an export named '{}' \ + (imported as '{}' in {}). Resolved module: {}. \ + For CommonJS properties not exposed as named exports, use a default import and read the property from it.", + source, name, local, importer.display(), target + ); + } + } + Ok(()) +} + +/// The program-wide names importers ask of a CommonJS module whose +/// `module.exports` value its source does not spell out, and that the +/// module does not otherwise export (see +/// `CompilationContext::cjs_demanded_exports`). Grows the current set. +pub(super) fn opaque_cjs_demand( + ctx: &mut CompilationContext, +) -> BTreeMap> { + let mut demand = ctx.cjs_demanded_exports.clone(); + let mut opaque: HashMap = HashMap::new(); + for (_, _, target, name, _) in static_named_edges(ctx) { + let target = PathBuf::from(target); + if availability(ctx, &target, &name, &mut HashSet::new()) != Availability::Absent { + continue; + } + let is_opaque = *opaque.entry(target.clone()).or_insert_with(|| { + std::fs::read_to_string(&target) + .is_ok_and(|source| is_commonjs(&source) && module_exports_is_opaque(&source)) + }); + if is_opaque { + demand.entry(target).or_default().insert(name); + } + } + demand +} + +/// Every value-bearing static named import between compiled modules, sorted: +/// `(importer, specifier, resolved target, imported name, local name)`. +fn static_named_edges(ctx: &CompilationContext) -> Vec<(PathBuf, String, String, String, String)> { let mut edges = Vec::new(); let mut value_refs: HashMap> = HashMap::new(); for (importer, module) in &ctx.native_modules { @@ -242,38 +305,7 @@ pub(super) fn enforce(ctx: &mut CompilationContext) -> Result<()> { } } edges.sort(); - for (importer, source, target, name, local) in edges { - if availability(ctx, Path::new(&target), &name, &mut HashSet::new()) == Availability::Absent - { - // Ambient native-library declarations have no executable HIR export. - // The later import router binds manifest symbols (including ergonomic - // aliases), rejects ambiguous aliases, and prefers real TS wrappers. - // Let that router validate only names actually backed by this import's - // manifest; unrelated missing exports must still fail here. - let manifest_backed = ctx.native_libraries.iter().any(|library| { - library.module == source - && library.functions.iter().any(|function| { - function.name == name - || super::super::resolve::ergonomic_export_alias( - &library.module, - &function.name, - ) - .as_deref() - == Some(name.as_str()) - }) - }); - if manifest_backed { - continue; - } - bail!( - "The requested module '{}' does not provide an export named '{}' \ - (imported as '{}' in {}). Resolved module: {}. \ - For CommonJS properties not exposed as named exports, use a default import and read the property from it.", - source, name, local, importer.display(), target - ); - } - } - Ok(()) + edges } /// Names that `module`'s executable code reads as runtime bindings: imported diff --git a/crates/perry/src/commands/compile/cjs_wrap/extract_exports.rs b/crates/perry/src/commands/compile/cjs_wrap/extract_exports.rs index 6bc9bbf9898..b7e7d9d006b 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/extract_exports.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/extract_exports.rs @@ -34,6 +34,24 @@ pub fn extract_single_module_exports_assignment(source: &str) -> Option found } +/// True when the module's export names cannot be read from its source: some +/// `module.exports = …` assignment's value is not an object literal (a call +/// such as a UMD `factory(…)`, an identifier, a `require`, …). Only `exports.X +/// = …` writes and `module.exports = { … }` literals list them completely. +/// The names an importer asks of such a module are then exported from its +/// `module.exports` object (see `CompilationContext::cjs_demanded_exports`). +pub fn module_exports_is_opaque(source: &str) -> bool { + // The first character of each assigned value; `==`/`===` comparisons + // fail the `[^=\s]` after the single `=`. + let Ok(re) = perry_perex::tooling::Regex::new( + r#"\bmodule(?:\.exports|\[\s*'exports'\s*\]|\[\s*"exports"\s*\])\s*=\s*([^=\s])"#, + ) else { + return false; + }; + re.captures_iter(source) + .any(|cap| cap.get(1).is_some_and(|first| first.as_str() != "{")) +} + /// Return the set of specs `SPEC` for which this module is a *trivial /// re-export wrapper* — i.e. it contains a `module.exports = require('SPEC')` /// (or bare `exports = require('SPEC')`) assignment, optionally inside a diff --git a/crates/perry/src/commands/compile/cjs_wrap/mod.rs b/crates/perry/src/commands/compile/cjs_wrap/mod.rs index ed841c7b53c..2c3b7200b4c 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/mod.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/mod.rs @@ -72,6 +72,7 @@ mod static_edge_tests; // Cross-sibling helpers — siblings reach for these via `use super::*;`. use deferred_requires::deferred_require_specs_for_path; use detect::is_js_reserved_word; +pub(super) use extract_exports::module_exports_is_opaque; use extract_exports::{ extract_exports_from_source, extract_named_exports_from_require, extract_object_literal_exports_from_require, extract_single_module_exports_assignment, diff --git a/crates/perry/src/commands/compile/cjs_wrap/native_addon_tests.rs b/crates/perry/src/commands/compile/cjs_wrap/native_addon_tests.rs index 11b2b5b4923..217fb821da9 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/native_addon_tests.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/native_addon_tests.rs @@ -19,6 +19,7 @@ fn project_addon_require_and_resolve_share_the_authorized_id() { false, None, Some(&paths), + None, &mut Vec::new(), None, ); @@ -52,6 +53,7 @@ fn exact_project_mapping_wins_over_nested_package_name() { false, None, Some(&paths), + None, &mut Vec::new(), None, ); @@ -76,6 +78,7 @@ fn package_addon_keeps_its_package_logical_id() { false, None, Some(&BTreeMap::new()), + None, &mut Vec::new(), None, ); diff --git a/crates/perry/src/commands/compile/cjs_wrap/opaque_exports_tests.rs b/crates/perry/src/commands/compile/cjs_wrap/opaque_exports_tests.rs new file mode 100644 index 00000000000..f80f274a6e1 --- /dev/null +++ b/crates/perry/src/commands/compile/cjs_wrap/opaque_exports_tests.rs @@ -0,0 +1,52 @@ +use super::*; +use std::collections::BTreeSet; + +#[test] +fn module_exports_opacity_follows_the_assigned_value() { + // UMD, call results, identifiers and re-export wrappers hide the names. + assert!(module_exports_is_opaque( + "(function (g, f) {\n if (typeof module === 'object' && module.exports)\n module.exports = f(require('x'));\n})(this, function () {});" + )); + assert!(module_exports_is_opaque( + "function pino() {}\nmodule.exports = pino;" + )); + assert!(module_exports_is_opaque( + "module['exports'] = require('./impl');" + )); + // Property writes and object literals list them completely. + assert!(!module_exports_is_opaque( + "exports.a = 1;\nmodule.exports.b = 2;" + )); + assert!(!module_exports_is_opaque("module.exports = {\n a: 1,\n};")); + assert!(!module_exports_is_opaque( + "if (module.exports === undefined) {}" + )); +} + +#[test] +fn demanded_names_are_exported_as_module_exports_snapshots() { + let source = "module.exports = factory();\nfunction factory() { return { pg_query: 1 }; }"; + let demanded = BTreeSet::from([ + "pg_query".to_string(), + "class".to_string(), + "a-b".to_string(), + ]); + let (wrapped, _) = wrap_commonjs_with_addon_paths( + source, + Path::new("/fixture/proto.js"), + None, + false, + None, + None, + Some(&demanded), + &mut Vec::new(), + None, + ); + assert!( + wrapped.contains("export const pg_query = _cjs.pg_query;"), + "{wrapped}" + ); + // Names no `export const` can bind stay unexported (the import still fails). + assert!(!wrapped.contains("_cjs.class"), "{wrapped}"); + assert!(!wrapped.contains("a-b ="), "{wrapped}"); +} diff --git a/crates/perry/src/commands/compile/cjs_wrap/wrap.rs b/crates/perry/src/commands/compile/cjs_wrap/wrap.rs index dda813db7ed..7b04830803f 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/wrap.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/wrap.rs @@ -270,6 +270,7 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( is_entry_module, compile_packages, None, + None, &mut Vec::new(), None, ) @@ -284,6 +285,7 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_addon_paths( is_entry_module: bool, compile_packages: Option<&HashSet>, native_addon_paths: Option<&std::collections::BTreeMap>, + demanded_exports: Option<&std::collections::BTreeSet>, messages: &mut Vec, computed_require_warnings: Option<&mut Vec>, ) -> (String, Option) { @@ -1253,6 +1255,21 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_addon_paths( .map(|(n, _)| n.clone()) .collect(); + // Names importers ask of a module whose `module.exports` value the source + // does not spell out (`CompilationContext::cjs_demanded_exports`): each + // is a snapshot of that property once the body has run, as for the + // names read from the source. + for name in demanded_exports.into_iter().flatten() { + if !is_js_reserved_word(name) + && is_identifier_name(name) + && !named_exports.contains(name) + && !hoisted_class_names.contains(name) + && !named_reexport_names.contains(name) + { + named_exports.push(name.clone()); + } + } + let named_export_decls = named_exports .iter() .filter(|n| !hoisted_class_names.contains(n)) @@ -1966,3 +1983,16 @@ mod cache_invalidation_tests; #[cfg(test)] #[path = "native_addon_tests.rs"] mod native_addon_tests; + +#[cfg(test)] +#[path = "opaque_exports_tests.rs"] +mod opaque_exports_tests; + +/// An ASCII JavaScript identifier, usable as `export const `. +fn is_identifier_name(name: &str) -> bool { + let mut chars = name.chars(); + chars + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '_' || c == '$') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') +} diff --git a/crates/perry/src/commands/compile/collect_modules/source_prep.rs b/crates/perry/src/commands/compile/collect_modules/source_prep.rs index 6611be470f2..529480dbdd8 100644 --- a/crates/perry/src/commands/compile/collect_modules/source_prep.rs +++ b/crates/perry/src/commands/compile/collect_modules/source_prep.rs @@ -130,6 +130,7 @@ pub(super) struct PrepInputs<'a> { pub(super) native_addon_paths: &'a BTreeMap, pub(super) bunfs_root: Option<&'a Path>, pub(super) debug_symbols: bool, + pub(super) cjs_demanded_exports: &'a BTreeMap>, } impl<'a> PrepInputs<'a> { @@ -140,6 +141,7 @@ impl<'a> PrepInputs<'a> { native_addon_paths: &ctx.native_addon_paths, bunfs_root: ctx.bunfs_root.as_deref(), debug_symbols: ctx.debug_symbols, + cjs_demanded_exports: &ctx.cjs_demanded_exports, } } } @@ -152,6 +154,7 @@ struct PrepConfig { compile_packages: HashSet, native_addon_paths: BTreeMap, debug_symbols: bool, + cjs_demanded_exports: BTreeMap>, } impl PrepConfig { @@ -164,9 +167,19 @@ impl PrepConfig { compile_packages: ctx.compile_packages.clone(), native_addon_paths: ctx.native_addon_paths.clone(), debug_symbols: ctx.debug_symbols, + cjs_demanded_exports: ctx.cjs_demanded_exports.clone(), }) } + /// Same settings except, possibly, the demanded CommonJS exports, which + /// change only the modules they name. + fn same_except_demand(&self, other: &Self) -> bool { + self.target == other.target + && self.compile_packages == other.compile_packages + && self.native_addon_paths == other.native_addon_paths + && self.debug_symbols == other.debug_symbols + } + fn inputs(&self) -> PrepInputs<'_> { PrepInputs { target: self.target.as_deref(), @@ -174,6 +187,7 @@ impl PrepConfig { native_addon_paths: &self.native_addon_paths, bunfs_root: None, debug_symbols: self.debug_symbols, + cjs_demanded_exports: &self.cjs_demanded_exports, } } } @@ -294,6 +308,7 @@ pub(super) fn prepare_from_raw( class.is_entry, Some(inputs.compile_packages), Some(inputs.native_addon_paths), + inputs.cjs_demanded_exports.get(canonical), &mut messages, Some(&mut computed_require_warnings), ); @@ -680,9 +695,17 @@ impl WalkSources<'_> { } WalkSources::Reuse(sources) => { let config = PrepConfig::of(ctx, target); - if config.is_none() || sources.config != config { - sources.modules.clear(); + match (&config, &sources.config) { + (Some(new), Some(old)) if new.same_except_demand(old) => { + // Re-prepare only the modules whose demanded exports + // changed; every other kept source still applies. + sources.modules.retain(|path, _| { + new.cjs_demanded_exports.get(path) == old.cjs_demanded_exports.get(path) + }); + } + _ => sources.modules.clear(), } + sources.config = config; } } } diff --git a/crates/perry/src/commands/compile/program_facts/outlined_tests.rs b/crates/perry/src/commands/compile/program_facts/outlined_tests.rs index 2ccd3cec1e4..ca886de6dd0 100644 --- a/crates/perry/src/commands/compile/program_facts/outlined_tests.rs +++ b/crates/perry/src/commands/compile/program_facts/outlined_tests.rs @@ -17,6 +17,7 @@ fn cjs(path: &str, source: &str) -> String { false, None, None, + None, &mut Vec::new(), None, ) diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index c623ffb7f2e..8c216bfd1b0 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -865,6 +865,13 @@ pub struct CompilationContext { /// platform payload is approved through the wrapper copy that loads it, /// which an isolated store (pnpm) keeps outside the payload's ancestors. pub native_addon_wrapper_roots: BTreeSet, + /// Names importers ask of a CommonJS module whose `module.exports` is + /// assigned a value its source does not spell out (a UMD `factory(…)`, + /// an identifier, …), keyed by the module's canonical path. The wrap + /// exports each as a snapshot of the `module.exports` property, as Bun + /// and bundlers bind them. A program-wide fact: found after the first + /// walk, applied by the second. + pub cjs_demanded_exports: BTreeMap>, /// #1680 (Phase 2 of #1677): build-time codegen steps declared in the /// host `package.json` `perry.codegen`. Each is a shell command run /// (in `codegen_dir`) before module collection, so a codegen library @@ -1429,6 +1436,7 @@ impl CompilationContext { compile_package_dirs: BTreeSet::new(), checked_compile_package_native_addon_roots: HashSet::new(), native_addon_wrapper_roots: BTreeSet::new(), + cjs_demanded_exports: BTreeMap::new(), codegen_steps: Vec::new(), codegen_dir: None, type_checker: None, diff --git a/crates/perry/tests/cjs_opaque_named_imports.rs b/crates/perry/tests/cjs_opaque_named_imports.rs new file mode 100644 index 00000000000..f414bdb05d1 --- /dev/null +++ b/crates/perry/tests/cjs_opaque_named_imports.rs @@ -0,0 +1,167 @@ +//! A named import of a CommonJS module whose `module.exports` value its +//! source does not spell out (UMD `factory(...)`, a function with properties) +//! binds that property of `module.exports`, as Bun and bundlers do: the +//! module's wrap exports every name the program's importers ask of it. A +//! module whose names are all visible (`exports.X = ...`) keeps the static +//! "does not provide an export named" error. + +use std::path::PathBuf; +use std::process::Command; +use std::sync::Once; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("canonicalize workspace root") +} + +fn target_debug_dir() -> PathBuf { + std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| workspace_root().join("target")) + .join("debug") +} + +/// Build `libperry_runtime.a` once so the compiled binaries can link. The CI +/// `cargo-test` job doesn't pre-build the runtime staticlib, and these tests +/// link real executables, so they'd otherwise fail with "Could not find +/// libperry_runtime.a" (mirrors module_import_forms.rs). +fn ensure_runtime_archive() { + static BUILD_RUNTIME: Once = Once::new(); + BUILD_RUNTIME.call_once(|| { + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let build = Command::new(cargo) + .current_dir(workspace_root()) + .arg("build") + .arg("-p") + .arg("perry-runtime") + .output() + .expect("run cargo build -p perry-runtime"); + assert!( + build.status.success(), + "cargo build -p perry-runtime failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&build.stdout), + String::from_utf8_lossy(&build.stderr) + ); + }); +} + +fn runtime_dir() -> PathBuf { + ensure_runtime_archive(); + target_debug_dir() +} + +/// Compile `main.ts` in `dir` and return its stdout. Asset files must already +/// be written into `dir`. +fn compile_and_run(dir: &std::path::Path) -> String { + let entry = dir.join("main.ts"); + let output = dir.join("main_bin"); + + let mut compile = Command::new(perry_bin()); + compile + .current_dir(dir) + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .env("PERRY_RUNTIME_DIR", runtime_dir()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output); + let compile = compile.output().expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output) + .current_dir(dir) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +fn compile(dir: &std::path::Path) -> std::process::Output { + Command::new(perry_bin()) + .current_dir(dir) + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .env("PERRY_RUNTIME_DIR", runtime_dir()) + .arg("compile") + .arg(dir.join("main.ts")) + .arg("-o") + .arg(dir.join("main_bin")) + .output() + .expect("run perry compile") +} + +#[test] +fn named_imports_of_opaque_commonjs_bind_module_exports_properties() { + let dir = tempfile::tempdir().expect("tempdir"); + // The `@libpg-query/parser` shape: an ES module importing a name from a + // UMD protobuf bundle. + std::fs::write( + dir.path().join("proto.js"), + r#"(function (global, factory) { + if (typeof module === "object" && module && module.exports) + module.exports = factory(); +})(this, function () { + var $root = {}; + $root.pg_query = { kind: "namespace" }; + return $root; +}); +"#, + ) + .expect("write proto"); + std::fs::write( + dir.path().join("pino.js"), + "function pino() { return 'logger'; }\npino.levels = { info: 30 };\nmodule.exports = pino;\n", + ) + .expect("write pino"); + std::fs::write( + dir.path().join("parser.mjs"), + "import { pg_query } from './proto.js';\nexport const kind = pg_query.kind;\n", + ) + .expect("write parser"); + std::fs::write( + dir.path().join("main.ts"), + r#"import { kind } from "./parser.mjs"; +import { levels } from "./pino.js"; +console.log(kind + " " + levels.info); +"#, + ) + .expect("write main"); + + assert_eq!(compile_and_run(dir.path()), "namespace 30\n"); +} + +#[test] +fn visible_commonjs_exports_still_reject_absent_names() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("lib.js"), "exports.present = 1;\n").expect("write lib"); + std::fs::write( + dir.path().join("main.ts"), + "import { absent } from \"./lib.js\";\nconsole.log(absent);\n", + ) + .expect("write main"); + + let output = compile(dir.path()); + assert!(!output.status.success(), "compile must fail"); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("does not provide an export named 'absent'"), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} From 942eddace537682c9fb3fb18821066cae2890804 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 30 Oct 2026 00:31:06 +0100 Subject: [PATCH 03/84] fix(codegen): long image-literal key names; inline alias-scope metadata in the native reader Both failed oxfmt's bundled parsers in the Supabase CLI compile. - A layout key list named a key's image string record by spelling the atom's hex, but the record names strings over 256 bytes by length and SHA-256: a long key (a TypeScript diagnostic message used as an object key) referenced an undefined `@perry_image_literal_0_...`. The key list now uses the record's own rule (`string_pool::image_record_symbol_of`). - The native IR reader took only `!{!"NAME"}` metadata operands. The indexed-owner loop declares its alias scopes inline: a nested scope list in `llvm.experimental.noalias.scope.decl` and `!alias.scope`/`!noalias` on its loads and stores. The reader parses inline metadata (quote-aware, LLVM `\XX` escapes) and keeps the two attachments, which it dropped. --- .../perry-codegen/src/codegen/realm_image.rs | 44 +++++++- .../perry-codegen/src/codegen/string_pool.rs | 5 + .../src/codegen/string_pool/image.rs | 20 ++-- crates/perry-codegen/src/dialect/mod.rs | 29 +++-- crates/perry-codegen/src/dialect/tests.rs | 51 +++++++++ crates/perry-codegen/src/dialect/text.rs | 105 ++++++++++++++++++ 6 files changed, 232 insertions(+), 22 deletions(-) diff --git a/crates/perry-codegen/src/codegen/realm_image.rs b/crates/perry-codegen/src/codegen/realm_image.rs index 459e7cb0b09..3324290ed04 100644 --- a/crates/perry-codegen/src/codegen/realm_image.rs +++ b/crates/perry-codegen/src/codegen/realm_image.rs @@ -419,7 +419,15 @@ pub(crate) fn emit_layout_key_list( if hex.is_empty() || hex.len() % 2 != 0 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { return "null".into(); } - slots.push(Slot::ImageString(format!("perry_image_literal_0_{hex}"))); + // The record's own name rule: a long key is named by its digest, + // not spelled (`string_pool::image_record_symbol_of`). + let bytes: Vec = (0..hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("hex checked above")) + .collect(); + slots.push(Slot::ImageString( + super::string_pool::image_record_symbol_of(&bytes, false), + )); } let entries = keys .iter() @@ -672,6 +680,40 @@ mod tests { module.to_ir() } + /// A key longer than the spelled-name limit names its image string + /// record by digest, as the record's definition does. Spelling it from + /// the atom's hex referenced an undefined symbol (`LLVM IR parse error: + /// use of undefined value @perry_image_literal_0_4543...`, a TypeScript + /// diagnostic message used as an object key in oxfmt's typescript + /// bundle). + #[test] + fn a_long_key_names_its_record_by_digest() { + let long = "ECMAScript_imports_and_exports_cannot_be_written_".repeat(6); + let hex: String = long.bytes().map(|b| format!("{b:02x}")).collect(); + let short_hex: String = "length".bytes().map(|b| format!("{b:02x}")).collect(); + let keys = vec![ + (format!("perry_atom_{short_hex}"), 0, 0, 0), + (format!("perry_atom_{hex}"), 0, 0, 1), + ]; + let mut module = LlModule::new("aarch64-apple-darwin"); + emit_layout_key_list(&mut module, "keys", &keys); + let ir = module.to_ir(); + let record = super::super::string_pool::image_record_symbol_of(long.as_bytes(), false); + assert!( + record.starts_with(&format!("perry_image_literal_0_s{}_", long.len())), + "{record}" + ); + assert!(ir.contains(&format!("@{record}")), "{ir}"); + assert!( + !ir.contains(&format!("perry_image_literal_0_{hex}")), + "{ir}" + ); + assert!( + ir.contains(&format!("@perry_image_literal_0_{short_hex}")), + "{ir}" + ); + } + #[test] fn captured_functions_keep_props_flags_and_relocations_on_every_target() { for triple in [ diff --git a/crates/perry-codegen/src/codegen/string_pool.rs b/crates/perry-codegen/src/codegen/string_pool.rs index d806049d5b6..b6ab3292119 100644 --- a/crates/perry-codegen/src/codegen/string_pool.rs +++ b/crates/perry-codegen/src/codegen/string_pool.rs @@ -26,6 +26,11 @@ pub(super) fn image_pointer(entry: &crate::strings::StringEntry) -> String { pub(crate) fn image_record_symbol(entry: &crate::strings::StringEntry) -> String { image::name(entry) } +/// The image string record symbol of a string with these bytes (the same +/// rule as `image_record_symbol`: long strings are named by digest). +pub(crate) fn image_record_symbol_of(bytes: &[u8], is_wtf8: bool) -> String { + image::name_of(bytes, is_wtf8) +} /// Define `entry`'s image string record in `llmod` (WRT step 2): a unit that /// emits no pool, such as the static seed unit, still defines the coalesced /// record its atoms and key lists name. diff --git a/crates/perry-codegen/src/codegen/string_pool/image.rs b/crates/perry-codegen/src/codegen/string_pool/image.rs index b26dff099a7..a3679e32a58 100644 --- a/crates/perry-codegen/src/codegen/string_pool/image.rs +++ b/crates/perry-codegen/src/codegen/string_pool/image.rs @@ -17,18 +17,20 @@ const IMAGE_GC_FLAGS: u8 = 0x15; const STRING_IMAGE_MARKER: u16 = 0x494d; pub(super) fn name(entry: &StringEntry) -> String { - let spelling: String = if entry.bytes.len() <= SPELLED_NAME_MAX_BYTES { - entry.bytes.iter().map(|b| format!("{b:02x}")).collect() + name_of(&entry.bytes, entry.is_wtf8) +} + +/// The record symbol of a string with these bytes: spelled in hex up to +/// `SPELLED_NAME_MAX_BYTES`, else named by length and SHA-256. +pub(super) fn name_of(bytes: &[u8], is_wtf8: bool) -> String { + let spelling: String = if bytes.len() <= SPELLED_NAME_MAX_BYTES { + bytes.iter().map(|b| format!("{b:02x}")).collect() } else { - let digest = Sha256::digest(&entry.bytes); + let digest = Sha256::digest(bytes); let hash: String = digest.iter().map(|b| format!("{b:02x}")).collect(); - format!("s{}_{}", entry.bytes.len(), hash) + format!("s{}_{}", bytes.len(), hash) }; - format!( - "perry_image_literal_{}_{}", - u8::from(entry.is_wtf8), - spelling - ) + format!("perry_image_literal_{}_{}", u8::from(is_wtf8), spelling) } pub(super) fn pointer(entry: &StringEntry) -> String { diff --git a/crates/perry-codegen/src/dialect/mod.rs b/crates/perry-codegen/src/dialect/mod.rs index af26fe03e7f..853c416e28a 100644 --- a/crates/perry-codegen/src/dialect/mod.rs +++ b/crates/perry-codegen/src/dialect/mod.rs @@ -42,8 +42,9 @@ pub(crate) const LLVM_CC_PRESERVE_NONE: u32 = 21; pub(crate) const LLVM_CC_PRESERVE_MOST: u32 = 14; use text::{ - add_enum_attr, apply_flags, be, float_pred, int_pred, rauw, rmatch_paren, set_alignment_if_any, - set_invariant_load, split_top_level, take_quoted, unquote, + add_enum_attr, apply_flags, be, float_pred, inline_metadata, int_pred, rauw, rmatch_paren, + set_alignment_if_any, set_inline_attachment, set_invariant_load, split_top_level, take_quoted, + unquote, }; use types::{basic_type, constant, fn_type_of, indirect_fn_type, strip_label, ty_and_val}; #[cfg(test)] @@ -636,6 +637,8 @@ impl<'ctx, 'm> FnReader<'ctx, 'm> { let _ = inst.set_volatile(true); } set_alignment_if_any(Some(inst), rest); + set_inline_attachment(self.ctx, inst, rest, "alias.scope")?; + set_inline_attachment(self.ctx, inst, rest, "noalias")?; Ok(()) } "br" => { @@ -712,6 +715,10 @@ impl<'ctx, 'm> FnReader<'ctx, 'm> { set_invariant_load(self.ctx, i); } } + if let Some(i) = v.as_instruction_value() { + set_inline_attachment(self.ctx, i, rest, "alias.scope")?; + set_inline_attachment(self.ctx, i, rest, "noalias")?; + } if atomic { let ord = match ordering { Some("seq_cst") => { @@ -879,16 +886,14 @@ impl<'ctx, 'm> FnReader<'ctx, 'm> { for a in split_top_level(args_str) { let (aty, atok) = ty_and_val(&a)?; if aty == "metadata" { - // `metadata !{!"NAME"}`: the one metadata operand Perry - // emits, `llvm.read_register`'s register name - // (`expr/stack_guard.rs`). - let name = atok - .strip_prefix("!{!\"") - .and_then(|t| t.strip_suffix("\"}")) - .ok_or_else(|| anyhow!("unsupported metadata operand `{atok}`"))?; - let node = self - .ctx - .metadata_node(&[self.ctx.metadata_string(name).into()]); + // Inline metadata operands: `llvm.read_register`'s register + // name (`!{!"NAME"}`, `expr/stack_guard.rs`) and an + // indexed-owner loop's alias scope list + // (`llvm.experimental.noalias.scope.decl`). + let (node, used) = inline_metadata(self.ctx, atok)?; + if !atok[used..].trim().is_empty() { + bail!("trailing text after metadata operand `{atok}`"); + } args.push(node.into()); arg_types.push(self.ctx.metadata_type().into()); continue; diff --git a/crates/perry-codegen/src/dialect/tests.rs b/crates/perry-codegen/src/dialect/tests.rs index b5bfcb3b638..9202e28ae49 100644 --- a/crates/perry-codegen/src/dialect/tests.rs +++ b/crates/perry-codegen/src/dialect/tests.rs @@ -1003,3 +1003,54 @@ fn birth_tables_and_frame_shape_tables_round_trip_through_the_reader() { ); assert!(roundtrip_ir(&worker, "frame_shapes_roundtrip") > 0); } + +/// The indexed-owner loop (`stmt/indexed_owner_loop.rs`) declares its alias +/// scopes inline: a `noalias.scope.decl` call whose operand is a nested +/// scope list, and `!alias.scope`/`!noalias` on its loads and stores. The +/// reader took only `!{!"NAME"}` operands and failed the whole unit +/// ("unsupported metadata operand"), oxfmt's yaml bundle in the Supabase +/// CLI. The domain string here carries `,`, `}` and an escaped quote. +#[test] +fn inline_alias_scope_metadata_round_trips_through_the_reader() { + let domain = r#"indexed.owner.f, {x}\22q.3"#; + let scope = |id: u32| format!("!{{!\"owner.{id}\", !{{!\"{domain}\"}}}}"); + let ir = format!( + r#" +declare void @llvm.experimental.noalias.scope.decl(metadata) + +define double @scoped(ptr %a, ptr %b) {{ +entry: + call void @llvm.experimental.noalias.scope.decl(metadata !{{{s1}}}) + call void @llvm.experimental.noalias.scope.decl(metadata !{{{s2}}}) + %v = load double, ptr %a, align 8, !alias.scope !{{{s1}}}, !noalias !{{{s2}}} + store double %v, ptr %b, align 8, !alias.scope !{{{s2}}}, !noalias !{{{s1}}} + ret double %v +}} +"#, + s1 = scope(1), + s2 = scope(2) + ); + let (skeleton, fns) = split_corpus(&ir); + let ctx = Context::create(); + let module = crate::inprocess::parse_ir_text(&ctx, &skeleton, "scopes").expect("skeleton"); + for f in &fns { + predeclare_function_from_text(&ctx, &module, f).unwrap_or_else(|e| panic!("{e:#}")); + } + for f in &fns { + add_function_from_text(&ctx, &module, f).unwrap_or_else(|e| panic!("{e:#}")); + } + module + .verify() + .unwrap_or_else(|e| panic!("verifier: {}", e.to_string())); + let text = module.print_to_string().to_string(); + assert_eq!( + text.matches("call void @llvm.experimental.noalias.scope.decl(metadata !") + .count(), + 2, + "{text}" + ); + assert_eq!(text.matches("!alias.scope").count(), 2, "{text}"); + assert_eq!(text.matches("!noalias").count(), 2, "{text}"); + assert!(text.contains(r#"!"owner.1""#), "{text}"); + assert!(text.contains(r#"indexed.owner.f, {x}\22q.3"#), "{text}"); +} diff --git a/crates/perry-codegen/src/dialect/text.rs b/crates/perry-codegen/src/dialect/text.rs index e7ef9b2e061..e33ea957c69 100644 --- a/crates/perry-codegen/src/dialect/text.rs +++ b/crates/perry-codegen/src/dialect/text.rs @@ -200,3 +200,108 @@ pub(super) fn apply_flags(inst: Option>, flags: &[&str]) { unsafe { llvm_sys::core::LLVMSetFastMathFlags(inst.as_value_ref(), fmf) }; } } + +/// Inline metadata as Perry writes it: `!"text"` (LLVM's `\XX` escapes) and +/// `!{a, b, ..}` tuples of those, nested (`!{!{!"owner.1", !{!"domain"}}}`, +/// an alias scope list). Quote-aware: a string may hold `,` or braces. +/// Returns the node and the bytes of `s` it spans. +pub(super) fn inline_metadata<'ctx>( + ctx: &'ctx Context, + s: &str, +) -> Result<(inkwell::values::MetadataValue<'ctx>, usize)> { + fn value<'ctx>( + ctx: &'ctx Context, + s: &[u8], + at: &mut usize, + ) -> Result> { + while *at < s.len() && s[*at].is_ascii_whitespace() { + *at += 1; + } + if s.get(*at) != Some(&b'!') { + bail!("metadata expected at byte {at}"); + } + *at += 1; + match s.get(*at) { + Some(b'"') => { + *at += 1; + let mut bytes = Vec::new(); + loop { + match s.get(*at) { + None => bail!("unterminated metadata string"), + Some(b'"') => break, + Some(b'\\') => { + let hex = s + .get(*at + 1..*at + 3) + .and_then(|h| std::str::from_utf8(h).ok()) + .and_then(|h| u8::from_str_radix(h, 16).ok()) + .ok_or_else(|| anyhow!("bad metadata string escape"))?; + bytes.push(hex); + *at += 3; + } + Some(&b) => { + bytes.push(b); + *at += 1; + } + } + } + *at += 1; + let text = String::from_utf8(bytes) + .map_err(|_| anyhow!("metadata string is not UTF-8"))?; + Ok(ctx.metadata_string(&text)) + } + Some(b'{') => { + *at += 1; + let mut items: Vec> = Vec::new(); + loop { + while *at < s.len() && s[*at].is_ascii_whitespace() { + *at += 1; + } + if s.get(*at) == Some(&b'}') { + *at += 1; + break; + } + items.push(value(ctx, s, at)?.into()); + while *at < s.len() && s[*at].is_ascii_whitespace() { + *at += 1; + } + match s.get(*at) { + Some(b',') => *at += 1, + Some(b'}') => {} + _ => bail!("bad metadata tuple"), + } + } + Ok(ctx.metadata_node(&items)) + } + _ => bail!("unsupported metadata form"), + } + } + let mut at = 0; + let node = value(ctx, s.as_bytes(), &mut at).with_context(|| format!("metadata `{s}`"))?; + Ok((node, at)) +} + +/// Attach the instruction metadata `kind` (`alias.scope`, `noalias`) an +/// instruction's text carries inline (`, !alias.scope !{..}`), as the +/// textual IR would. +pub(super) fn set_inline_attachment( + ctx: &Context, + inst: InstructionValue<'_>, + text: &str, + kind: &str, +) -> Result<()> { + use inkwell::context::AsContextRef; + let marker = format!(", !{kind} "); + let Some(pos) = text.find(&marker) else { + return Ok(()); + }; + let (node, _) = inline_metadata(ctx, &text[pos + marker.len()..])?; + let id = unsafe { + llvm_sys::core::LLVMGetMDKindIDInContext( + ctx.as_ctx_ref(), + kind.as_ptr() as *const std::os::raw::c_char, + kind.len() as u32, + ) + }; + inst.set_metadata(node, id) + .map_err(|e| anyhow!("attach !{kind}: {e}")) +} From fb35b74301cc95e781952ed35b947456547a009f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 30 Oct 2026 00:31:06 +0100 Subject: [PATCH 04/84] fix(resolve): compile a package from its TypeScript sources only when they are self-contained A package's sources stand in for its published JavaScript only when every value import reachable from the sources its manifest's JavaScript targets map to resolves: relatively, to a package installed beside it, or to a built-in. Otherwise the whole package stays on its JavaScript, which is what Node runs. Sources written against their monorepo left imports unbound and the link failed on symbols named after the bindings: `@posthog/core`'s `tsconfig` alias `@/utils`, and `@supabase/supabase-js`'s unpublished `@supabase/tracing` (bundled into its `dist`). Type-only statements are erased and not counted. --- crates/perry/src/commands/compile/resolve.rs | 149 +++++++++++++++++- .../compile/resolve/source_closure_tests.rs | 69 ++++++++ 2 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 crates/perry/src/commands/compile/resolve/source_closure_tests.rs diff --git a/crates/perry/src/commands/compile/resolve.rs b/crates/perry/src/commands/compile/resolve.rs index 04b35da073a..7026d67aefc 100644 --- a/crates/perry/src/commands/compile/resolve.rs +++ b/crates/perry/src/commands/compile/resolve.rs @@ -180,6 +180,8 @@ mod package_enumeration_tests; #[cfg(test)] mod package_instance_tests; #[cfg(test)] +mod source_closure_tests; +#[cfg(test)] mod tests; /// Packages that Perry provides built-in native extensions for. @@ -986,13 +988,158 @@ fn package_entry_requires_cjs_emit(package_dir: &Path) -> bool { .is_some_and(|src| !is_js_file(&src) && is_hybrid_cjs_emit_input(&src)) } +/// A package's TypeScript sources stand in for its published JavaScript +/// only when they are self-contained: every import of every source reachable +/// (by relative imports) from the sources its manifest's JavaScript targets +/// map to resolves, relatively, to a package installed beside it, or to a +/// built-in. Sources written against their monorepo do not run as shipped: +/// `tsconfig` path aliases (`@posthog/core`'s `@/utils`) and unpublished +/// workspace packages the published build bundles (`@supabase/supabase-js`'s +/// `@supabase/tracing`) left those imports unbound and the link failed on +/// symbols named after their bindings. Node runs the JavaScript; so does +/// Perry then, for the whole package (one identity per module, #6585). +/// Type-only statements are erased and not counted. +fn package_sources_self_contained(package_dir: &Path) -> bool { + static CACHE: OnceLock>> = OnceLock::new(); + let Ok(dir) = fs::canonicalize(package_dir) else { + return true; + }; + let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); + if let Some(cached) = cache.lock().expect("source closure cache").get(&dir) { + return *cached; + } + let verdict = sources_resolve(&dir); + cache + .lock() + .expect("source closure cache") + .insert(dir, verdict); + verdict +} + +fn sources_resolve(package_dir: &Path) -> bool { + let Some(manifest) = fs::read_to_string(package_dir.join("package.json")) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + else { + return true; + }; + let mut targets = Vec::new(); + for field in ["main", "module"] { + if let Some(t) = manifest.get(field).and_then(|v| v.as_str()) { + targets.push(t.to_string()); + } + } + fn leaves(v: &serde_json::Value, out: &mut Vec) { + match v { + serde_json::Value::String(s) => out.push(s.clone()), + serde_json::Value::Array(a) => a.iter().for_each(|x| leaves(x, out)), + serde_json::Value::Object(o) => o.values().for_each(|x| leaves(x, out)), + _ => {} + } + } + if let Some(exports) = manifest.get("exports") { + leaves(exports, &mut targets); + } + let mut queue: Vec = targets + .iter() + .filter(|t| !t.contains('*')) + .filter_map(|t| resolve_with_extensions(&package_dir.join(t.trim_start_matches("./")))) + .filter(|entry| is_js_file(entry)) + .filter_map(|entry| original_source_via_map(&entry)) + .collect(); + queue.extend(resolve_with_extensions( + &package_dir.join("src").join("index"), + )); + queue.retain(|source| !is_js_file(source) && !is_declaration_file(source)); + let mut seen: HashSet = queue.iter().cloned().collect(); + while let Some(file) = queue.pop() { + let Ok(text) = fs::read_to_string(&file) else { + continue; + }; + for specifier in value_import_specifiers(&text) { + if is_relative_specifier(&specifier) { + match resolve_relative_import_path(&specifier, &file) { + Some(next) => { + if !is_js_file(&next) + && !is_declaration_file(&next) + && seen.insert(next.clone()) + { + queue.push(next); + } + } + None => return false, + } + continue; + } + let bare = specifier.strip_prefix("node:").unwrap_or(&specifier); + if specifier.starts_with("node:") + || perry_hir::is_node_builtin_module(bare) + || perry_hir::is_bare_prefix_only_builtin(&specifier) + || specifier == "bun" + || specifier.starts_with("bun:") + || specifier.starts_with("perry/") + { + continue; + } + let (package, _) = parse_package_specifier(&specifier); + let installed = file.ancestors().any(|dir| { + package + .split('/') + .fold(dir.join("node_modules"), |path, part| path.join(part)) + .join("package.json") + .is_file() + }); + if !installed { + return false; + } + } + } + true +} + +/// The specifiers of a module's value imports and re-exports (`import .. +/// from`, `export .. from`, `import "x"`, `import("x")`), not its `import +/// type` / `export type` statements. +fn value_import_specifiers(text: &str) -> Vec { + static FROM: OnceLock> = OnceLock::new(); + static BARE: OnceLock> = OnceLock::new(); + let from = FROM.get_or_init(|| { + perry_perex::tooling::Regex::new( + r#"\b(?:import|export)(\s+type\b)?[^'";]*?\bfrom\s*['"]([^'"\n]+)['"]"#, + ) + .ok() + }); + let bare = BARE.get_or_init(|| { + perry_perex::tooling::Regex::new(r#"\bimport\s*\(?\s*['"]([^'"\n]+)['"]"#).ok() + }); + let mut out = Vec::new(); + if let Some(re) = from { + for cap in re.captures_iter(text) { + if cap.get(1).is_none() { + if let Some(m) = cap.get(2) { + out.push(m.as_str().to_string()); + } + } + } + } + if let Some(re) = bare { + for cap in re.captures_iter(text) { + if let Some(m) = cap.get(1) { + out.push(m.as_str().to_string()); + } + } + } + out +} + /// Resolve package entry preferring TypeScript source over compiled JS output. /// Used for compile_packages where we want to compile from TS source, not bundled JS. pub(super) fn resolve_package_source_entry( package_dir: &Path, subpath: Option<&str>, ) -> Option { - if package_entry_requires_cjs_emit(package_dir) { + if package_entry_requires_cjs_emit(package_dir) || !package_sources_self_contained(package_dir) + { return None; } diff --git a/crates/perry/src/commands/compile/resolve/source_closure_tests.rs b/crates/perry/src/commands/compile/resolve/source_closure_tests.rs new file mode 100644 index 00000000000..afac5680b72 --- /dev/null +++ b/crates/perry/src/commands/compile/resolve/source_closure_tests.rs @@ -0,0 +1,69 @@ +//! A package's TypeScript sources stand in for its published JavaScript only +//! when they are self-contained (`package_sources_self_contained`): the +//! Supabase CLI linked `@posthog/core` and `@supabase/supabase-js` from their +//! sources, whose `@/utils` alias and unpublished `@supabase/tracing` import +//! were left unbound. + +use super::resolve_package_source_entry; +use std::fs; +use std::path::{Path, PathBuf}; + +/// A package shipping `dist/index.js` with a source map to `src/index.ts`, +/// whose text is `source`; plus a `src/util.ts` it may import. +fn package(root: &Path, name: &str, source: &str) -> (PathBuf, PathBuf) { + let dir = root.join("node_modules").join(name); + fs::create_dir_all(dir.join("dist")).unwrap(); + fs::create_dir_all(dir.join("src")).unwrap(); + fs::write(dir.join("dist/index.js"), "export const x = 1;\n").unwrap(); + fs::write( + dir.join("dist/index.js.map"), + r#"{"version":3,"file":"index.js","sources":["../src/index.ts"],"mappings":""}"#, + ) + .unwrap(); + fs::write(dir.join("src/index.ts"), source).unwrap(); + fs::write(dir.join("src/util.ts"), "export const u = 1;\n").unwrap(); + fs::write( + dir.join("package.json"), + serde_json::json!({ "name": name, "type": "module", "module": "./dist/index.js" }) + .to_string(), + ) + .unwrap(); + let source = dir.join("src/index.ts").canonicalize().unwrap(); + (dir, source) +} + +#[test] +fn a_path_alias_keeps_the_package_on_its_javascript() { + let root = tempfile::tempdir().unwrap(); + let (dir, _) = package( + root.path(), + "aliased", + "import { u } from '@/utils';\nexport const x = u;\n", + ); + assert_eq!(resolve_package_source_entry(&dir, None), None); +} + +#[test] +fn an_unpublished_workspace_import_keeps_the_package_on_its_javascript() { + let root = tempfile::tempdir().unwrap(); + let (dir, _) = package( + root.path(), + "bundles-tracing", + "import { u } from './util.js';\nexport { parse } from '@scope/not-installed';\nexport const x = u;\n", + ); + assert_eq!(resolve_package_source_entry(&dir, None), None); +} + +#[test] +fn self_contained_sources_are_still_used() { + let root = tempfile::tempdir().unwrap(); + let dep = root.path().join("node_modules/dep"); + fs::create_dir_all(&dep).unwrap(); + fs::write(dep.join("package.json"), r#"{"name":"dep"}"#).unwrap(); + let (dir, source) = package( + root.path(), + "contained", + "import type { T } from '@types/not-installed';\nimport { u } from './util.js';\nimport d from 'dep';\nimport fs from 'node:fs';\nexport const x: T = [u, d, fs];\n", + ); + assert_eq!(resolve_package_source_entry(&dir, None), Some(source)); +} From 141816fb90b41b5b6dbae6f75ac3f971137681b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 30 Oct 2026 02:35:03 +0100 Subject: [PATCH 05/84] fix(runtime): callable class instances: inherited getter receiver, instanceof A callable class instance is a function object given a class prototype (`Object.setPrototypeOf(fn.bind(this), new.target.prototype)`, ArkType's `Callable`). Two reads on it disagreed with JavaScript: - An inherited getter two or more levels up its prototype chain ran with the first prototype as `this`: the static-prototype walk read the rest of the chain with that prototype as receiver. It now uses the prototype's [[Get]] with the original function as receiver (`js_reflect_get`). ArkType's `BaseRoot.rawIn` (`super.rawIn`) returned `BaseRoot.prototype` and `new UnionNode` threw reading `$.parseDefinition`. - `instanceof` a user class was always false: only ordinary objects' class shapes were consulted. A function object now has its prototype chain walked for the class's prototype object (OrdinaryHasInstance). test_gap_callable_class_inherited_getter_receiver matches Node. --- .../src/closure/dynamic_props.rs | 28 +++++++++---- .../src/object/instanceof/static_dispatch.rs | 27 +++++++++++++ ...allable_class_inherited_getter_receiver.ts | 39 +++++++++++++++++++ 3 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 test-files/test_gap_callable_class_inherited_getter_receiver.ts diff --git a/crates/perry-runtime/src/closure/dynamic_props.rs b/crates/perry-runtime/src/closure/dynamic_props.rs index a27559e607d..034c3d5aba8 100644 --- a/crates/perry-runtime/src/closure/dynamic_props.rs +++ b/crates/perry-runtime/src/closure/dynamic_props.rs @@ -361,14 +361,28 @@ pub(crate) fn closure_get_dynamic_prop_keyed( ); } { - // The thread's canonical interned header: no allocation per read, - // and one stable key identity, so the read below can be served by - // (and prime) the inherited-read cache instead of minting a fresh - // key string that no cache entry can ever match again. + // The proto object's [[Get]](key, Receiver) with the ORIGINAL + // closure as receiver: an accessor further up its chain runs on + // the closure too, not on the proto that holds the chain. A + // callable class instance (`Object.setPrototypeOf(fn.bind(this), + // new.target.prototype)`, ArkType's `Callable`) reads its class + // getters this way; a getter two levels up returned the + // intermediate prototype for `this` (ArkType's `rawIn`). + // The thread's canonical interned header: one stable key + // identity, no allocation per read. let key_hdr = crate::string::canonical_key(prop.as_bytes()); - let v = crate::object::js_object_get_field_by_name( - proto_ptr as *const crate::object::ObjectHeader, - key_hdr as *const crate::StringHeader, + let this_scope = crate::gc::RuntimeHandleScope::new(); + let receiver = this_scope.root_nanbox_f64( + crate::object::field_get_set::accessor_receiver_override_take() + .unwrap_or_else(|| crate::value::js_nanbox_pointer(ptr as i64)), + ); + let v = crate::value::JSValue::from_bits( + crate::proxy::js_reflect_get( + proto_f64, + crate::value::js_nanbox_string(key_hdr as i64), + receiver.get_nanbox_f64(), + ) + .to_bits(), ); if !v.is_undefined() && !v.is_null() { return f64::from_bits(v.bits()); diff --git a/crates/perry-runtime/src/object/instanceof/static_dispatch.rs b/crates/perry-runtime/src/object/instanceof/static_dispatch.rs index a461272c451..d009d654f68 100644 --- a/crates/perry-runtime/src/object/instanceof/static_dispatch.rs +++ b/crates/perry-runtime/src/object/instanceof/static_dispatch.rs @@ -178,6 +178,33 @@ fn instanceof_dispatch( false_val }; } + // A function object given a class's prototype (a callable class + // instance: `Object.setPrototypeOf(fn.bind(this), + // new.target.prototype)`, ArkType's `Callable`) has no class shape: + // OrdinaryHasInstance walks its prototype chain for the class's + // prototype object. + if crate::closure::is_closure_bits(value.to_bits()) { + if let Some(first) = crate::closure::closure_static_prototype(addr) { + let scope = crate::gc::RuntimeHandleScope::new(); + let target = scope.root_nanbox_f64( + super::super::class_registry::class_decl_prototype_value(class_id), + ); + let mut proto = scope.root_nanbox_u64(first); + for _ in 0..1024 { + let bits = proto.get_nanbox_u64(); + let current = crate::value::JSValue::from_bits(bits); + if current.is_null() || current.is_undefined() { + break; + } + if bits == target.get_nanbox_u64() { + return true_val; + } + let next = crate::object::js_object_get_prototype_of(f64::from_bits(bits)); + proto = scope.root_nanbox_u64(next.to_bits()); + } + return false_val; + } + } } native_with_constructor(value, class_id, constructor) } diff --git a/test-files/test_gap_callable_class_inherited_getter_receiver.ts b/test-files/test_gap_callable_class_inherited_getter_receiver.ts new file mode 100644 index 00000000000..ac947757e1a --- /dev/null +++ b/test-files/test_gap_callable_class_inherited_getter_receiver.ts @@ -0,0 +1,39 @@ +// A callable class instance (`Callable`'s constructor returns +// `Object.setPrototypeOf(fn.bind(this), new.target.prototype)`, ArkType's +// pattern) reads its class getters through the function object's static +// prototype chain. A getter one or more levels above the first prototype ran +// with that prototype as `this`: ArkType's `BaseRoot.rawIn` (a `super` getter +// read on a `DomainNode`) returned `BaseRoot.prototype` and `new UnionNode` +// failed reading `$.parseDefinition`. +class Callable { + constructor(fn: (...a: any[]) => any) { + return Object.setPrototypeOf(fn.bind(this), (this as any).constructor.prototype); + } +} +class Node extends Callable { + tag = "node"; + constructor() { super((x: number) => x * 2); } + get me(): any { return this; } + get label(): string { return "label:" + this.tag; } + cacheGetter(name: string, value: any) { Object.defineProperty(this, name, { value }); return value; } + get rawIn(): any { return this.cacheGetter("rawIn", this); } +} +class Root extends Node { + get rawIn(): any { return super.rawIn; } +} +class Basis extends Root {} +class Domain extends Basis {} + +const one: any = new Root(); +const two: any = new Domain(); +console.log("depth 1:", one.me === one, one.label); +console.log("depth 2:", two.me === two, two.label); +const raw = two.rawIn; +console.log("super getter through a subclass:", raw === two, typeof raw, raw(21)); +console.log("cached on the instance:", Object.getOwnPropertyNames(two).includes("rawIn")); +console.log("not cached on a prototype:", !Object.getOwnPropertyNames(Root.prototype).includes("rawIn") || typeof Object.getOwnPropertyDescriptor(Root.prototype, "rawIn")!.get === "function"); + +const fn: any = Object.setPrototypeOf(function () { return 1; }, Domain.prototype); +console.log("plain function:", fn.me === fn, fn.label); +const obj: any = Object.create(Domain.prototype); +console.log("plain object:", obj.me === obj); From c0e6c0b60afbc8609976be37f10e3d8cb8bbf688 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 30 Oct 2026 02:35:03 +0100 Subject: [PATCH 06/84] fix(hir): arrows passed to super(...) read the bound `this` An arrow passed directly to `super(...)` reads the derived constructor's `this` binding, which `super()` initializes when it returns, to the object the parent constructor returned when it returned one. Closures capture `this` when created, so the arrow ran on the discarded allocation: ArkType's `GenericRoot` (`super((...args) => .. this.names ..)` to `Callable`) threw reading `this.paramDefs`. Such a `super(...)` statement now declares a cell, the arrows read `this` as `cell.t`, and `cell.t = this` follows the call (`lower_decl::super_arrow_this`; `substitute_lexical_this_in_expr` captures the local a property replacement reads). An arrow run before `super()` returns reads undefined where the binding would throw. test_gap_super_argument_arrow_this_binding matches Node. --- crates/perry-hir/src/analysis.rs | 12 ++- .../perry-hir/src/lower_decl/class_members.rs | 1 + crates/perry-hir/src/lower_decl/mod.rs | 1 + .../src/lower_decl/super_arrow_this.rs | 87 +++++++++++++++++++ ...t_gap_super_argument_arrow_this_binding.ts | 51 +++++++++++ 5 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 crates/perry-hir/src/lower_decl/super_arrow_this.rs create mode 100644 test-files/test_gap_super_argument_arrow_this_binding.ts diff --git a/crates/perry-hir/src/analysis.rs b/crates/perry-hir/src/analysis.rs index 4a5e2ad2377..3b2cf14b5c7 100644 --- a/crates/perry-hir/src/analysis.rs +++ b/crates/perry-hir/src/analysis.rs @@ -523,7 +523,17 @@ pub fn substitute_lexical_this_in_expr(expr: &mut Expr, replacement: &Expr) { // #11157: a local replacement (a per-evaluation class's // self-binding) is read from the closure body now, so the // closure must capture it like any other outer local. - if let Expr::LocalGet(id) = replacement { + let local = match replacement { + Expr::LocalGet(id) => Some(id), + // A property of a local (a derived constructor's + // `this` cell, `lower_decl::super_arrow_this`). + Expr::PropertyGet { object, .. } => match object.as_ref() { + Expr::LocalGet(id) => Some(id), + _ => None, + }, + _ => None, + }; + if let Some(id) = local { if !captures.contains(id) { captures.push(*id); } diff --git a/crates/perry-hir/src/lower_decl/class_members.rs b/crates/perry-hir/src/lower_decl/class_members.rs index 334f9f8d689..1dff9fd60f5 100644 --- a/crates/perry-hir/src/lower_decl/class_members.rs +++ b/crates/perry-hir/src/lower_decl/class_members.rs @@ -298,6 +298,7 @@ fn lower_constructor_in_scope( new_body.append(&mut body); body = new_body; } + super::super_arrow_this::bind_super_argument_arrows(ctx, &mut body); let class_expr_entries = ctx .body_class_expr_captures .split_off(class_expr_capture_mark); diff --git a/crates/perry-hir/src/lower_decl/mod.rs b/crates/perry-hir/src/lower_decl/mod.rs index d376810620d..15ec9db7de7 100644 --- a/crates/perry-hir/src/lower_decl/mod.rs +++ b/crates/perry-hir/src/lower_decl/mod.rs @@ -23,6 +23,7 @@ mod interface_decl; mod lexical_block; mod private_members; mod static_init; +mod super_arrow_this; mod type_alias; mod typeof_narrow; diff --git a/crates/perry-hir/src/lower_decl/super_arrow_this.rs b/crates/perry-hir/src/lower_decl/super_arrow_this.rs new file mode 100644 index 00000000000..c25612f0a6d --- /dev/null +++ b/crates/perry-hir/src/lower_decl/super_arrow_this.rs @@ -0,0 +1,87 @@ +//! An arrow passed directly to `super(...)` reads the derived constructor's +//! `this` binding, which `super()` initializes when it returns: to the +//! object the parent constructor returned, when it returned one. Closures +//! capture `this` when they are created, before that. The callable-class +//! pattern (`Callable`'s constructor returns +//! `Object.setPrototypeOf(fn.bind(this), new.target.prototype)`; ArkType's +//! `GenericRoot` and `BaseNode` pass the arrow `fn`) then ran the arrow on the +//! discarded allocation (`this.paramDefs` undefined). +//! +//! Each such `super(...)` statement becomes +//! +//! ```text +//! const cell = {}; +//! super(.., () => .. cell.t .., ..); +//! cell.t = this; +//! ``` +//! +//! An arrow that runs before `super()` returns reads `undefined` where the +//! binding would throw a ReferenceError. + +use crate::ir::*; +use crate::lower::LoweringContext; +use crate::types::{LocalId, Type}; + +pub(super) fn bind_super_argument_arrows(ctx: &mut LoweringContext, stmts: &mut Vec) { + let mut i = 0; + while i < stmts.len() { + match &mut stmts[i] { + Stmt::If { + then_branch, + else_branch, + .. + } => { + bind_super_argument_arrows(ctx, then_branch); + if let Some(else_branch) = else_branch { + bind_super_argument_arrows(ctx, else_branch); + } + } + Stmt::Expr(Expr::SuperCall(args)) if args.iter().any(is_this_arrow) => { + let cell = ctx.fresh_local(); + let read = Expr::PropertyGet { + object: Box::new(Expr::LocalGet(cell)), + property: "t".to_string(), + byte_offset: 0, + byte_end: 0, + }; + for arg in args.iter_mut().filter(|a| is_this_arrow(a)) { + crate::analysis::substitute_lexical_this_in_expr(arg, &read); + } + stmts.insert(i, cell_decl(cell)); + stmts.insert( + i + 2, + Stmt::Expr(Expr::PropertySet { + object: Box::new(Expr::LocalGet(cell)), + property: "t".to_string(), + value: Box::new(Expr::This), + }), + ); + i += 3; + continue; + } + _ => {} + } + i += 1; + } +} + +fn is_this_arrow(expr: &Expr) -> bool { + matches!( + expr, + Expr::Closure { + captures_this: true, + is_arrow: true, + .. + } + ) +} + +fn cell_decl(cell: LocalId) -> Stmt { + Stmt::Let { + id: cell, + name: format!("__perry_super_this_{cell}"), + ty: Type::Any, + mutable: false, + init: Some(Expr::Object(Vec::new())), + } +} diff --git a/test-files/test_gap_super_argument_arrow_this_binding.ts b/test-files/test_gap_super_argument_arrow_this_binding.ts new file mode 100644 index 00000000000..2601924af01 --- /dev/null +++ b/test-files/test_gap_super_argument_arrow_this_binding.ts @@ -0,0 +1,51 @@ +// An arrow passed to `super(...)` reads the derived constructor's `this` +// binding, which `super()` sets to the object the parent constructor returned. +// ArkType's `GenericRoot` passes such an arrow to `Callable`, whose +// constructor returns a re-prototyped bound function, and calls it later. +class Callable { + constructor(fn: (...a: any[]) => any) { + return Object.setPrototypeOf(fn.bind(this), new.target.prototype); + } +} +class Generic extends Callable { + params: number[]; + constructor(params: number[]) { + super((...args: number[]) => this.params.map((p) => p + args.length)); + this.params = params; + } +} +const g: any = new Generic([1, 2]); +console.log("callable result:", JSON.stringify(g(7, 8))); +console.log("arrow this is the instance:", typeof g, g instanceof Generic); + +class Nested extends Callable { + tag = "nested"; + constructor() { + super(() => [1, 2].map(() => this.tag).join(",")); + } +} +console.log("nested arrow:", (new Nested() as any)()); + +class Holder { + cb: () => any; + constructor(cb: () => any) { this.cb = cb; } +} +class Plain extends Holder { + v = 5; + constructor() { super(() => this.v); } +} +const p = new Plain(); +console.log("plain base:", p.cb(), p.cb() === p.v); + +class Branchy extends Holder { + w: string; + constructor(flag: boolean) { + if (flag) { + super(() => this.w + "!"); + } else { + super(() => this.w + "?"); + } + this.w = flag ? "yes" : "no"; + } +} +console.log("branches:", new Branchy(true).cb(), new Branchy(false).cb()); From f9c33433eb3720acb64e78b4f74516d44f0f91b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 30 Oct 2026 08:09:21 +0100 Subject: [PATCH 07/84] =?UTF-8?q?fix(wpfacts):=20one=20spelling=20of=20a?= =?UTF-8?q?=20=E2=8A=A4=20function=20result?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A result named by no contract (`ret: None`) and one whose only member is ⊤ (`Some([])`, `ret_unknown`, every primitive kind) mean the same to every reader (`Gen::fill_described`: ⊤ and every kind; `gen.rs` and the join treat them alike), but digest differently, and which one a solve publishes depended on the order its escapes ran (`ret_top`). Readers embed the contract in what they publish, so its owner was solved again and published the other spelling: oxfmt's esbuild `__commonJS` inner closure alternated between the two every round. Both are now published as `ret: None`, `ret_unknown`, every kind (as `fn_named` describes one). No fact changes. --- crates/perry-wpfacts/src/lib/contracts.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/perry-wpfacts/src/lib/contracts.rs b/crates/perry-wpfacts/src/lib/contracts.rs index 7a7e203bee1..c5cca84ea5d 100644 --- a/crates/perry-wpfacts/src/lib/contracts.rs +++ b/crates/perry-wpfacts/src/lib/contracts.rs @@ -481,7 +481,7 @@ pub(super) fn fn_contract_of( .filter(|(g2, _)| *g2 == f) .map(|&(_, n)| n) .collect(); - let d = describe_nodes( + let mut d = describe_nodes( g, templates, f, @@ -490,6 +490,23 @@ pub(super) fn fn_contract_of( layouts_out, open_tm, ); + // A ⊤ result has one spelling: `ret: None` with `ret_unknown` and every + // primitive kind, as `fn_named` describes one. A reader takes a result named + // by no contract (`None`) and one whose only member is ⊤ (`Some([])`, + // unknown, every kind) the same way (`Gen::fill_described`: ⊤ and every + // kind), but the two digests differ, and which one a solve publishes + // depends on the order its escapes ran (`ret_top` holds only a result + // that escaped before its function became a contract function). Readers + // embed the contract in what they publish, so its owner solved again and + // published the other spelling: oxfmt's esbuild `__commonJS` closures in + // the Supabase CLI alternated between the two every round, forever. + if d.layouts.as_ref().is_some_and(|l| l.is_empty()) && d.unknown && d.prim == model::prim::ALL { + d.layouts = None; + } + if d.layouts.is_none() { + d.unknown = true; + d.prim = model::prim::ALL; + } // DECISIONS 140: the calls the body makes through a parameter marker, // each argument described as the result is. let mut param_calls: Vec = Vec::new(); From 231e1f4382dbe334df3e50008703ed2e51fb1953 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 30 Oct 2026 08:09:25 +0100 Subject: [PATCH 08/84] perf(wpfacts): join only the solved units' shared writes each round The shared writes only grow (D16): last round's set already holds every other unit's writes, so joining the units a round solved gives the same set. Every round cloned every unit's writes into it instead: on the Supabase CLI 86.8M stored writes into a ~3k-element set, minutes per round. Only writes the set lacks are cloned. The set is unchanged. --- crates/perry-wpfacts/src/units/driver.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/perry-wpfacts/src/units/driver.rs b/crates/perry-wpfacts/src/units/driver.rs index f4213a49e42..18bd79560e5 100644 --- a/crates/perry-wpfacts/src/units/driver.rs +++ b/crates/perry-wpfacts/src/units/driver.rs @@ -352,6 +352,8 @@ pub(crate) fn run(sorted: &[(&str, &Module)], cfg: &FactsConfig) -> ProgramFacts let mut published_fns: FxHashSet = FxHashSet::default(); let mut reports: FxHashSet = FxHashSet::default(); let mut work = 0u64; + // The units this round solves: only their shared writes can be new. + let mut solved: Vec = Vec::new(); for level in &levels { // A unit over the bound keeps its result (`dependents`). for &si in level { @@ -394,6 +396,7 @@ pub(crate) fn run(sorted: &[(&str, &Module)], cfg: &FactsConfig) -> ProgramFacts } } per_unit[si] = Some(u); + solved.push(si); shared_seen[si] = shared_rounds.len() - 1; } if cfg.report && !todo.is_empty() { @@ -874,11 +877,19 @@ pub(crate) fn run(sorted: &[(&str, &Module)], cfg: &FactsConfig) -> ProgramFacts } input.writes = writes; // The shared objects' writes (`model::SharedWrite`): last round's - // joined with every unit's latest, so they only grow (D16). When + // joined with every unit's latest, so they only grow (D16). Last + // round's already holds every other unit's writes, so joining the + // units this round solved is that join; a unit's writes are mostly + // the program's few shared ones, so only new ones are cloned. When // they grew, the units that can see a new one are solved again. let mut shared = input.shared.clone(); - for u in per_unit.iter().flatten() { - shared.extend(u.shared_out.iter().cloned()); + for &si in &solved { + let u = per_unit[si].as_ref().expect("every unit solved"); + for w in &u.shared_out { + if !shared.contains(w) { + shared.insert(w.clone()); + } + } } shared_closure(&mut shared); if shared != input.shared { From 282f40d01a360779d6274f3e522202b045dce78c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 30 Oct 2026 11:08:33 +0100 Subject: [PATCH 09/84] feat(wpfacts): name what keeps per-unit facts from a fixpoint Past 64 rounds (and again at 128, 256, ...) the driver prints the units the round solved and, for each, the contracts it added, dropped or changed, with the changed fields read from their derived Debug form. Computed only on those rounds. --- .../src/tests/unit_mode/commonjs_helper.rs | 17 +++ crates/perry-wpfacts/src/units.rs | 2 +- crates/perry-wpfacts/src/units/driver.rs | 119 ++++++++++++++++++ 3 files changed, 137 insertions(+), 1 deletion(-) diff --git a/crates/perry-wpfacts/src/tests/unit_mode/commonjs_helper.rs b/crates/perry-wpfacts/src/tests/unit_mode/commonjs_helper.rs index 325d56c5082..f3a494d4508 100644 --- a/crates/perry-wpfacts/src/tests/unit_mode/commonjs_helper.rs +++ b/crates/perry-wpfacts/src/tests/unit_mode/commonjs_helper.rs @@ -56,3 +56,20 @@ fn a_helper_reading_its_own_contract_back_reaches_the_sound_fixpoint() { let wx = w.fields.iter().find(|f| f.key == "exports").unwrap(); assert!(!wx.readonly && !ux.readonly, "the callbacks store exports"); } + +/// The no-fixpoint report names the top-level fields that differ, nested +/// values included, from the derived `Debug` form. +#[test] +fn the_no_fixpoint_report_names_the_changed_fields() { + #[derive(Debug)] + struct C { + params: Vec, + reach: Vec, + ret: Option>, + name: String, + } + let a = C { params: vec![1, 5], reach: vec![0, 4], ret: Some(vec!["x".into()]), name: "ct".into() }; + let b = C { params: vec![1, 5], reach: vec![0, 0], ret: Some(vec!["y".into()]), name: "ct".into() }; + assert_eq!(crate::units::driver::changed_fields(&a, &b), ["reach", "ret"]); + assert!(crate::units::driver::changed_fields(&a, &a).is_empty()); +} diff --git a/crates/perry-wpfacts/src/units.rs b/crates/perry-wpfacts/src/units.rs index e4484a2b8cb..39abae537e6 100644 --- a/crates/perry-wpfacts/src/units.rs +++ b/crates/perry-wpfacts/src/units.rs @@ -1382,7 +1382,7 @@ pub(crate) fn module_exports(sorted: &[(&str, &Module)], es: &crate::ExportSourc } #[path = "units/driver.rs"] -mod driver; +pub(crate) mod driver; pub(crate) use driver::digest; pub(crate) use driver::run; #[cfg(test)] diff --git a/crates/perry-wpfacts/src/units/driver.rs b/crates/perry-wpfacts/src/units/driver.rs index 18bd79560e5..117a09eb699 100644 --- a/crates/perry-wpfacts/src/units/driver.rs +++ b/crates/perry-wpfacts/src/units/driver.rs @@ -354,6 +354,10 @@ pub(crate) fn run(sorted: &[(&str, &Module)], cfg: &FactsConfig) -> ProgramFacts let mut work = 0u64; // The units this round solves: only their shared writes can be new. let mut solved: Vec = Vec::new(); + // A round past `LONG_ROUNDS` (64, 128, ...) names what each unit it + // solves changed (`no_fixpoint_report`). + let long = rounds >= LONG_ROUNDS && rounds.is_power_of_two(); + let mut long_changes: Vec<(usize, Vec)> = Vec::new(); for level in &levels { // A unit over the bound keeps its result (`dependents`). for &si in level { @@ -381,6 +385,11 @@ pub(crate) fn run(sorted: &[(&str, &Module)], cfg: &FactsConfig) -> ProgramFacts // value one writer took from another's earlier write cannot // pass back and forth between them after its source stopped // writing it (tsc typescript.js#u4336/#u4339, period 2). + if long { + if let Some(old) = per_unit[si].as_ref() { + long_changes.push((si, output_changes(&u, old))); + } + } if let Some(old) = per_unit[si].take() { let at: FxHashMap = u .cells_out @@ -911,6 +920,9 @@ pub(crate) fn run(sorted: &[(&str, &Module)], cfg: &FactsConfig) -> ProgramFacts shared_rounds.push(shared.clone()); } input.shared = shared; + if long { + no_fixpoint_report(rounds, &solves, &long_changes, seeds.len()); + } if cfg.report { eprintln!( "perry: wpfacts census r{rounds} end seeds {} {}", @@ -1655,3 +1667,110 @@ pub(super) fn unique_bindings(values: &mut [(u32, T)], module: &str, kind: &s ); } } + +/// The rounds after which the driver names what keeps it going (the corpus +/// reaches its fixpoint in at most ~15). A round count that keeps growing is +/// a unit whose outputs never settle: each report at round 64, 128, 256, ... +/// names the units that round solved and what each changed. +const LONG_ROUNDS: u32 = 64; + +/// What a unit's solve changed in its outputs against its last solve: each +/// contract added (`+`), dropped (`-`) or changed, with the fields that +/// differ. +fn output_changes(u: &ProgramFacts, old: &ProgramFacts) -> Vec { + fn keyed( + out: &mut Vec, + what: &str, + new: &[(K, &C)], + old: &[(K, &C)], + ) { + for (k, c) in new { + match old.iter().find(|(ok, _)| ok == k) { + None => out.push(format!("+{what} {k}")), + Some((_, p)) if p != c => { + out.push(format!("{what} {k} [{}]", changed_fields(*p, *c).join(" "))) + } + Some(_) => {} + } + } + for (k, _) in old { + if !new.iter().any(|(nk, _)| nk == k) { + out.push(format!("-{what} {k}")); + } + } + } + fn own(v: &ProgramFacts) -> Vec<(String, &FnContract)> { + v.own_fns_out.iter().map(|c| (c.name.clone(), c)).collect() + } + fn named(v: &[((String, String), C)]) -> Vec<(String, &C)> { + v.iter().map(|(k, c)| (format!("{}:{}", k.0, k.1), c)).collect() + } + fn cells(v: &ProgramFacts) -> Vec<(String, &ValueContract)> { + v.cells_out.iter().map(|(k, c)| (k.clone(), c)).collect() + } + let mut out = Vec::new(); + keyed(&mut out, "own_fn", &own(u), &own(old)); + keyed(&mut out, "fn", &named(&u.fn_contracts_out), &named(&old.fn_contracts_out)); + keyed(&mut out, "class", &named(&u.contracts_out), &named(&old.contracts_out)); + keyed(&mut out, "value", &named(&u.values_out), &named(&old.values_out)); + keyed(&mut out, "cell", &cells(u), &cells(old)); + let added = u.shared_out.iter().filter(|w| !old.shared_out.contains(w)).count(); + let gone = old.shared_out.iter().filter(|w| !u.shared_out.contains(w)).count(); + if added + gone != 0 { + out.push(format!("shared writes +{added} -{gone}")); + } + out +} + +/// The top-level fields of `a` and `b` (one type) whose values differ, read +/// from their derived `Debug` form: no list of fields to keep in step. +pub(crate) fn changed_fields(a: &T, b: &T) -> Vec { + fn fields(s: &str) -> Vec<(String, String)> { + let mut v: Vec<(String, String)> = Vec::new(); + for line in s.lines() { + let top = line.starts_with(" ") && !line.starts_with(" "); + match line[line.len().min(4)..].split_once(": ") { + Some((name, rest)) if top => v.push((name.to_string(), rest.to_string())), + _ => { + if let Some(last) = v.last_mut() { + last.1.push('\n'); + last.1.push_str(line); + } + } + } + } + v + } + let (fa, fb) = (fields(&format!("{a:#?}")), fields(&format!("{b:#?}"))); + let mut out: Vec = fa + .iter() + .filter(|(n, x)| fb.iter().find(|(m, _)| m == n).is_none_or(|(_, y)| x != y)) + .map(|(n, _)| n.clone()) + .collect(); + out.extend(fb.iter().filter(|(n, _)| !fa.iter().any(|(m, _)| m == n)).map(|(n, _)| n.clone())); + out +} + +/// The no-fixpoint report of round `round`: the units it solved and what +/// each changed (at most 16 units, 8 changes each). +fn no_fixpoint_report( + round: u32, + solves: &[Solve<'_>], + changed: &[(usize, Vec)], + seeds: usize, +) { + eprintln!( + "perry: wpfacts: no fixpoint after {round} rounds: round {round} solved {} units, {seeds} seeded for the next", + changed.len() + ); + for (si, cs) in changed.iter().take(16) { + let shown: Vec<&str> = cs.iter().take(8).map(|c| c.as_str()).collect(); + eprintln!( + "perry: wpfacts: {} changed {}: {}{}", + solves[*si].key, + cs.len(), + if shown.is_empty() { "nothing".to_string() } else { shown.join("; ") }, + if cs.len() > 8 { "; ..." } else { "" } + ); + } +} From 20f98f1f00ae5d5c6245c8d56670eebe7484d129 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 30 Oct 2026 11:40:23 +0100 Subject: [PATCH 10/84] fix(wpfacts): the no-fixpoint report compares a cell after its join A unit's cell write is its last write joined with its latest one (DECISIONS 197): the report read the latest alone and named every cell the solve did not write again. --- crates/perry-wpfacts/src/units/driver.rs | 25 +++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/perry-wpfacts/src/units/driver.rs b/crates/perry-wpfacts/src/units/driver.rs index 117a09eb699..9fe24f0e575 100644 --- a/crates/perry-wpfacts/src/units/driver.rs +++ b/crates/perry-wpfacts/src/units/driver.rs @@ -1705,15 +1705,34 @@ fn output_changes(u: &ProgramFacts, old: &ProgramFacts) -> Vec { fn named(v: &[((String, String), C)]) -> Vec<(String, &C)> { v.iter().map(|(k, c)| (format!("{}:{}", k.0, k.1), c)).collect() } - fn cells(v: &ProgramFacts) -> Vec<(String, &ValueContract)> { - v.cells_out.iter().map(|(k, c)| (k.clone(), c)).collect() + // A cell keeps the unit's last write joined with its latest + // (DECISIONS 197): what it holds after this solve is that join. + let joined: Vec<(String, ValueContract)> = old + .cells_out + .iter() + .map(|(k, c)| { + let mut j = c.clone(); + if let Some((_, n)) = u.cells_out.iter().find(|(q, _)| q == k) { + j.join(n); + } + (k.clone(), j) + }) + .chain( + u.cells_out + .iter() + .filter(|(k, _)| !old.cells_out.iter().any(|(q, _)| q == k)) + .cloned(), + ) + .collect(); + fn cells(v: &[(String, ValueContract)]) -> Vec<(String, &ValueContract)> { + v.iter().map(|(k, c)| (k.clone(), c)).collect() } let mut out = Vec::new(); keyed(&mut out, "own_fn", &own(u), &own(old)); keyed(&mut out, "fn", &named(&u.fn_contracts_out), &named(&old.fn_contracts_out)); keyed(&mut out, "class", &named(&u.contracts_out), &named(&old.contracts_out)); keyed(&mut out, "value", &named(&u.values_out), &named(&old.values_out)); - keyed(&mut out, "cell", &cells(u), &cells(old)); + keyed(&mut out, "cell", &cells(&joined), &cells(&old.cells_out)); let added = u.shared_out.iter().filter(|w| !old.shared_out.contains(w)).count(); let gone = old.shared_out.iter().filter(|w| !u.shared_out.contains(w)).count(); if added + gone != 0 { From b787b33b606c9ed6a180855557700af88d0e7d4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 30 Oct 2026 11:40:23 +0100 Subject: [PATCH 11/84] fix(wpfacts): a stand-in's result reached through a key stays in its run Replaying a contract's result transfer through a key (ret_reach_keys, reach) loaded the parameter into a node outside the stand-in's run, so the caller's own argument came back as a marker of no run: the caller's result read it as another call's value and escaped. A recursive function reading its own contract (babel's fe in oxfmt) then published the transfer and the escape in turn and per-unit facts had no fixpoint. The intermediate nodes are of the run of the node they feed. --- crates/perry-wpfacts/src/gen/import_values.rs | 9 ++++ .../src/tests/unit_mode/commonjs_helper.rs | 42 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/crates/perry-wpfacts/src/gen/import_values.rs b/crates/perry-wpfacts/src/gen/import_values.rs index 7f1be614eaa..ac0d764e86a 100644 --- a/crates/perry-wpfacts/src/gen/import_values.rs +++ b/crates/perry-wpfacts/src/gen/import_values.rs @@ -495,6 +495,10 @@ impl<'a> Gen<'a> { pub(super) fn reach_into(&mut self, holder: NodeId, out: NodeId, label: &str, name: &str) { let q = self.m.node_named("reach", || format!("{label}({name})")); self.m.own(q); + // Of the run of the node it feeds (a stand-in's result is one run, + // DECISIONS 44 (b)): what the caller passed comes back relative to + // the call, not as a marker crossing out of that run. + self.m.nodes[q as usize].frame = self.m.nodes[out as usize].frame; self.m.cons( holder, Cons::Load { @@ -527,6 +531,9 @@ impl<'a> Gen<'a> { ) { let q = self.m.node_named("reach", || format!("reach.{k}({name})")); self.m.own(q); + // Of the run of the result it feeds, as `reach_into`'s. + let run = self.m.nodes[ret as usize].frame; + self.m.nodes[q as usize].frame = run; let a = self.m.atom(k); self.m.cons( holder, @@ -550,6 +557,7 @@ impl<'a> Gen<'a> { .m .node_named("reach", || format!("reach.{k}.{j}({name})")); self.m.own(n); + self.m.nodes[n as usize].frame = run; let key = match sk { Some(s) => crate::model::Key::Named(self.m.atom(s)), None => crate::model::Key::Any, @@ -584,6 +592,7 @@ impl<'a> Gen<'a> { }; let r = self.m.node_named("reach", || format!("reach.{k}*({name})")); self.m.own(r); + self.m.nodes[r as usize].frame = run; for key in keys { self.m.cons(q, Cons::Load { key, dst: r }); self.m.cons(r, Cons::Load { key, dst: r }); diff --git a/crates/perry-wpfacts/src/tests/unit_mode/commonjs_helper.rs b/crates/perry-wpfacts/src/tests/unit_mode/commonjs_helper.rs index f3a494d4508..0b45c62106d 100644 --- a/crates/perry-wpfacts/src/tests/unit_mode/commonjs_helper.rs +++ b/crates/perry-wpfacts/src/tests/unit_mode/commonjs_helper.rs @@ -73,3 +73,45 @@ fn the_no_fixpoint_report_names_the_changed_fields() { assert_eq!(crate::units::driver::changed_fields(&a, &b), ["reach", "ret"]); assert!(crate::units::driver::changed_fields(&a, &a).is_empty()); } + +/// Babel's `fe` (oxfmt's bundle): a recursive arrow assigned in an init +/// function calls itself through its module cell, so its unit reads its own +/// contract back. The contract says the result is the argument or what the +/// argument reaches under `expression`; replaying that through the +/// stand-in loaded `p.expression` into a node outside the stand-in's run, +/// so the caller's own `a.expression` came back as a marker of no run, the +/// result read as another call's value and escaped, and the next solve +/// published the transfer again. Converges on the transfer, claiming no +/// more than the whole program for the caller's deep field it writes +/// through the result. +#[test] +fn a_recursive_helper_reading_its_own_result_transfer_reaches_a_fixpoint() { + const T: &str = r#" +var fe; +var init0 = () => { fe = (a) => a.type === "P" ? fe(a.expression) : a; }; +export var strip = (x) => { init0(); return fe(x); }; +"#; + const M: &str = "import { strip } from './t'; const o = { type: 'P', expression: { type: 'P', expression: { type: 'I', v: 1 } } }; const r = strip(o); r.v = 2; console.log(o.expression.expression.v);"; + let mods: &'static [(&'static str, &'static str)] = &[("t", T), ("m", M)]; + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(units_of(mods)); + }); + let u = rx + .recv_timeout(std::time::Duration::from_secs(60)) + .expect("per-unit facts reach a fixpoint"); + assert!(u.rounds <= 4, "rounds {}", u.rounds); + let w = whole_of(mods); + let v = |pf: &ProgramFacts| -> Vec { + let mut r: Vec = pf + .templates + .iter() + .filter(|t| t.display.starts_with("m.ts")) + .flat_map(|t| t.fields.iter().filter(|f| f.key == "v").map(|f| f.readonly)) + .collect(); + r.sort(); + r + }; + assert_eq!(v(&u), v(&w), "{}", u.dump_text()); + assert!(v(&u).iter().all(|&ro| !ro), "r.v = 2 writes the deep literal"); +} From f1003984994d3fd32fc9eb6698cd4b77f4e12b4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 30 Oct 2026 13:04:10 +0100 Subject: [PATCH 12/84] changelog: the Supabase CLI compiles from source --- changelog.d/13477-supabase-cli-from-source.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 changelog.d/13477-supabase-cli-from-source.md diff --git a/changelog.d/13477-supabase-cli-from-source.md b/changelog.d/13477-supabase-cli-from-source.md new file mode 100644 index 00000000000..46a3cfef3d1 --- /dev/null +++ b/changelog.d/13477-supabase-cli-from-source.md @@ -0,0 +1,9 @@ +The Supabase CLI (`supabase/cli`, `apps/cli/src/main.ts`, a pnpm workspace with a global virtual store) compiles from its TypeScript source to a native binary whose `--version` and `--help` match the Node bundle byte for byte. + +- **pnpm isolated stores and Node-API addons.** A prebuilt `.node` for another platform is left to fail at runtime, as napi-rs loaders expect under Node, instead of failing the compile. A platform payload is approved through the allowlisted wrapper copy the walk entered. The `compilePackages` wildcard enumerates `node_modules/.pnpm`. +- **Static `with { type: "text" }`** reads any file as a text module, as Bun's text loader does. +- **Named imports of CommonJS modules with opaque exports** (UMD `factory(...)` and similar) bind the property from `module.exports`, as Bun and bundlers do; modules whose exports are fully visible keep the static error. +- **Codegen:** long image-literal key names are digest-named like the record they point to; the native reader parses inline alias-scope metadata. +- **Resolve:** a package is compiled from its TypeScript sources only when they are self-contained. +- **Runtime and HIR:** an inherited getter of a callable class instance runs on the right receiver, `instanceof` walks the function's prototype chain, and arrows passed to `super(...)` read the bound `this`. +- **Per-unit facts reach their fixpoint** on the CLI's 64,283 units (round 11, about 7 minutes), with no cycle rule. Babel's `fe` (oxfmt's bundle), a recursive arrow that reads its own contract, published a result transfer and an escape in turn: replaying the transfer through a key loaded the parameter into a node outside the stand-in's run, so the caller's own argument came back as another call's value. Those nodes now belong to the run of the node they feed. A no-fixpoint report names, at round 64, 128, 256 and so on, the units each round solved and the contract fields they changed. A ⊤ function result has one spelling, and the end-of-round shared join takes only the units solved that round. From 38d75c685a155280785443d8a989adadefe6da63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 30 Oct 2026 15:45:29 +0100 Subject: [PATCH 13/84] fix(hir): every arrow created before super() returns reads the bound this The rule covered only arrows passed directly to super(...). An arrow created in a statement before super(...), the constructor's own default parameters included, captured the receiver super() discards just the same. Every arrow created before a super(...) statement returns now reads the binding cell; arrows created after it read this as before, and a constructor with no such arrow is unchanged. The gap test carries the cases of #13543's test (arguments, default parameters, nested arrows, an arrow before super(), a base returning nothing, per-instance identity). --- .../src/lower_decl/super_arrow_this.rs | 94 +++++++++++++------ ...st_gap_derived_ctor_arrow_returned_this.ts | 75 +++++++++++++++ 2 files changed, 139 insertions(+), 30 deletions(-) create mode 100644 test-files/test_gap_derived_ctor_arrow_returned_this.ts diff --git a/crates/perry-hir/src/lower_decl/super_arrow_this.rs b/crates/perry-hir/src/lower_decl/super_arrow_this.rs index c25612f0a6d..3e9709703e3 100644 --- a/crates/perry-hir/src/lower_decl/super_arrow_this.rs +++ b/crates/perry-hir/src/lower_decl/super_arrow_this.rs @@ -1,22 +1,28 @@ -//! An arrow passed directly to `super(...)` reads the derived constructor's -//! `this` binding, which `super()` initializes when it returns: to the -//! object the parent constructor returned, when it returned one. Closures -//! capture `this` when they are created, before that. The callable-class -//! pattern (`Callable`'s constructor returns +//! An arrow created before `super()` returns reads the derived constructor's +//! `this` binding, which `super()` initializes when it returns: to the object +//! the parent constructor returned, when it returned one. Closures capture +//! `this` when they are created, before that. The callable-class pattern +//! (`Callable`'s constructor returns //! `Object.setPrototypeOf(fn.bind(this), new.target.prototype)`; ArkType's //! `GenericRoot` and `BaseNode` pass the arrow `fn`) then ran the arrow on the -//! discarded allocation (`this.paramDefs` undefined). +//! discarded allocation (`this.paramDefs` undefined). The same holds for an +//! arrow created in a statement before `super(...)` (the constructor's own +//! default parameters included: they are its first statements here) and for +//! arrows nested in such an arrow. //! -//! Each such `super(...)` statement becomes +//! When a statement list holds a `super(...)` statement and an arrow before +//! it (or in its arguments) reads `this`, it becomes //! //! ```text //! const cell = {}; +//! //! super(.., () => .. cell.t .., ..); //! cell.t = this; //! ``` //! -//! An arrow that runs before `super()` returns reads `undefined` where the -//! binding would throw a ReferenceError. +//! Arrows created after `super()` returns read `this` as before. An arrow +//! that runs before `super()` returns reads `undefined` where the binding +//! would throw a ReferenceError. use crate::ir::*; use crate::lower::LoweringContext; @@ -36,28 +42,12 @@ pub(super) fn bind_super_argument_arrows(ctx: &mut LoweringContext, stmts: &mut bind_super_argument_arrows(ctx, else_branch); } } - Stmt::Expr(Expr::SuperCall(args)) if args.iter().any(is_this_arrow) => { - let cell = ctx.fresh_local(); - let read = Expr::PropertyGet { - object: Box::new(Expr::LocalGet(cell)), - property: "t".to_string(), - byte_offset: 0, - byte_end: 0, - }; - for arg in args.iter_mut().filter(|a| is_this_arrow(a)) { - crate::analysis::substitute_lexical_this_in_expr(arg, &read); + Stmt::Expr(Expr::SuperCall(_)) => { + if bind_before_super(ctx, stmts, i) { + // The cell declaration and its assignment were inserted. + i += 3; + continue; } - stmts.insert(i, cell_decl(cell)); - stmts.insert( - i + 2, - Stmt::Expr(Expr::PropertySet { - object: Box::new(Expr::LocalGet(cell)), - property: "t".to_string(), - value: Box::new(Expr::This), - }), - ); - i += 3; - continue; } _ => {} } @@ -65,6 +55,50 @@ pub(super) fn bind_super_argument_arrows(ctx: &mut LoweringContext, stmts: &mut } } +/// `stmts[at]` is a `super(...)` statement. Rewrites the arrows created +/// before it returns (in `stmts[..at]` and in its arguments) to read the +/// binding cell, when any of them reads `this`; true when it did. +fn bind_before_super(ctx: &mut LoweringContext, stmts: &mut Vec, at: usize) -> bool { + let cell = ctx.fresh_local(); + let read = Expr::PropertyGet { + object: Box::new(Expr::LocalGet(cell)), + property: "t".to_string(), + byte_offset: 0, + byte_end: 0, + }; + // Rewrite a copy: only an arrow that reads `this` makes it read the cell, + // so a constructor without one stays exactly as it was. + let mut before: Vec = stmts[..at].to_vec(); + crate::analysis::substitute_lexical_this_in_stmts(&mut before, &read); + let Stmt::Expr(Expr::SuperCall(args)) = &stmts[at] else { + return false; + }; + let mut args = args.clone(); + for arg in args.iter_mut().filter(|a| is_this_arrow(a)) { + crate::analysis::substitute_lexical_this_in_expr(arg, &read); + } + let mut refs = Vec::new(); + let mut visited = std::collections::HashSet::new(); + for s in &before { + crate::analysis::collect_local_refs_stmt(s, &mut refs, &mut visited); + } + for a in &args { + crate::analysis::collect_local_refs_expr(a, &mut refs, &mut visited); + } + if !refs.contains(&cell) { + return false; + } + stmts.splice(..at, before); + stmts[at] = Stmt::Expr(Expr::SuperCall(args)); + stmts.insert(at + 1, Stmt::Expr(Expr::PropertySet { + object: Box::new(Expr::LocalGet(cell)), + property: "t".to_string(), + value: Box::new(Expr::This), + })); + stmts.insert(0, cell_decl(cell)); + true +} + fn is_this_arrow(expr: &Expr) -> bool { matches!( expr, diff --git a/test-files/test_gap_derived_ctor_arrow_returned_this.ts b/test-files/test_gap_derived_ctor_arrow_returned_this.ts new file mode 100644 index 00000000000..40ad9201d6e --- /dev/null +++ b/test-files/test_gap_derived_ctor_arrow_returned_this.ts @@ -0,0 +1,75 @@ +// `super()` initialises a derived constructor's `this` to the object the base +// constructor returned. An arrow created before `super()` returns shares that +// binding: it observes the returned object, not the discarded receiver. +class Callable { + constructor(fn: Function, ...[opts]: [{ attach?: object }?]) { + return Object.assign( + Object.setPrototypeOf(fn.bind(this), (this as any).constructor.prototype), + opts?.attach, + ); + } +} + +class Base extends Callable { + att: any; + $: any; + label: string; + constructor(att: any, $: any) { + // The arrow is an argument to `super()`: created before `this` is bound. + super((x: number) => (this as any).go(x), { attach: att }); + this.att = att; + this.$ = $; + this.label = "L" + this.$.name; + } + go(x: number) { + return "go:" + this.$.name + x; + } +} + +class Root extends Base { + constructor(att: any, $: any) { + super(att, $); + Object.defineProperty(this, " kind", { value: "root", enumerable: false }); + } +} + +const n: any = new Root({ id: 1 }, { name: "S" }); +console.log("typeof", typeof n, "label", n.label, "att", n.att.id); +console.log("call", n(2)); + +// A default parameter reading `this` inside the arrow resolves the same way. +class Hook extends Callable { + tag = "hook"; + constructor() { + super((data: string, onFail: () => string = () => (this as any).tag) => data + ":" + onFail(), {}); + } +} +console.log("default param", (new Hook() as any)("d")); + +// An arrow nested in an arrow created before `super()` follows the same binding. +class Nested extends Callable { + tag = "nested"; + constructor() { + super(() => () => (this as any).tag, {}); + } +} +console.log("nested", (new Nested() as any)()()); + +// A plain base constructor that returns nothing keeps the receiver. +class Plain { + tag = "plain"; +} +class PlainChild extends Plain { + read: () => string; + constructor() { + const early = () => (this as any).tag; + super(); + this.read = early; + } +} +console.log("plain", new PlainChild().read()); + +// Each construction sees its own returned object. +const a: any = new Root({ id: 1 }, { name: "A" }); +const b: any = new Root({ id: 2 }, { name: "B" }); +console.log("distinct", a(1), b(1)); From 0d6cc87b647ebfbac31e16ef6ac01271f6f044f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 30 Oct 2026 18:42:52 +0000 Subject: [PATCH 14/84] fix(wpfacts): forwarded copies of one literal shape join, so the unit fixpoint terminates Several units of a module can forward copies of a literal shape no unit there births (effect cli/internal/parser.js: three units forward the {onNone, onSome} shape). The forward branch replaced the entry, so the last forwarder of a round won; a reader solved before it recorded an earlier copy, saw it changed at the round end, and was solved again every round (the Supabase CLI facts pass never ended). The copies now join, ascending from the last round entry, as several owners of one literal shape do (DECISIONS 112). --- .../0-wpfacts-forwarded-copies-join.md | 1 + crates/perry-wpfacts/src/units/driver.rs | 23 ++++++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 changelog.d/0-wpfacts-forwarded-copies-join.md diff --git a/changelog.d/0-wpfacts-forwarded-copies-join.md b/changelog.d/0-wpfacts-forwarded-copies-join.md new file mode 100644 index 00000000000..67786853b69 --- /dev/null +++ b/changelog.d/0-wpfacts-forwarded-copies-join.md @@ -0,0 +1 @@ +Per-unit facts reach their fixpoint when several units forward copies of one literal shape that no unit of the module births: the copies join, ascending from the last round, instead of the last forwarder of a round replacing the others. Before, a reader solved between two forwarders never read what the round left and was solved again every round (the Supabase CLI never finished its facts pass). diff --git a/crates/perry-wpfacts/src/units/driver.rs b/crates/perry-wpfacts/src/units/driver.rs index a7c5d0cee38..c16ce3a5bd6 100644 --- a/crates/perry-wpfacts/src/units/driver.rs +++ b/crates/perry-wpfacts/src/units/driver.rs @@ -446,9 +446,26 @@ pub(crate) fn run(sorted: &[(&str, &Module)], cfg: &FactsConfig) -> ProgramFacts published_layouts.insert(c.display.clone()); if u.layouts_fwd.contains(&c.display) { if !owned_layouts.contains(&c.display) { - input - .layouts - .insert(c.display.clone(), Digested::new(c.clone())); + // Several units forward copies of one literal + // shape no unit here births: its entry is their + // join, ascending from the last round's copy, as + // for several owners (DECISIONS 112). Replaced, + // the last forwarder of a round would win and a + // reader solved before it would never read what + // the round leaves. + let e = match input.layouts.get(&c.display).map(|e| &e.c) { + Some(p) + if c.literal_shape + && p.literal_shape + && copy_of.contains_key(&c.display) => + { + let mut j = p.clone(); + j.join(c); + j + } + _ => c.clone(), + }; + input.layouts.insert(c.display.clone(), Digested::new(e)); copy_of.insert(c.display.clone(), si); } continue; From 93a125bf7c3d1cea863973bba33afc319aaf4c3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 30 Oct 2026 19:45:09 +0100 Subject: [PATCH 15/84] changelog: number the PR fragment --- ...rded-copies-join.md => 13579-wpfacts-forwarded-copies-join.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{0-wpfacts-forwarded-copies-join.md => 13579-wpfacts-forwarded-copies-join.md} (100%) diff --git a/changelog.d/0-wpfacts-forwarded-copies-join.md b/changelog.d/13579-wpfacts-forwarded-copies-join.md similarity index 100% rename from changelog.d/0-wpfacts-forwarded-copies-join.md rename to changelog.d/13579-wpfacts-forwarded-copies-join.md From 9f4a2dbf7308b23c3a4cc6411f0f08a59f4556fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 30 Oct 2026 19:47:08 +0100 Subject: [PATCH 16/84] fix(wpfacts): the no-fixpoint report compares every unit output It compared contracts, own functions, values, cells and shared writes, so a unit whose forwarded layout changed every round read as changed nothing (#13579). Layouts, argument, store, write, receiver, method and constructor-run contracts, defined functions, method runs, forwarded layouts and reports are compared too. --- crates/perry-wpfacts/src/units/driver.rs | 33 ++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/perry-wpfacts/src/units/driver.rs b/crates/perry-wpfacts/src/units/driver.rs index 9fe24f0e575..14ef7577bbd 100644 --- a/crates/perry-wpfacts/src/units/driver.rs +++ b/crates/perry-wpfacts/src/units/driver.rs @@ -1733,6 +1733,39 @@ fn output_changes(u: &ProgramFacts, old: &ProgramFacts) -> Vec { keyed(&mut out, "class", &named(&u.contracts_out), &named(&old.contracts_out)); keyed(&mut out, "value", &named(&u.values_out), &named(&old.values_out)); keyed(&mut out, "cell", &cells(&joined), &cells(&old.cells_out)); + // Every other output the driver publishes or feeds back to the units: + // a change in any of them re-seeds a reader, so each is compared (a + // forwarded layout replaced each round once read as "nothing"). + fn by_display(v: &[ClassContract]) -> Vec<(String, &ClassContract)> { + v.iter().map(|c| (c.display.clone(), c)).collect() + } + fn plain(v: &[(String, C)]) -> Vec<(String, &C)> { + v.iter().map(|(k, c)| (k.clone(), c)).collect() + } + fn stores(v: &[(String, String, ValueContract)]) -> Vec<(String, &ValueContract)> { + v.iter().map(|(d, k, c)| (format!("{d}.{k}"), c)).collect() + } + keyed(&mut out, "layout", &by_display(&u.layouts_out), &by_display(&old.layouts_out)); + keyed(&mut out, "args", &plain(&u.args_out), &plain(&old.args_out)); + keyed(&mut out, "ctor_args", &named(&u.ctor_args_out), &named(&old.ctor_args_out)); + keyed(&mut out, "store", &stores(&u.stores_out), &stores(&old.stores_out)); + keyed(&mut out, "write", &named(&u.writes_out), &named(&old.writes_out)); + keyed(&mut out, "this_args", &plain(&u.this_args_out), &plain(&old.this_args_out)); + keyed(&mut out, "method_args", &plain(&u.method_args_out), &plain(&old.method_args_out)); + keyed(&mut out, "ctor_runs", &plain(&u.ctor_runs_out), &plain(&old.ctor_runs_out)); + for (what, new, was) in [ + ("fn defined", &u.fns_defined, &old.fns_defined), + ("method run", &u.method_runs_out, &old.method_runs_out), + ("forwarded layout", &u.layouts_fwd, &old.layouts_fwd), + ("report", &u.reports, &old.reports), + ] { + for n in new.iter().filter(|n| !was.contains(n)) { + out.push(format!("+{what} {n}")); + } + for n in was.iter().filter(|n| !new.contains(n)) { + out.push(format!("-{what} {n}")); + } + } let added = u.shared_out.iter().filter(|w| !old.shared_out.contains(w)).count(); let gone = old.shared_out.iter().filter(|w| !u.shared_out.contains(w)).count(); if added + gone != 0 { From 3648b80022ae784b9ff8f82a562b9a3ad5e549d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 30 Oct 2026 20:45:02 +0000 Subject: [PATCH 17/84] test(wpfacts): forwarded copies of one literal shape reach a fixpoint effect cli/internal/parser.ts reduced to one module: the parse state mode holds { _tag } literals that several units forward. Before #13579 the per-unit rounds never end (the test times out at 60 s); with the join they end after 5 rounds. --- crates/perry-wpfacts/src/tests/unit_mode.rs | 2 + .../unit_mode/forwarded_literal_copies.rs | 48 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 crates/perry-wpfacts/src/tests/unit_mode/forwarded_literal_copies.rs diff --git a/crates/perry-wpfacts/src/tests/unit_mode.rs b/crates/perry-wpfacts/src/tests/unit_mode.rs index f31fd92c096..f625c0d1181 100644 --- a/crates/perry-wpfacts/src/tests/unit_mode.rs +++ b/crates/perry-wpfacts/src/tests/unit_mode.rs @@ -595,5 +595,7 @@ mod return_transfers; #[path = "unit_mode/commonjs_helper.rs"] mod commonjs_helper; +#[path = "unit_mode/forwarded_literal_copies.rs"] +mod forwarded_literal_copies; #[path = "unit_mode/pub_callable.rs"] mod pub_callable; diff --git a/crates/perry-wpfacts/src/tests/unit_mode/forwarded_literal_copies.rs b/crates/perry-wpfacts/src/tests/unit_mode/forwarded_literal_copies.rs new file mode 100644 index 00000000000..172226868e2 --- /dev/null +++ b/crates/perry-wpfacts/src/tests/unit_mode/forwarded_literal_copies.rs @@ -0,0 +1,48 @@ +//! effect's `cli/internal/parser.ts` (effect/cli `Command`), reduced: the +//! parse state's `mode` holds `{ _tag }` literals, born in +//! `createParseState` and replaced in `processValue`, and the state goes +//! through the units of the scan. Several units forward copies of one +//! literal shape: replaced, not joined, the last forwarder of a round won, +//! a reader solved before it saw the entry change at the end of every +//! round, and the rounds never ended (#13579). + +use super::*; + +const P: &str = r#" +const buildSubcommandIndex = () => { +}; +const createParseState = () => ({ + mode: { _tag: "AwaitingFirstValue" } +}); +const resolveFirstValue = (context) => { + const subIndex = buildSubcommandIndex(); +}; +const processValue = (value, cursor, context, state) => { + if (state.mode._tag === "AwaitingFirstValue") { + const result = resolveFirstValue(state); + state.mode = { _tag: "CollectingArguments" }; + } +}; +const scanCommandLevel = () => { + const state = createParseState(); + for (let token = cursor.take(); token; token = cursor.take()) { + if (token._tag === "Value") { + const subResult = processValue(token.value, cursor, context, state); + } + } +}; +"#; + +#[test] +fn forwarded_copies_of_one_literal_shape_reach_a_fixpoint() { + let mods: &'static [(&'static str, &'static str)] = &[("p", P)]; + // Before: no fixpoint (the solve runs forever), so the bound is a wait. + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(units_of(mods)); + }); + let pf = rx + .recv_timeout(std::time::Duration::from_secs(60)) + .expect("per-unit facts reach a fixpoint"); + assert!(pf.rounds <= 5, "rounds {}", pf.rounds); +} From 777531e971db22f82441364f131df4db9af667fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 30 Oct 2026 21:03:25 +0000 Subject: [PATCH 18/84] hir: super_arrow_this PropertySet carries byte_offset/byte_end (merge of #13477 with main) --- crates/perry-hir/src/lower_decl/super_arrow_this.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/perry-hir/src/lower_decl/super_arrow_this.rs b/crates/perry-hir/src/lower_decl/super_arrow_this.rs index 3e9709703e3..cebb29eddbd 100644 --- a/crates/perry-hir/src/lower_decl/super_arrow_this.rs +++ b/crates/perry-hir/src/lower_decl/super_arrow_this.rs @@ -94,6 +94,8 @@ fn bind_before_super(ctx: &mut LoweringContext, stmts: &mut Vec, at: usize object: Box::new(Expr::LocalGet(cell)), property: "t".to_string(), value: Box::new(Expr::This), + byte_offset: 0, + byte_end: 0, })); stmts.insert(0, cell_decl(cell)); true From ebee3b16273dd65da574a2c95af787d081f6b9b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 30 Oct 2026 21:08:08 +0000 Subject: [PATCH 19/84] wpfacts: driver.rs merge of #13477 keeps both appended blocks whole --- crates/perry-wpfacts/src/units/driver.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/perry-wpfacts/src/units/driver.rs b/crates/perry-wpfacts/src/units/driver.rs index 221ad50ad02..d1ffb947f57 100644 --- a/crates/perry-wpfacts/src/units/driver.rs +++ b/crates/perry-wpfacts/src/units/driver.rs @@ -1796,6 +1796,9 @@ fn capture_diagnostics( Some((p.to_path_buf(), spans)) } _ => None, + } +} + /// The rounds after which the driver names what keeps it going (the corpus /// reaches its fixpoint in at most ~15). A round count that keeps growing is /// a unit whose outputs never settle: each report at round 64, 128, 256, ... From 62e8b30a5da365285683e81004235a6b785338ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 30 Oct 2026 21:20:51 +0000 Subject: [PATCH 20/84] WIP snap census runner --- crates/perry-codegen/src/codegen/entry.rs | 5 + crates/perry-codegen/src/runtime_decls/mod.rs | 2 + crates/perry-runtime/src/arena/block.rs | 1 + crates/perry-runtime/src/arena/inline.rs | 2 +- crates/perry-runtime/src/arena/mod.rs | 4 + .../src/arena/region/reservation.rs | 25 + crates/perry-runtime/src/async_context.rs | 6 +- .../src/async_context/job_domain.rs | 4 + crates/perry-runtime/src/builtins/globals.rs | 6 + crates/perry-runtime/src/calendar.rs | 3 + crates/perry-runtime/src/clock.rs | 2 + crates/perry-runtime/src/fs/validate.rs | 2 + crates/perry-runtime/src/gc/census.rs | 1 + crates/perry-runtime/src/gc/instruments.rs | 1 + crates/perry-runtime/src/gc/mod.rs | 1 + crates/perry-runtime/src/gc/policy.rs | 2 + crates/perry-runtime/src/gc/roots.rs | 13 + crates/perry-runtime/src/i18n.rs | 1 + crates/perry-runtime/src/intl/time_zone.rs | 1 + crates/perry-runtime/src/lib.rs | 1 + crates/perry-runtime/src/math.rs | 1 + crates/perry-runtime/src/module_require.rs | 9 +- crates/perry-runtime/src/navigator.rs | 1 + crates/perry-runtime/src/os.rs | 6 + .../src/os/os_process_emitter.rs | 2 + .../src/os_process_stream_write.rs | 2 + crates/perry-runtime/src/process.rs | 1 + .../perry-runtime/src/process/credentials.rs | 4 + crates/perry-runtime/src/process/env_misc.rs | 6 + crates/perry-runtime/src/process/tick_args.rs | 1 + crates/perry-runtime/src/promise/mod.rs | 4 + crates/perry-runtime/src/snap_census.rs | 285 ++++ .../perry-runtime/src/snap_census/runner.rs | 1221 +++++++++++++++++ crates/perry-runtime/src/timer.rs | 2 + crates/perry-runtime/src/timer/store.rs | 1 + crates/perry-runtime/src/tty.rs | 6 + crates/perry-stdlib/src/crypto/random.rs | 6 + .../compile/optimized_libs/features.rs | 1 + 38 files changed, 1638 insertions(+), 4 deletions(-) create mode 100644 crates/perry-runtime/src/snap_census.rs create mode 100644 crates/perry-runtime/src/snap_census/runner.rs diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index c39242600c1..c8706bfbcab 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -696,6 +696,11 @@ pub(super) fn compile_module_entry( } blk.call_void(&format!("{}__init", prefix), &[]); } + // SNAP census, instrumented compiles only (`PERRY_SNAP_CENSUS` + // set while compiling): the entry's own body starts here. + if std::env::var_os("PERRY_SNAP_CENSUS").is_some_and(|v| !v.is_empty()) { + blk.call_void("js_snap_entry_body", &[]); + } } // Mark the boundary between init prelude and user code so // hoisted post-init setup (cached `@perry_class_keys_*` loads diff --git a/crates/perry-codegen/src/runtime_decls/mod.rs b/crates/perry-codegen/src/runtime_decls/mod.rs index 33536b3dbce..75c137e3202 100644 --- a/crates/perry-codegen/src/runtime_decls/mod.rs +++ b/crates/perry-codegen/src/runtime_decls/mod.rs @@ -184,6 +184,8 @@ pub fn declare_phase2_1(module: &mut LlModule) { /// which the mark phase scans alongside the stack. pub fn declare_phase_a_strings(module: &mut LlModule) { module.declare_function("js_gc_register_global_root", VOID, &[I64]); + // SNAP census: the entry body's start (instrumented compiles only). + module.declare_function("js_snap_entry_body", VOID, &[]); // Phase B (core types) additions live here too — split into a separate // function once they grow. diff --git a/crates/perry-runtime/src/arena/block.rs b/crates/perry-runtime/src/arena/block.rs index 0a350638a79..9b1498dd9b7 100644 --- a/crates/perry-runtime/src/arena/block.rs +++ b/crates/perry-runtime/src/arena/block.rs @@ -580,6 +580,7 @@ impl ArenaBlock { let ptr = unsafe { self.data.add(aligned_offset) }; let next = bumped.checked_add(pad - 1)? & !(pad - 1); self.offset = next; + crate::snap_census::birth(ptr, size); Some(ptr) } diff --git a/crates/perry-runtime/src/arena/inline.rs b/crates/perry-runtime/src/arena/inline.rs index 3f9b7ad64ce..28d0b5d3e3f 100644 --- a/crates/perry-runtime/src/arena/inline.rs +++ b/crates/perry-runtime/src/arena/inline.rs @@ -197,7 +197,7 @@ pub fn arena_start_fresh_general_block() { /// extent and the existing coloring/seeding path. #[inline(always)] pub(crate) fn allocation_limit(offset: usize, block_size: usize) -> usize { - if crate::gc::gc_birth_extra_flags() != 0 { + if crate::gc::gc_birth_extra_flags() != 0 || crate::snap_census::inline_bump_closed() { 0 } else { super::alloc_sample::inline_limit(offset, block_size) diff --git a/crates/perry-runtime/src/arena/mod.rs b/crates/perry-runtime/src/arena/mod.rs index e76c2d0abae..3f0059a3dc2 100644 --- a/crates/perry-runtime/src/arena/mod.rs +++ b/crates/perry-runtime/src/arena/mod.rs @@ -229,3 +229,7 @@ pub(crate) use region::new_test_block_backing; pub(crate) use region::release_test_block_backing; pub(crate) use inline::refresh_inline_allocation_limit; +#[cfg(target_os = "linux")] +pub(crate) use region::reservation::{ + for_each_mapped as snap_reservation_regions, payload_bounds as snap_reservation_bounds, +}; diff --git a/crates/perry-runtime/src/arena/region/reservation.rs b/crates/perry-runtime/src/arena/region/reservation.rs index e24935b0e66..10a3026d684 100644 --- a/crates/perry-runtime/src/arena/region/reservation.rs +++ b/crates/perry-runtime/src/arena/region/reservation.rs @@ -434,10 +434,35 @@ pub(super) unsafe fn map(kind: Kind, mapped: usize) -> *mut u8 { d.publication .store(serial() | kind_bits(kind) | MAPPED, SeqCst); } + crate::snap_census::region_mapped(base, mapped); return data.cast(); } std::ptr::null_mut() } +/// `[base, base + len)` of the reservation's payload (empty before it +/// exists). The SNAP census sizes its page map with it. +pub(crate) fn payload_bounds() -> (usize, usize) { + initialized().map_or((0, 0), |r| (r.base, r.base + r.len)) +} + +/// Every mapped region, `[base, end)`, once each. +pub(crate) fn for_each_mapped(mut f: impl FnMut(usize, usize)) { + let Some(r) = existing() else { + return; + }; + let mut slot = 0; + while slot < r.len / ALIGN { + let d = r.descriptor(slot); + if d.publication.load(SeqCst) & MAPPED != 0 { + let (base, end) = (d.base.load(SeqCst), d.end.load(SeqCst)); + f(base, end); + slot = (end - r.base).div_ceil(ALIGN).max(slot + 1); + } else { + slot += 1; + } + } +} + pub(super) unsafe fn unmap(data: *mut u8, mapped: usize) { let r = existing().expect("region reservation missing"); let _guard = r.allocation.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/crates/perry-runtime/src/async_context.rs b/crates/perry-runtime/src/async_context.rs index 51bc2d854a4..bef737a001e 100644 --- a/crates/perry-runtime/src/async_context.rs +++ b/crates/perry-runtime/src/async_context.rs @@ -17,6 +17,10 @@ pub use job_domain::JobDomainToken; pub struct AsyncContextSnapshot { entries: Vec, job_domain: Option, + /// SNAP census: the init that enqueued the job this snapshot belongs to + /// (stamped at enqueue; a job runs as that init). + #[cfg(perry_gc_instruments)] + pub(crate) snap_init: u32, } /// A native context vector in an ordinary young, movable GC holder. The @@ -169,7 +173,7 @@ pub fn token_from_storage_value(value: f64) -> Option { // as reaching JS. `HotKey::with` is `#[inline(always)]`, and its only indirect // call is the audited cold `resolve`. crate::perry_thread_local! { - static ACTIVE_CONTEXT: RefCell = const { RefCell::new(AsyncContextSnapshot { entries: Vec::new(), job_domain: None }) }; + static ACTIVE_CONTEXT: RefCell = const { RefCell::new(AsyncContextSnapshot { entries: Vec::new(), job_domain: None, #[cfg(perry_gc_instruments)] snap_init: 0 }) }; } unsafe fn token_ref(token: i64) -> &'static AsyncLocalStorageTokenInner { diff --git a/crates/perry-runtime/src/async_context/job_domain.rs b/crates/perry-runtime/src/async_context/job_domain.rs index 3b9d3c1c2fe..da82f96385a 100644 --- a/crates/perry-runtime/src/async_context/job_domain.rs +++ b/crates/perry-runtime/src/async_context/job_domain.rs @@ -158,6 +158,10 @@ impl AsyncContextSnapshot { } pub(crate) fn prepare_job_enqueue(&mut self) -> bool { + #[cfg(perry_gc_instruments)] + { + self.snap_init = crate::snap_census::job_owner(); + } prepare_enqueue(&mut self.job_domain) } diff --git a/crates/perry-runtime/src/builtins/globals.rs b/crates/perry-runtime/src/builtins/globals.rs index dcebc0bac66..f2d37d9ba17 100644 --- a/crates/perry-runtime/src/builtins/globals.rs +++ b/crates/perry-runtime/src/builtins/globals.rs @@ -538,6 +538,7 @@ pub extern "C" fn js_queue_microtask(callback: i64) { #[no_mangle] pub extern "C" fn js_queue_next_tick(callback: i64) { + crate::effect!(host_write, tick); queue_microtask_with_type(callback, "TickObject", Vec::new()); } @@ -551,6 +552,7 @@ pub extern "C" fn js_queue_next_tick(callback: i64) { /// `n_args == 0`. #[no_mangle] pub unsafe extern "C" fn js_queue_next_tick_args(callback: i64, args_ptr: *const f64, n_args: i32) { + crate::effect!(host_write, tick); let args: Vec = if args_ptr.is_null() || n_args <= 0 { Vec::new() } else { @@ -618,6 +620,7 @@ pub fn restore_queued_microtask_contexts() { let mut stack = stack.borrow_mut(); while let Some(previous) = stack.pop() { crate::async_context::restore_context(previous); + crate::snap_census::job_exit(); } }); } @@ -665,6 +668,8 @@ pub(crate) fn drain_queued_microtasks_count() -> i32 { let callback_handle = scope.root_raw_const_ptr(cb as *const crate::closure::ClosureHeader); let arg_handles = scope.root_nanbox_f64_slice(&args); + #[cfg(perry_gc_instruments)] + crate::snap_census::job_enter(context.snap_init); let previous = crate::async_context::enter_context(&context); QUEUED_MICROTASK_PREV_CONTEXTS.with(|stack| { stack.borrow_mut().push(previous); @@ -770,6 +775,7 @@ pub(crate) fn drain_queued_microtasks_count() -> i32 { QUEUED_MICROTASK_PREV_CONTEXTS.with(|stack| { if let Some(previous) = stack.borrow_mut().pop() { crate::async_context::restore_context(previous); + crate::snap_census::job_exit(); } }); ran += 1; diff --git a/crates/perry-runtime/src/calendar.rs b/crates/perry-runtime/src/calendar.rs index d2599716c7e..6951a5616df 100644 --- a/crates/perry-runtime/src/calendar.rs +++ b/crates/perry-runtime/src/calendar.rs @@ -5,6 +5,7 @@ /// UTC timestamp to get the local-time representation (i.e. local - UTC). #[cfg(unix)] pub(crate) fn timestamp_to_local_components(secs: i64) -> (i32, u32, u32, u32, u32, u32, i64) { + crate::effect!(host_read, tz); unsafe { let t: libc::time_t = secs as libc::time_t; let mut tm: libc::tm = std::mem::zeroed(); @@ -28,12 +29,14 @@ pub(crate) fn timestamp_to_local_components(secs: i64) -> (i32, u32, u32, u32, u /// so local time is UTC with a zero offset. #[cfg(target_os = "wasi")] pub(crate) fn timestamp_to_local_components(secs: i64) -> (i32, u32, u32, u32, u32, u32, i64) { + crate::effect!(host_read, tz); let (y, m, d, h, mi, s) = timestamp_to_components(secs); (y, m, d, h, mi, s, 0) } #[cfg(windows)] pub(crate) fn timestamp_to_local_components(secs: i64) -> (i32, u32, u32, u32, u32, u32, i64) { + crate::effect!(host_read, tz); unsafe { let t: libc::time_t = secs as libc::time_t; let mut tm: libc::tm = std::mem::zeroed(); diff --git a/crates/perry-runtime/src/clock.rs b/crates/perry-runtime/src/clock.rs index 2c51fbc2ef6..998c76e828d 100644 --- a/crates/perry-runtime/src/clock.rs +++ b/crates/perry-runtime/src/clock.rs @@ -1,6 +1,7 @@ //! Numeric wall and performance clocks. use std::time::{SystemTime, UNIX_EPOCH}; pub(crate) fn wall_millis() -> f64 { + crate::effect!(host_read, clock); if let Some(now) = crate::timer::js_mock_timers_date_now() { return now; } @@ -14,5 +15,6 @@ pub(crate) fn wall_millis() -> f64 { /// Returns ms since performance.timeOrigin as f64. #[no_mangle] pub extern "C" fn js_performance_now() -> f64 { + crate::effect!(host_read, clock); crate::perf_clock::performance_now_ms() } diff --git a/crates/perry-runtime/src/fs/validate.rs b/crates/perry-runtime/src/fs/validate.rs index f2a034d91ea..b02121a4d40 100644 --- a/crates/perry-runtime/src/fs/validate.rs +++ b/crates/perry-runtime/src/fs/validate.rs @@ -200,6 +200,7 @@ static KEEP_JS_VALIDATE_EVENT_LISTENER: unsafe extern "C" fn(i64, *const u8, u32 /// `readdirSync`, `unlinkSync`, …). Throws `ERR_INVALID_ARG_TYPE` on any /// non path-like value (including numbers). No-op when the value is valid. pub(crate) fn validate_path(arg_name: &str, value: f64) { + crate::effect!(host_read, fs); if !validate_path_like_value(arg_name, value) { throw_invalid_path_arg(arg_name, value); } @@ -211,6 +212,7 @@ pub(crate) fn validate_path(arg_name: &str, value: f64) { /// throws `EBADF` (matching `fs.readFileSync(123)`); anything else throws /// `ERR_INVALID_ARG_TYPE`. `syscall` names the operation for the EBADF error. pub(crate) fn validate_path_or_fd(arg_name: &str, value: f64, syscall: &'static str) { + crate::effect!(host_read, fs); if validate_path_like_value(arg_name, value) { return; } diff --git a/crates/perry-runtime/src/gc/census.rs b/crates/perry-runtime/src/gc/census.rs index 2cf2886abaf..2f21530b1fa 100644 --- a/crates/perry-runtime/src/gc/census.rs +++ b/crates/perry-runtime/src/gc/census.rs @@ -449,6 +449,7 @@ impl Census { } } self.space_live[space].add(size); + crate::snap_census::census_live(header.cast(), obj_type, size); if t > GC_TYPE_MAX as usize { return; } diff --git a/crates/perry-runtime/src/gc/instruments.rs b/crates/perry-runtime/src/gc/instruments.rs index c7cc9486b6f..6577f4586c2 100644 --- a/crates/perry-runtime/src/gc/instruments.rs +++ b/crates/perry-runtime/src/gc/instruments.rs @@ -573,6 +573,7 @@ pub(crate) const INSTRUMENT_KNOBS: &[&str] = &[ "PERRY_GC_VERIFY_CLASSIFIER", "PERRY_STACK_SYMBOLS", "PERRY_FIELD_REPR_VERIFY", + "PERRY_SNAP_CENSUS", ]; /// The first instrument knob set (non-empty) in the environment, if any. diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 339946e539b..14cb1f849b3 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -92,6 +92,7 @@ pub use malloc::*; mod hot_tls; pub(crate) use hot_tls::*; mod roots; +pub(crate) use roots::for_each_global_root_slot; pub use roots::*; mod full_trace; pub(crate) use full_trace::*; diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index 361db17ab6c..8f0ed32e75f 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -5104,6 +5104,7 @@ pub(super) static GC_UNSAFE_WARNED: std::sync::atomic::AtomicBool = /// worker threads are active (see GC_UNSAFE_ZONES). #[no_mangle] pub extern "C" fn js_gc_collect() { + crate::snap_census::explicit_gc(); if manual_gc_blocked_by_unsafe_zone() { return; } @@ -5116,6 +5117,7 @@ pub extern "C" fn js_gc_collect() { let start = std::time::Instant::now(); manual_gc_collect_now(); super::instruments::note_full_collect_us(start.elapsed().as_micros() as u64); + crate::snap_census::explicit_gc_done(); } /// Run an explicit (`gc()`) full collection, with **precise roots** — the same diff --git a/crates/perry-runtime/src/gc/roots.rs b/crates/perry-runtime/src/gc/roots.rs index 7d9d34d2ba5..b6052fff137 100644 --- a/crates/perry-runtime/src/gc/roots.rs +++ b/crates/perry-runtime/src/gc/roots.rs @@ -555,6 +555,7 @@ pub extern "C" fn js_gc_register_global_root(ptr: i64) { /// failure unwinds from here; across the C ABI it aborts the process. pub(super) fn register_global_root(root: *mut u64) { if !root.is_null() { + let _bookkeeping = crate::snap_census::bookkeeping(); unsafe { super::root_words::verify_jsvalue_root_word(*root, "global"); runtime_write_barrier_root_nanbox(*root); @@ -563,6 +564,18 @@ pub(super) fn register_global_root(root: *mut u64) { GLOBAL_ROOTS.with(|roots| { roots.borrow_mut().push(root); }); + crate::snap_census::global_root(root as usize); +} + +/// Every registered module-global root slot (the SNAP census tracks them). +pub(crate) fn for_each_global_root_slot(mut f: impl FnMut(usize)) { + GLOBAL_ROOTS.with(|roots| { + for &root in roots.borrow().iter() { + if !root.is_null() { + f(root as usize); + } + } + }); } /// Suppress GC triggers. While suppressed, `gc_check_trigger` is a no-op. diff --git a/crates/perry-runtime/src/i18n.rs b/crates/perry-runtime/src/i18n.rs index 52f058f6933..0cce7d6e717 100644 --- a/crates/perry-runtime/src/i18n.rs +++ b/crates/perry-runtime/src/i18n.rs @@ -175,6 +175,7 @@ pub extern "C" fn perry_i18n_init( /// Detect the system locale from environment or platform APIs. fn detect_system_locale() -> Option { + 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 diff --git a/crates/perry-runtime/src/intl/time_zone.rs b/crates/perry-runtime/src/intl/time_zone.rs index 970187c6ba2..abe2d01c69c 100644 --- a/crates/perry-runtime/src/intl/time_zone.rs +++ b/crates/perry-runtime/src/intl/time_zone.rs @@ -14,6 +14,7 @@ use super::*; /// option given — must ALWAYS yield a valid zone: an unrecognized host zone (a /// broken `TZ`) falls back to `"UTC"`, never throws. pub(crate) fn resolved_date_time_zone(options: f64) -> String { + crate::effect!(host_read, tz); let explicit = get_option_string(options, "timeZone"); let tz = explicit .clone() diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index d2977dbb295..8e949a286c7 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -196,6 +196,7 @@ pub mod native_payload_stream_abi; #[cfg(any(target_os = "linux", target_os = "android"))] mod native_stack; pub mod native_value_profile; +pub mod snap_census; pub mod navigator; pub mod net_validate; pub mod node_api_host; diff --git a/crates/perry-runtime/src/math.rs b/crates/perry-runtime/src/math.rs index afd209a0d8d..04cda22a59c 100644 --- a/crates/perry-runtime/src/math.rs +++ b/crates/perry-runtime/src/math.rs @@ -285,6 +285,7 @@ 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 { + crate::effect!(host_read, random); let mut rng = rand::rng(); rng.random::() } diff --git a/crates/perry-runtime/src/module_require.rs b/crates/perry-runtime/src/module_require.rs index 86bc259408e..4d93b1f0800 100644 --- a/crates/perry-runtime/src/module_require.rs +++ b/crates/perry-runtime/src/module_require.rs @@ -1233,6 +1233,7 @@ pub extern "C" fn js_register_path_module(path_value: f64, exports: f64) { /// `init_addr` is codegen's `ptrtoint` of an `extern "C" fn()` module body. #[no_mangle] pub unsafe extern "C" fn js_run_module_init_catching(init_addr: i64) { + crate::snap_census::init_part(); let init_fn: extern "C" fn() = std::mem::transmute::(init_addr as usize); let boundary = MODULE_PATH_REGISTRY.with(|r| r.begin_module_boundary()); let outcome = crate::exception::js_call_catching(|| { @@ -1313,12 +1314,15 @@ fn run_path_initializer(addr: usize) -> Result<(), u64> { // SAFETY: `addr` came from codegen's `ptrtoint` of an `extern "C" fn()` // module initializer and was accepted once for this canonical path. let init_fn: extern "C" fn() = unsafe { std::mem::transmute::(addr) }; - crate::exception::js_call_catching(|| { + crate::snap_census::init_enter(addr); + let outcome = crate::exception::js_call_catching(|| { init_fn(); undefined() }) .map(|_| ()) - .map_err(f64::to_bits) + .map_err(f64::to_bits); + crate::snap_census::init_exit(outcome.is_err()); + outcome } fn require_path_key(key: &str) -> Result, PathModuleRequireError> { @@ -1331,6 +1335,7 @@ fn require_path_key(key: &str) -> Result, PathModuleRequireError> { /// partial exports, while unrelated waiters receive only the final namespace. #[no_mangle] pub extern "C" fn js_require_path_module(path_value: f64) -> f64 { + let _require = crate::snap_census::require_scope(); let path = value_to_string(path_value, "id"); let key = module_lookup_key(&path); match require_path_key(&key) { diff --git a/crates/perry-runtime/src/navigator.rs b/crates/perry-runtime/src/navigator.rs index 66eca53b1aa..8c47d33e053 100644 --- a/crates/perry-runtime/src/navigator.rs +++ b/crates/perry-runtime/src/navigator.rs @@ -81,6 +81,7 @@ fn normalize_navigator_locale(locale: &str) -> Option { } fn navigator_language() -> String { + crate::effect!(host_read, locale); std::env::var("LC_ALL") .ok() .filter(|locale| !locale.is_empty() && locale != "C" && locale != "POSIX") diff --git a/crates/perry-runtime/src/os.rs b/crates/perry-runtime/src/os.rs index 55e8275c904..7ddd3144dbb 100644 --- a/crates/perry-runtime/src/os.rs +++ b/crates/perry-runtime/src/os.rs @@ -555,6 +555,7 @@ pub extern "C" fn js_process_uptime() -> f64 { /// Get the current working directory #[no_mangle] pub extern "C" fn js_process_cwd() -> *mut StringHeader { + crate::effect!(host_read, cwd); let cwd = std::env::current_dir() .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_else(|_| String::new()); @@ -579,6 +580,7 @@ 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 { + 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; @@ -598,6 +600,7 @@ 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 { + crate::effect!(host_read, argv); use crate::array::js_array_push_f64; use crate::value::js_nanbox_string; @@ -749,6 +752,7 @@ 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 { + crate::effect!(host_read, clock); use crate::bigint_hooks::js_bigint_from_u64; use crate::value::js_nanbox_bigint; @@ -766,6 +770,7 @@ 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 { + 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')`). @@ -1336,6 +1341,7 @@ fn options_request_buffer(opts_bits: i64) -> bool { static KEEP_OS_USER_INFO_OPTIONS: extern "C" fn(i64) -> *mut ObjectHeader = js_os_user_info_options; fn js_os_user_info_impl(buffer_encoding: bool) -> *mut ObjectHeader { + crate::effect!(host_read, ids); use crate::value::JSValue; crate::object::runtime_layouts::runtime_record!(obj, RECORD_F414_0); diff --git a/crates/perry-runtime/src/os/os_process_emitter.rs b/crates/perry-runtime/src/os/os_process_emitter.rs index ae716b70de3..5c13ca306b7 100644 --- a/crates/perry-runtime/src/os/os_process_emitter.rs +++ b/crates/perry-runtime/src/os/os_process_emitter.rs @@ -394,6 +394,7 @@ pub(crate) fn emit_process_event(event: &str, args: &[f64]) -> bool { /// (#3047), matching Node's EventEmitter argument handling. #[no_mangle] pub extern "C" fn js_process_on(event_bits: i64, listener_bits: i64) -> f64 { + crate::effect!(host_write, listener); register_process_listener(event_bits, listener_bits, false, false) } @@ -406,6 +407,7 @@ pub extern "C" fn js_process_add_listener(event_bits: i64, listener_bits: i64) - /// process.once(event, listener) — one-shot listener (Node parity). #[no_mangle] pub extern "C" fn js_process_once(event_bits: i64, listener_bits: i64) -> f64 { + crate::effect!(host_write, listener); register_process_listener(event_bits, listener_bits, true, false) } diff --git a/crates/perry-runtime/src/os_process_stream_write.rs b/crates/perry-runtime/src/os_process_stream_write.rs index 6713eea669a..6bafce66f29 100644 --- a/crates/perry-runtime/src/os_process_stream_write.rs +++ b/crates/perry-runtime/src/os_process_stream_write.rs @@ -188,6 +188,7 @@ fn flush_completely(handle: &mut impl std::io::Write, fd: i32) -> bool { /// chunk is never interleaved with a concurrent `console.log`, and drains /// that handle's buffer first so the two funnels stay in program order. pub(super) fn write_stdout(bytes: &[u8]) { + crate::effect!(host_write, stdio); let stdout = std::io::stdout(); let mut handle = stdout.lock(); #[cfg(unix)] @@ -206,6 +207,7 @@ pub(super) fn write_stdout(bytes: &[u8]) { /// Put `bytes` on fd 2. Same contract as [`write_stdout`]. pub(super) fn write_stderr(bytes: &[u8]) { + crate::effect!(host_write, stdio); let stderr = std::io::stderr(); let mut handle = stderr.lock(); #[cfg(unix)] diff --git a/crates/perry-runtime/src/process.rs b/crates/perry-runtime/src/process.rs index cc6d5ebbf0e..aa73f014518 100644 --- a/crates/perry-runtime/src/process.rs +++ b/crates/perry-runtime/src/process.rs @@ -405,6 +405,7 @@ pub(crate) fn module_builtin_modules_array() -> f64 { } fn process_exec_argv_value() -> f64 { + crate::effect!(host_read, argv); 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()); diff --git a/crates/perry-runtime/src/process/credentials.rs b/crates/perry-runtime/src/process/credentials.rs index fb272fee16f..4d29e421d4f 100644 --- a/crates/perry-runtime/src/process/credentials.rs +++ b/crates/perry-runtime/src/process/credentials.rs @@ -12,6 +12,7 @@ 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 { + crate::effect!(host_read, ids); #[cfg(unix)] unsafe { libc::getuid() as f64 @@ -23,6 +24,7 @@ pub extern "C" fn js_process_getuid() -> f64 { } #[no_mangle] pub extern "C" fn js_process_geteuid() -> f64 { + crate::effect!(host_read, ids); #[cfg(unix)] unsafe { libc::geteuid() as f64 @@ -34,6 +36,7 @@ pub extern "C" fn js_process_geteuid() -> f64 { } #[no_mangle] pub extern "C" fn js_process_getgid() -> f64 { + crate::effect!(host_read, ids); #[cfg(unix)] unsafe { libc::getgid() as f64 @@ -45,6 +48,7 @@ pub extern "C" fn js_process_getgid() -> f64 { } #[no_mangle] pub extern "C" fn js_process_getegid() -> f64 { + crate::effect!(host_read, ids); #[cfg(unix)] unsafe { libc::getegid() as f64 diff --git a/crates/perry-runtime/src/process/env_misc.rs b/crates/perry-runtime/src/process/env_misc.rs index 470739d072f..c2592641bf7 100644 --- a/crates/perry-runtime/src/process/env_misc.rs +++ b/crates/perry-runtime/src/process/env_misc.rs @@ -88,6 +88,7 @@ pub extern "C" fn js_process_add_uncaught_exception_capture_callback(callback: f /// during async event loop drain and V8 isolate destruction. #[no_mangle] pub extern "C" fn js_process_exit(code: f64) { + crate::effect!(host_write, exit); // #3041 — match Node's `parseAndValidateExitCode`: // * `undefined` / `null` → exit with the stored `process.exitCode` // (0 when it was never set / was reset to nullish — #6666). @@ -850,6 +851,7 @@ pub extern "C" fn js_process_constrained_memory() -> f64 { /// Returns a string pointer, or null (0) if not found #[no_mangle] pub extern "C" fn js_getenv(name_ptr: *const StringHeader) -> *mut StringHeader { + crate::effect!(host_read, env); unsafe { let name = match env_name_from_header(name_ptr) { Some(name) => name, @@ -883,6 +885,7 @@ pub extern "C" fn js_getenv(name_ptr: *const StringHeader) -> *mut StringHeader /// Node, so `??` won't clobber a legitimately empty value. #[no_mangle] pub extern "C" fn js_getenv_value(name_ptr: *const StringHeader) -> f64 { + crate::effect!(host_read, env); let ptr = js_getenv(name_ptr); let val = if ptr.is_null() { JSValue::undefined() @@ -986,6 +989,7 @@ static KEEP_PROCESS_PENDING_EXIT_CODE: extern "C" fn() -> i32 = js_process_pendi /// `js_removeenv` below, which the delete path can call directly). #[no_mangle] pub extern "C" fn js_setenv(name_ptr: *const StringHeader, value: f64) { + crate::effect!(host_write, env); use crate::value::js_jsvalue_to_string; unsafe { let name = match env_name_from_header(name_ptr) { @@ -1127,6 +1131,7 @@ static KEEP_JS_MODULE_RESOLVE_LOOKUP_PATHS: extern "C" fn(f64, f64) -> f64 = /// Unset an environment variable. Backs `delete process.env.X` (#1344). #[no_mangle] pub extern "C" fn js_removeenv(name_ptr: *const StringHeader) { + crate::effect!(host_write, env); unsafe { let name = match env_name_from_header(name_ptr) { Some(name) => name, @@ -1420,6 +1425,7 @@ fn env_name_is_settable(name: &str) -> bool { } fn js_process_env_impl() -> f64 { + crate::effect!(host_read, env); super::ipc_hooks::ensure_initialized(); let cached = CACHED_ENV.with(|c| c.get()); if cached != 0.0 { diff --git a/crates/perry-runtime/src/process/tick_args.rs b/crates/perry-runtime/src/process/tick_args.rs index 43f0b9c42b5..37ed30e4a6e 100644 --- a/crates/perry-runtime/src/process/tick_args.rs +++ b/crates/perry-runtime/src/process/tick_args.rs @@ -25,6 +25,7 @@ pub(crate) fn collect_emit_args(args: *const ArrayHeader) -> Vec { /// `args` must be a valid NaN-boxed args array pointer, or null. #[no_mangle] pub unsafe extern "C" fn js_process_next_tick(callback_bits: i64, args: *const ArrayHeader) { + crate::effect!(host_write, tick); // Mirror codegen's setTimeout/queueMicrotask validation: the timer // validator always reports the `"callback"` argument name, which is the // wording Node uses for `process.nextTick`. diff --git a/crates/perry-runtime/src/promise/mod.rs b/crates/perry-runtime/src/promise/mod.rs index ad75553abfd..1d48da69f81 100644 --- a/crates/perry-runtime/src/promise/mod.rs +++ b/crates/perry-runtime/src/promise/mod.rs @@ -817,6 +817,8 @@ pub(crate) fn clear_promise_context_for_gc(promise: *mut Promise) { } pub(crate) fn enter_microtask_context(snapshot: AsyncContextSnapshot) { + #[cfg(perry_gc_instruments)] + crate::snap_census::job_enter(snapshot.snap_init); let previous = crate::async_context::enter_owned_context(snapshot); MICROTASK_PREV_CONTEXTS.with(|stack| stack.borrow_mut().push(previous)); } @@ -825,6 +827,7 @@ pub(crate) fn restore_microtask_context() { MICROTASK_PREV_CONTEXTS.with(|stack| { if let Some(previous) = stack.borrow_mut().pop() { restore_context(previous); + crate::snap_census::job_exit(); } }); } @@ -834,6 +837,7 @@ pub(crate) fn restore_all_microtask_contexts() { let mut stack = stack.borrow_mut(); while let Some(previous) = stack.pop() { restore_context(previous); + crate::snap_census::job_exit(); } }); } diff --git a/crates/perry-runtime/src/snap_census.rs b/crates/perry-runtime/src/snap_census.rs new file mode 100644 index 00000000000..50bf9eae44a --- /dev/null +++ b/crates/perry-runtime/src/snap_census.rs @@ -0,0 +1,285 @@ +//! SNAP phase 1: what a build-time snapshot of module initialization would +//! image and what it would defer (`secret-tests/nightly/CW/SNAP_DESIGN_2026-10-30.md`). +//! +//! Two parts live here: +//! +//! - **Effect classes.** A runtime entry that observes or changes anything +//! outside the JS heap says so at its definition with [`effect!`]: +//! `target_fact` (a constant of the compile target), `host_read` (a value of +//! the machine or the invocation) or `host_write` (changes the process or +//! the world). An entry that reads and writes only the JS heap says +//! `effect!(pure)`. The declaration is read twice: by the census runner at +//! run time (the macro expands to a call of [`host`]), and from the source +//! by the effect-class gate, which checks that every runtime entry codegen +//! can call carries one (an entry without a class counts as host write). +//! In the default archives [`host`] is an empty inline function, so the +//! declaration compiles to nothing. +//! +//! - **The census runner** (`PERRY_SNAP_CENSUS=`, instrumented runtime +//! only): runs the program's module initialization up to the boundary +//! (the entry body's start, or the first explicit `gc()`), with collection +//! suppressed so an address names one object for the whole run, and records +//! per init: its extent, how it was reached, its exclusive instruction +//! count, its births, its host touches, and (with page protection over the +//! GC reservation, the realm image and the module-global root slots) the +//! exact words the deferred inits read and wrote and the first access of +//! any other init that conflicts with them (design §2.3 rules 2-4). See +//! `snap_census/runner.rs`. +//! +//! Every hook below is `#[inline(always)]` and empty without the +//! `perry_gc_instruments` cfg (and off Linux x86_64, where the runner's page +//! protection and single-step do not exist); with it, a hook that finds the +//! runner off costs one relaxed load. + +/// The effect class of a runtime entry (design §2.2). `pure` has no value: +/// it is the absence of an effect. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[repr(u8)] +pub enum Effect { + /// A constant of the compile target (`process.platform`, `os.EOL`): + /// the snapshot answers the target's value, the init stays imageable. + TargetFact = 1, + /// A value of the machine or the invocation (env, argv, cwd, tty, ids, + /// default locale and time zone, clocks, randomness, fs reads): the + /// reading init is deferred. + HostRead = 2, + /// A change of the process or the world (stdio, env writes, listeners, + /// timers and ticks, exit code, fs/net/child process, workers): the + /// writing init is deferred. + HostWrite = 3, +} + +/// Declare the effect class of the runtime entry this statement opens. +/// +/// `effect!(pure)`, `effect!(target_fact, )`, `effect!(host_read, +/// )`, `effect!(host_write, )`; `` names the host state +/// (`env`, `argv`, `tty`, `clock`, ...) for the census report. The gate reads +/// this form from the source, so it is one statement at the top of the entry +/// body, never computed. +#[macro_export] +macro_rules! effect { + (pure) => {}; + (target_fact, $what:ident) => { + $crate::snap_census::host($crate::snap_census::Effect::TargetFact, stringify!($what)) + }; + (host_read, $what:ident) => { + $crate::snap_census::host($crate::snap_census::Effect::HostRead, stringify!($what)) + }; + (host_write, $what:ident) => { + $crate::snap_census::host($crate::snap_census::Effect::HostWrite, stringify!($what)) + }; +} + +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +#[path = "snap_census/runner.rs"] +mod runner; + +/// The entry that declared `class` is running (see [`effect!`]). +#[inline(always)] +pub fn host(class: Effect, what: &'static str) { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + runner::host(class, what); + } + #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] + let _ = (class, what); +} + +/// A module evaluation (`run_path_initializer`) starts: `addr` is its +/// evaluation body (a guarded module's `__init_evaluate`, a path +/// module's initializer). +#[inline(always)] +pub(crate) fn init_enter(addr: usize) { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + runner::init_enter(addr); + #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] + let _ = addr; +} + +/// The evaluation [`init_enter`] opened returned; `threw` when it threw. +#[inline(always)] +pub(crate) fn init_exit(threw: bool) { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + runner::init_exit(threw); + } + #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] + let _ = threw; +} + +/// `js_run_module_init_catching` runs one part of the current evaluation +/// (its literal preparation, then its body). +#[inline(always)] +pub(crate) fn init_part() { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + runner::init_part(); + } +} + +/// A `require` of a path module is in progress (the next evaluation it +/// starts was reached by `require`, not by an import). +#[inline(always)] +pub(crate) fn require_scope() -> RequireScope { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + runner::require_depth(1); + return RequireScope { active: true }; + } + RequireScope { active: false } +} + +pub(crate) struct RequireScope { + active: bool, +} + +impl Drop for RequireScope { + #[inline(always)] + fn drop(&mut self) { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if self.active { + runner::require_depth(-1); + } + #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] + let _ = self.active; + } +} + +/// The init a job enqueued now belongs to (0: none). +#[inline(always)] +pub(crate) fn job_owner() -> u32 { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + return runner::current_init(); + } + 0 +} + +/// A job owned by `owner` (from [`job_owner`] at its enqueue) starts. +#[inline(always)] +pub(crate) fn job_enter(owner: u32) { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + runner::job_enter(owner); + } + #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] + let _ = owner; +} + +/// The job [`job_enter`] opened is over. +#[inline(always)] +pub(crate) fn job_exit() { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + runner::job_exit(); + } +} + +/// An explicit `gc()`: the boundary marker of a census run. +#[inline(always)] +pub(crate) fn explicit_gc() { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + runner::boundary("gc"); + } +} + +/// The census of the collection the boundary's `gc()` runs has finished. +#[inline(always)] +pub(crate) fn explicit_gc_done() { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + runner::after_boundary_collection(); +} + +/// A walkable object is live at the boundary collection's census. +#[inline(always)] +pub(crate) fn census_live(header: *const u8, obj_type: u8, size: usize) { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + runner::census_live(header, obj_type, size); + #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] + let _ = (header, obj_type, size); +} + +/// An arena bump handed out `[ptr, ptr + size)`. +#[inline(always)] +pub(crate) fn birth(ptr: *mut u8, size: usize) { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + runner::birth(ptr as usize, size); + } + #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] + let _ = (ptr, size); +} + +/// While the census runs, compiled code must allocate through the runtime +/// (every birth is logged), so the inline bump bound is closed. +#[inline(always)] +pub(crate) fn inline_bump_closed() -> bool { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + return runner::on(); + #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] + false +} + +/// The reservation mapped `[base, base + len)` (a new arena block). +#[inline(always)] +pub(crate) fn region_mapped(base: usize, len: usize) { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + runner::region_mapped(base, len); + } + #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] + let _ = (base, len); +} + +/// A module-global root slot was registered. +#[inline(always)] +pub(crate) fn global_root(slot: usize) { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + runner::global_root(slot); + } + #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] + let _ = slot; +} + +/// Runtime bookkeeping that reads JS state without the program observing it +/// (root registration verifies the word it registers): its accesses are not +/// the current init's. +#[inline(always)] +pub(crate) fn bookkeeping() -> Bookkeeping { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + runner::bookkeeping_depth(1); + return Bookkeeping { active: true }; + } + Bookkeeping { active: false } +} + +pub(crate) struct Bookkeeping { + active: bool, +} + +impl Drop for Bookkeeping { + #[inline(always)] + fn drop(&mut self) { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if self.active { + runner::bookkeeping_depth(-1); + } + #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] + let _ = self.active; + } +} + +/// Codegen calls this where the entry module's own body starts (after its +/// eager imports), in instrumented compiles only (`PERRY_SNAP_CENSUS` set +/// while compiling). The census boundary of an ESM entry. +#[no_mangle] +pub extern "C" fn js_snap_entry_body() { + crate::effect!(pure); + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + runner::entry_body(); + } +} diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs new file mode 100644 index 00000000000..3a803539da0 --- /dev/null +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -0,0 +1,1221 @@ +//! The SNAP census runner (`PERRY_SNAP_CENSUS=`): instrumented runtime +//! only. See `snap_census.rs` for the hooks and the design doc §2.3 for the +//! rules this measures. +//! +//! **Run.** On the first module evaluation the runner turns on: it suppresses +//! collection (nothing moves until the boundary, so an address names one +//! object), closes the inline bump bound (every birth goes through +//! `ArenaBlock::alloc`, which logs it), logs the objects that already exist as +//! runtime births, and opens a perf counter of user instructions. Frames are +//! module evaluations (`run_path_initializer`) and promise jobs (a job runs as +//! the init that enqueued it); every access is the top frame's. The run ends +//! at the first explicit `gc()` (the boundary marker), where the report is +//! written, the protections are lifted and the collection runs (with +//! `PERRY_GC_CENSUS` set, its census of the live heap is attributed to births +//! here). +//! +//! **Deferred inits (D).** Rule 1: an init that runs a `host_read` or +//! `host_write` entry, or throws. `PERRY_SNAP_D=` (one evaluation +//! address per line, hex) seeds D with the inits an earlier pass found, so +//! their accesses are recorded from their start: while a member of D is the +//! top frame, every tracked page is `PROT_NONE` and each access is recorded as +//! a read or write of its 8-byte word (`R_D`, `W_D`); while any other init +//! runs, only the pages that hold a `W_D` word (`PROT_NONE`) or an `R_D` word +//! (`PROT_READ`) are protected. A read of a `W_D` word (rule 2) or a write of a +//! `W_D`/`R_D` word (rule 3) makes that init a member from then on; the next +//! pass records it from its start. Passes repeat until D stops growing. +//! Rule 4 (relaxed, `PERRY_SNAP_RULE4=1`): when the first dynamically +//! imported evaluation starts, every word that exists then is watched for +//! writes; an init of the dynamic subgraph that writes one joins D. +//! +//! Faults single-step: the handler opens the page, sets the trap flag, and +//! the `SIGTRAP` after the one instruction closes it again, so the program +//! runs unchanged. Tracked: the GC reservation's mapped regions, the realm +//! image section and the pages of registered module-global root slots. +//! +//! Linux x86_64 only (the reservation, `REG_ERR`, the trap flag). + +use super::Effect; +use std::sync::atomic::{AtomicBool, AtomicPtr, AtomicU8, Ordering::Relaxed}; + +const PAGE: usize = 4096; +const MAX_INITS: usize = 1 << 16; + +/// The runner's own words, alone on their page: every tracked page that the +/// handler might protect is a page of JS state, never this one. +#[repr(C, align(4096))] +struct Anchor { + on: AtomicBool, + started: AtomicU8, + state: AtomicPtr, + _pad: [u8; PAGE - 16], +} + +static ANCHOR: Anchor = Anchor { + on: AtomicBool::new(false), + started: AtomicU8::new(0), + state: AtomicPtr::new(std::ptr::null_mut()), + _pad: [0; PAGE - 16], +}; + +#[inline(always)] +pub(super) fn on() -> bool { + ANCHOR.on.load(Relaxed) +} + +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 + // thread only reads it). + unsafe { &mut *ANCHOR.state.load(Relaxed) } +} + +// --------------------------------------------------------------------------- +// Fixed-capacity tables in their own anonymous mappings (never protected, no +// allocation from the signal handler). +// --------------------------------------------------------------------------- + +unsafe fn map_zeroed(count: usize) -> *mut T { + let bytes = count * std::mem::size_of::(); + let p = libc::mmap( + std::ptr::null_mut(), + bytes, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_PRIVATE | libc::MAP_ANONYMOUS | libc::MAP_NORESERVE, + -1, + 0, + ); + assert!(p != libc::MAP_FAILED, "snap census: mmap {bytes} bytes failed"); + p.cast() +} + +/// Open-addressing set of nonzero words. +struct WordSet { + slots: *mut u64, + mask: usize, + len: usize, +} + +impl WordSet { + unsafe fn new(log2: u32) -> Self { + let n = 1usize << log2; + WordSet { + slots: map_zeroed::(n), + mask: n - 1, + len: 0, + } + } + #[inline] + fn hash(w: u64) -> usize { + (w.wrapping_mul(0x9E37_79B9_7F4A_7C15) >> 17) as usize + } + /// True when newly inserted. + fn insert(&mut self, w: u64) -> bool { + assert!(self.len * 2 < self.mask, "snap census: word set full"); + let mut i = Self::hash(w) & self.mask; + unsafe { + loop { + let s = *self.slots.add(i); + if s == w { + return false; + } + if s == 0 { + *self.slots.add(i) = w; + self.len += 1; + return true; + } + i = (i + 1) & self.mask; + } + } + } + fn contains(&self, w: u64) -> bool { + let mut i = Self::hash(w) & self.mask; + unsafe { + loop { + let s = *self.slots.add(i); + if s == w { + return true; + } + if s == 0 { + return false; + } + i = (i + 1) & self.mask; + } + } + } +} + +/// Append-only log of fixed records. +struct Log { + p: *mut T, + cap: usize, + len: usize, +} + +impl Log { + unsafe fn new(cap: usize) -> Self { + Log { + p: map_zeroed::(cap), + cap, + len: 0, + } + } + fn push(&mut self, v: T) { + assert!(self.len < self.cap, "snap census: log full"); + unsafe { *self.p.add(self.len) = v }; + self.len += 1; + } + fn as_slice(&self) -> &[T] { + unsafe { std::slice::from_raw_parts(self.p, self.len) } + } + fn as_mut_slice(&mut self) -> &mut [T] { + unsafe { std::slice::from_raw_parts_mut(self.p, self.len) } + } +} + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +/// How an evaluation was first reached. +#[derive(Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +enum Reach { + /// The entry's eager import list (before its body). + EntryEager = 0, + /// A static dependency of another evaluation (before that one's body). + StaticDep = 1, + /// From a body (an evaluation's, the entry's, or a job's) by `import()`. + DynImport = 2, + /// From a body by `require`. + Require = 3, +} + +#[derive(Clone, Copy)] +struct Hit { + class: u8, + what: &'static str, + pcs: [usize; 6], +} + +#[derive(Clone, Copy)] +struct Conflict { + /// 2: read a word a member of D wrote; 3: wrote a word a member of D + /// read or wrote; 4: a dynamic-subgraph init wrote a word that existed + /// when the dynamic import started. + rule: u8, + word: usize, + pc: usize, + write: bool, +} + +#[derive(Clone, Copy)] +struct Init { + addr: usize, + parent: u32, + reach: Reach, + /// In the dynamic subgraph: reached by `import()`, or below such an init. + dynamic: bool, + /// Reached from the entry's body (as opposed to its eager list). + from_entry_body: bool, + enter_seq: u32, + exit_seq: u32, + open: bool, + parts: u8, + threw: bool, + seeded: bool, + ir: u64, + births: u64, + birth_bytes: u64, + host_count: u32, + host: Option, + conflict: Option, + in_d: bool, + faults: u64, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +enum FrameKind { + Init = 0, + Job = 1, +} + +#[derive(Clone, Copy)] +struct Frame { + init: u32, + kind: FrameKind, +} + +#[derive(Clone, Copy)] +struct Birth { + addr: usize, + size: u32, + init: u32, +} + +/// Page flags (one byte per page). +const PF_W: u8 = 1; +const PF_R: u8 = 2; +const PF_PRE: u8 = 4; +const PF_LISTED: u8 = 8; +const PF_TRACKED: u8 = 16; + +struct PageMap { + lo: usize, + pages: usize, + flags: *mut u8, +} + +impl PageMap { + unsafe fn new(lo: usize, hi: usize) -> Self { + let lo = lo & !(PAGE - 1); + let pages = (hi - lo).div_ceil(PAGE); + PageMap { + lo, + pages, + flags: map_zeroed::(pages.max(1)), + } + } + #[inline] + fn slot(&self, addr: usize) -> Option<*mut u8> { + let i = addr.wrapping_sub(self.lo) / PAGE; + (addr >= self.lo && i < self.pages).then(|| unsafe { self.flags.add(i) }) + } +} + +struct State { + out: String, + inits: Vec, + frames: Vec, + seq: u32, + seeded: Vec, + rule4: bool, + sabotage_env: bool, + entry_body_seq: u32, + require_depth: i32, + bookkeeping: i32, + w: WordSet, + r: WordSet, + /// Registered module-global root slots (the handler cannot borrow + /// `GLOBAL_ROOTS`: the interrupted code may hold it). + globals: WordSet, + births: Log, + listed: Log, + regions: Vec<(usize, usize)>, + heap: PageMap, + bin: PageMap, + realm: (usize, usize), + d_mode: bool, + perf_fd: i32, + last_ir: u64, + /// Birth-log length when the first dynamic import started (rule 4). + pre_births: usize, + pre_sorted: Vec<(usize, usize)>, + rule4_armed: bool, + host_outside: u64, + faults_total: u64, + faults_ignored: u64, + boundary_seq: u32, + boundary_at: &'static str, + // census at the boundary collection + live_by: Vec<[u64; 4]>, + live_unattributed: [u64; 2], + boundary_done: bool, + /// Ground truth: every `getenv` by any code while an init runs. + env_reads: Vec<(u32, String)>, + mark: bool, + t0: std::time::Instant, +} + +// --------------------------------------------------------------------------- +// Start +// --------------------------------------------------------------------------- + +fn start_if_requested() -> bool { + match ANCHOR.started.load(Relaxed) { + 1 => return false, + 2 => return true, + _ => {} + } + let Ok(out) = std::env::var("PERRY_SNAP_CENSUS") else { + ANCHOR.started.store(1, Relaxed); + return false; + }; + if out.is_empty() { + ANCHOR.started.store(1, Relaxed); + return false; + } + ANCHOR.started.store(2, Relaxed); + unsafe { start(out) }; + true +} + +unsafe fn start(out: String) { + let seeded: Vec = std::env::var("PERRY_SNAP_D") + .ok() + .and_then(|p| std::fs::read_to_string(p).ok()) + .map(|t| { + t.lines() + .filter_map(|l| usize::from_str_radix(l.trim().trim_start_matches("0x"), 16).ok()) + .collect() + }) + .unwrap_or_default(); + let (res_lo, res_hi) = crate::arena::snap_reservation_bounds(); + let realm = crate::value::addr_class::realm::bounds(); + let (bin_lo, bin_hi) = writable_image_range(realm.0); + let mut st = Box::new(State { + out, + inits: Vec::with_capacity(4096), + frames: Vec::with_capacity(256), + seq: 0, + seeded, + rule4: std::env::var("PERRY_SNAP_RULE4").is_ok_and(|v| v == "1"), + sabotage_env: std::env::var("PERRY_SNAP_SABOTAGE").is_ok_and(|v| v == "env"), + entry_body_seq: 0, + require_depth: 0, + bookkeeping: 0, + w: WordSet::new(28), + r: WordSet::new(28), + globals: WordSet::new(22), + births: Log::new(1 << 28), + listed: Log::new(1 << 26), + regions: Vec::new(), + heap: PageMap::new(res_lo, res_hi), + bin: PageMap::new(bin_lo, bin_hi), + realm, + d_mode: false, + perf_fd: open_instruction_counter(), + last_ir: 0, + pre_births: usize::MAX, + pre_sorted: Vec::new(), + rule4_armed: false, + host_outside: 0, + faults_total: 0, + faults_ignored: 0, + boundary_seq: 0, + boundary_at: "", + live_by: Vec::new(), + live_unattributed: [0; 2], + boundary_done: false, + env_reads: Vec::new(), + mark: std::env::var("PERRY_SNAP_MARK").is_ok_and(|v| v == "1"), + t0: std::time::Instant::now(), + }); + // Init 0: the runtime's own start and the entry's body. + st.inits.push(blank_init(0, 0, Reach::EntryEager)); + st.last_ir = read_ir(st.perf_fd); + ANCHOR.state.store(&mut *st as *mut State, Relaxed); + std::mem::forget(st); + let st = state(); + // Nothing moves until the boundary. + crate::gc::gc_suppress(); + // The objects that exist now are the runtime's (init 0). + crate::arena::arena_walk_objects_with_block_index(|header, _| { + let size = (*(header as *const crate::gc::GcHeader)).size as usize; + st.births.push(Birth { + addr: header as usize, + size: size as u32, + init: 0, + }); + }); + crate::arena::snap_reservation_regions(|base, end| st.regions.push((base, end))); + for &(b, e) in &st.regions.clone() { + mark_tracked(b, e); + } + mark_tracked(realm.0, realm.1); + let mut slots = Vec::new(); + crate::gc::for_each_global_root_slot(|slot| slots.push(slot)); + for slot in slots { + st.globals.insert(slot as u64); + mark_tracked(slot, slot + 8); + } + install_handlers(); + ANCHOR.on.store(true, Relaxed); + crate::arena::refresh_inline_allocation_limit(); +} + +fn blank_init(addr: usize, parent: u32, reach: Reach) -> Init { + Init { + addr, + parent, + reach, + dynamic: false, + from_entry_body: false, + enter_seq: 0, + exit_seq: 0, + open: false, + parts: 0, + threw: false, + seeded: false, + ir: 0, + births: 0, + birth_bytes: 0, + host_count: 0, + host: None, + conflict: None, + in_d: false, + faults: 0, + } +} + +/// The executable's writable mapping(s) around `inside` (its .data/.bss, +/// where module globals and the realm section live), from /proc/self/maps. +fn writable_image_range(inside: usize) -> (usize, usize) { + let maps = std::fs::read_to_string("/proc/self/maps").unwrap_or_default(); + let mut rows: Vec<(usize, usize, bool)> = Vec::new(); + for line in maps.lines() { + let mut it = line.split_whitespace(); + let (Some(range), Some(perms)) = (it.next(), it.next()) else { + continue; + }; + let Some((a, b)) = range.split_once('-') else { + continue; + }; + let (Ok(a), Ok(b)) = (usize::from_str_radix(a, 16), usize::from_str_radix(b, 16)) else { + continue; + }; + rows.push((a, b, perms.starts_with("rw"))); + } + let Some(i) = rows.iter().position(|&(a, b, _)| a <= inside && inside < b) else { + return (inside, inside + PAGE); + }; + let (mut lo, mut hi) = (rows[i].0, rows[i].1); + let mut j = i; + while j > 0 && rows[j - 1].2 && rows[j - 1].1 == lo { + j -= 1; + lo = rows[j].0; + } + let mut k = i; + while k + 1 < rows.len() && rows[k + 1].2 && rows[k + 1].0 == hi { + k += 1; + hi = rows[k].1; + } + (lo, hi) +} + +fn open_instruction_counter() -> i32 { + // perf_event_attr, version 0 layout (64 bytes). + let mut attr = [0u64; 8]; + let type_and_size: u64 = /* PERF_TYPE_HARDWARE */ 0 | (64u64 << 32); + attr[0] = type_and_size; + attr[1] = 1; // PERF_COUNT_HW_INSTRUCTIONS + attr[5] = (1 << 5) | (1 << 6); // exclude_kernel | exclude_hv + let fd = unsafe { + libc::syscall( + libc::SYS_perf_event_open, + attr.as_ptr(), + 0 as libc::pid_t, + -1 as libc::c_int, + -1 as libc::c_int, + 0 as libc::c_ulong, + ) + }; + fd as i32 +} + +fn read_ir(fd: i32) -> u64 { + if fd < 0 { + return 0; + } + let mut v = 0u64; + unsafe { libc::read(fd, (&mut v as *mut u64).cast(), 8) }; + v +} + +// --------------------------------------------------------------------------- +// Frames +// --------------------------------------------------------------------------- + +fn charge(st: &mut State) { + let now = read_ir(st.perf_fd); + let top = st.frames.last().map_or(0, |f| f.init); + st.inits[top as usize].ir += now.saturating_sub(st.last_ir); + st.last_ir = now; +} + +pub(super) fn current_init() -> u32 { + state().frames.last().map_or(0, |f| f.init) +} + +fn set_mode_for_top(st: &mut State) { + let top = st.frames.last().map_or(0, |f| f.init) as usize; + if st.mark { + // A syscall trace (strace) attributes what follows to this init. + unsafe { libc::syscall(libc::SYS_lseek, -1 as libc::c_int, top as libc::c_long, 0x5a) }; + } + let want = top != 0 && st.inits[top].in_d; + if want != st.d_mode { + st.d_mode = want; + unsafe { apply_protection(st) }; + } +} + +pub(super) fn init_enter(addr: usize) { + if !on() && !start_if_requested() { + return; + } + let st = state(); + charge(st); + let parent = st.frames.last().copied(); + let parent_id = parent.map_or(0, |f| f.init); + let parent_in_body = match parent { + None => st.entry_body_seq != 0, + Some(f) if f.kind == FrameKind::Job => true, + Some(f) => st.inits[f.init as usize].parts >= 2 || st.inits[f.init as usize].addr == 0, + }; + let reach = if st.require_depth > 0 { + Reach::Require + } else if !parent_in_body { + if parent.is_none() { + Reach::EntryEager + } else { + Reach::StaticDep + } + } else { + Reach::DynImport + }; + st.seq += 1; + let id = st.inits.len() as u32; + assert!((id as usize) < MAX_INITS, "snap census: too many inits"); + let mut init = blank_init(addr, parent_id, reach); + init.enter_seq = st.seq; + init.open = true; + let parent_dynamic = parent_id != 0 && st.inits[parent_id as usize].dynamic; + init.dynamic = reach == Reach::DynImport || parent_dynamic; + init.from_entry_body = (parent.is_none() && st.entry_body_seq != 0) + || (parent_id != 0 && st.inits[parent_id as usize].from_entry_body); + init.seeded = st.seeded.contains(&addr); + init.in_d = init.seeded; + if init.dynamic && st.rule4 && !st.rule4_armed { + unsafe { arm_rule4(st) }; + } + st.inits.push(init); + st.frames.push(Frame { + init: id, + kind: FrameKind::Init, + }); + set_mode_for_top(st); +} + +pub(super) fn init_exit(threw: bool) { + let st = state(); + charge(st); + let Some(f) = st.frames.pop() else { + return; + }; + st.seq += 1; + let i = &mut st.inits[f.init as usize]; + i.open = false; + i.exit_seq = st.seq; + if threw { + i.threw = true; + i.in_d = true; + } + set_mode_for_top(st); +} + +pub(super) fn init_part() { + let st = state(); + if let Some(f) = st.frames.last() { + if f.kind == FrameKind::Init { + let i = &mut st.inits[f.init as usize]; + i.parts = i.parts.saturating_add(1); + } + } +} + +pub(super) fn require_depth(delta: i32) { + state().require_depth += delta; +} + +pub(super) fn bookkeeping_depth(delta: i32) { + state().bookkeeping += delta; +} + +pub(super) fn job_enter(owner: u32) { + let st = state(); + charge(st); + st.frames.push(Frame { + init: owner, + kind: FrameKind::Job, + }); + set_mode_for_top(st); +} + +pub(super) fn job_exit() { + let st = state(); + charge(st); + if st.frames.last().is_some_and(|f| f.kind == FrameKind::Job) { + st.frames.pop(); + } + set_mode_for_top(st); +} + +pub(super) fn entry_body() { + let st = state(); + if st.entry_body_seq == 0 { + st.seq += 1; + st.entry_body_seq = st.seq; + } +} + +pub(super) fn host(class: Effect, what: &'static str) { + let st = state(); + if st.sabotage_env && what == "env" { + return; + } + let top = st.frames.last().map_or(0, |f| f.init); + if top == 0 { + st.host_outside += 1; + return; + } + let i = &mut st.inits[top as usize]; + i.host_count += 1; + if class != Effect::TargetFact && !i.in_d { + i.in_d = true; + set_mode_for_top(st); + } + let i = &mut st.inits[top as usize]; + if i.host.is_none() || (class != Effect::TargetFact && i.host.is_some_and(|h| h.class == 1)) + { + let mut pcs = [0usize; crate::error::MAX_CAPTURED_FRAMES]; + let n = crate::error::capture_ips(&mut pcs); + let mut six = [0usize; 6]; + for (d, s) in six.iter_mut().zip(pcs[..n].iter().skip(1)) { + *d = *s; + } + i.host = Some(Hit { + class: class as u8, + what, + pcs: six, + }); + } +} + +pub(super) fn birth(addr: usize, size: usize) { + let st = state(); + let top = st.frames.last().map_or(0, |f| f.init); + st.births.push(Birth { + addr, + size: size as u32, + init: top, + }); + 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 st = state(); + st.regions.push((base, base + len)); + mark_tracked(base, base + len); + if st.d_mode { + unsafe { libc::mprotect(base as *mut _, len, libc::PROT_NONE) }; + } +} + +pub(super) fn global_root(slot: usize) { + let st = state(); + st.globals.insert(slot as u64); + mark_tracked(slot, slot + 8); + if st.d_mode { + let page = slot & !(PAGE - 1); + unsafe { libc::mprotect(page as *mut _, PAGE, libc::PROT_NONE) }; + } +} + +fn mark_tracked(lo: usize, hi: usize) { + let st = state(); + let mut p = lo & !(PAGE - 1); + while p < hi { + if let Some(f) = page_flags(st, p) { + unsafe { *f |= PF_TRACKED }; + } + p += PAGE; + } +} + +#[inline] +fn page_flags(st: &State, addr: usize) -> Option<*mut u8> { + st.heap.slot(addr).or_else(|| st.bin.slot(addr)) +} + +// --------------------------------------------------------------------------- +// Protection +// --------------------------------------------------------------------------- + +fn policy(st: &State, page: usize) -> i32 { + let Some(f) = page_flags(st, page) else { + return libc::PROT_READ | libc::PROT_WRITE; + }; + let f = unsafe { *f }; + if f & PF_TRACKED == 0 { + return libc::PROT_READ | libc::PROT_WRITE; + } + if st.d_mode { + return libc::PROT_NONE; + } + if f & PF_W != 0 { + libc::PROT_NONE + } else if f & (PF_R | PF_PRE) != 0 { + libc::PROT_READ + } else { + libc::PROT_READ | libc::PROT_WRITE + } +} + +/// Every tracked range: the reservation's regions, the realm section, the +/// pages of global root slots (the binary-range pages flagged tracked). +unsafe fn for_each_tracked_range(st: &State, mut f: impl FnMut(usize, usize)) { + for &(b, e) in &st.regions { + f(b, e); + } + let mut i = 0; + while i < st.bin.pages { + if *st.bin.flags.add(i) & PF_TRACKED != 0 { + let s = i; + while i < st.bin.pages && *st.bin.flags.add(i) & PF_TRACKED != 0 { + i += 1; + } + f(st.bin.lo + s * PAGE, st.bin.lo + i * PAGE); + } else { + i += 1; + } + } +} + +unsafe fn apply_protection(st: &mut State) { + if st.d_mode { + for_each_tracked_range(st, |b, e| { + libc::mprotect(b as *mut _, e - b, libc::PROT_NONE); + }); + return; + } + for_each_tracked_range(st, |b, e| { + libc::mprotect(b as *mut _, e - b, libc::PROT_READ | libc::PROT_WRITE); + }); + for &page in st.listed.as_slice() { + let p = policy(st, page); + if p != libc::PROT_READ | libc::PROT_WRITE { + libc::mprotect(page as *mut _, PAGE, p); + } + } +} + +fn flag_page(st: &mut State, page: usize, bit: u8) { + if let Some(f) = page_flags(st, page) { + unsafe { + if *f & PF_LISTED == 0 { + *f |= PF_LISTED; + st.listed.push(page); + } + *f |= bit; + } + } +} + +/// Rule 4 (relaxed): the words that exist when the first dynamic import +/// starts are watched for writes by the dynamic subgraph. +unsafe fn arm_rule4(st: &mut State) { + st.rule4_armed = true; + st.pre_births = st.births.len; + let mut v: Vec<(usize, usize)> = st + .births + .as_slice() + .iter() + .map(|b| (b.addr, b.addr + b.size as usize)) + .collect(); + v.sort_unstable(); + st.pre_sorted = v; + let pages: Vec = st + .pre_sorted + .iter() + .flat_map(|&(a, e)| (a & !(PAGE - 1)..e).step_by(PAGE)) + .collect(); + for p in pages { + flag_page(st, p, PF_PRE); + } + let (rb, re) = st.realm; + let mut p = rb & !(PAGE - 1); + while p < re { + flag_page(st, p, PF_PRE); + p += PAGE; + } + let mut globals = Vec::new(); + crate::gc::for_each_global_root_slot(|s| globals.push(s)); + for s in globals { + st.pre_sorted.push((s, s + 8)); + flag_page(st, s & !(PAGE - 1), PF_PRE); + } + st.pre_sorted.sort_unstable(); + apply_protection(st); +} + +fn pre_existing(st: &State, word: usize) -> bool { + let (rb, re) = st.realm; + if word >= rb && word < re { + return true; + } + let i = st.pre_sorted.partition_point(|&(a, _)| a <= word); + i > 0 && word < st.pre_sorted[i - 1].1 +} + +// --------------------------------------------------------------------------- +// Signals +// --------------------------------------------------------------------------- + +static mut PREV_SEGV: std::mem::MaybeUninit = std::mem::MaybeUninit::uninit(); +static mut PREV_TRAP: std::mem::MaybeUninit = std::mem::MaybeUninit::uninit(); + +std::thread_local! { + static PENDING: std::cell::Cell<[usize; 8]> = const { std::cell::Cell::new([0; 8]) }; +} + +unsafe fn install_handlers() { + let mut a: libc::sigaction = std::mem::zeroed(); + a.sa_sigaction = on_segv as *const () as usize; + a.sa_flags = libc::SA_SIGINFO; + libc::sigemptyset(&mut a.sa_mask); + libc::sigaction(libc::SIGSEGV, &a, std::ptr::addr_of_mut!(PREV_SEGV).cast()); + let mut t: libc::sigaction = std::mem::zeroed(); + t.sa_sigaction = on_trap as *const () as usize; + t.sa_flags = libc::SA_SIGINFO; + libc::sigemptyset(&mut t.sa_mask); + libc::sigaction(libc::SIGTRAP, &t, std::ptr::addr_of_mut!(PREV_TRAP).cast()); +} + +unsafe fn chain(prev: *const libc::sigaction, sig: i32) { + // Not ours: restore the previous disposition; the instruction faults + // again and takes it. + libc::sigaction(sig, prev, std::ptr::null_mut()); +} + +const EFLAGS_TF: i64 = 0x100; + +extern "C" fn on_segv(sig: libc::c_int, info: *mut libc::siginfo_t, ctx: *mut libc::c_void) { + unsafe { + let addr = (*info).si_addr() as usize; + let st_ptr = ANCHOR.state.load(Relaxed); + if st_ptr.is_null() { + return chain(std::ptr::addr_of!(PREV_SEGV).cast(), sig); + } + let st = &mut *st_ptr; + let page = addr & !(PAGE - 1); + 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 { + return chain(std::ptr::addr_of!(PREV_SEGV).cast(), sig); + } + let uc = ctx as *mut libc::ucontext_t; + let gregs = &mut (*uc).uc_mcontext.gregs; + let write = gregs[libc::REG_ERR as usize] & 2 != 0; + let pc = gregs[libc::REG_RIP as usize] as usize; + st.faults_total += 1; + note_access(st, addr & !7, write, pc); + // Open the page for one instruction. + let pending = PENDING.with(|p| p.get()); + let mut pend = pending; + if let Some(slot) = pend.iter_mut().find(|s| **s == 0) { + *slot = page; + } + PENDING.with(|p| p.set(pend)); + libc::mprotect(page as *mut _, PAGE, libc::PROT_READ | libc::PROT_WRITE); + gregs[libc::REG_EFL as usize] |= EFLAGS_TF; + } +} + +extern "C" fn on_trap(sig: libc::c_int, _info: *mut libc::siginfo_t, ctx: *mut libc::c_void) { + unsafe { + let pending = PENDING.with(|p| p.get()); + if pending[0] == 0 { + return chain(std::ptr::addr_of!(PREV_TRAP).cast(), sig); + } + let st = &mut *ANCHOR.state.load(Relaxed); + for &page in pending.iter().take_while(|p| **p != 0) { + libc::mprotect(page as *mut _, PAGE, policy(st, page)); + } + PENDING.with(|p| p.set([0; 8])); + let uc = ctx as *mut libc::ucontext_t; + (*uc).uc_mcontext.gregs[libc::REG_EFL as usize] &= !EFLAGS_TF; + } +} + +/// One access to a tracked word by the top frame. +fn note_access(st: &mut State, word: usize, write: bool, pc: usize) { + if st.bookkeeping > 0 { + st.faults_ignored += 1; + return; + } + // Words of the binary range count only as global slots or realm cells. + if st.bin.slot(word).is_some() + && !(word >= st.realm.0 && word < st.realm.1) + && !st.globals.contains(word as u64) + { + st.faults_ignored += 1; + return; + } + let top = st.frames.last().map_or(0, |f| f.init) as usize; + st.inits[top].faults += 1; + let w = word as u64; + if st.d_mode { + if write { + st.w.insert(w); + flag_page(st, word & !(PAGE - 1), PF_W); + } else if !st.w.contains(w) { + st.r.insert(w); + flag_page(st, word & !(PAGE - 1), PF_R); + } + return; + } + if top == 0 { + return; + } + let rule = if st.w.contains(w) { + Some(if write { 3 } else { 2 }) + } else if write && st.r.contains(w) { + Some(3) + } else if write + && st.rule4_armed + && st.inits[top].dynamic + && pre_existing(st, word) + { + Some(4) + } else { + None + }; + if let Some(rule) = rule { + let i = &mut st.inits[top]; + if i.conflict.is_none() { + i.conflict = Some(Conflict { + rule, + word, + pc, + write, + }); + } + i.in_d = true; + st.d_mode = true; + unsafe { apply_protection(st) }; + // This access is the member's first recorded one. + if write { + st.w.insert(w); + flag_page(st, word & !(PAGE - 1), PF_W); + } else { + st.r.insert(w); + flag_page(st, word & !(PAGE - 1), PF_R); + } + } +} + +// --------------------------------------------------------------------------- +// Ground truth: getenv +// --------------------------------------------------------------------------- + +unsafe extern "C" { + static mut environ: *const *const libc::c_char; +} + +/// The process's `getenv`, interposed in the instrumented archives so the +/// census sees every environment read by any code (Rust std, the runtime's +/// 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 { + if name.is_null() { + return std::ptr::null_mut(); + } + let key = std::ffi::CStr::from_ptr(name).to_bytes(); + let mut found: *mut libc::c_char = std::ptr::null_mut(); + let mut e = environ; + if !e.is_null() { + while !(*e).is_null() { + let entry = std::ffi::CStr::from_ptr(*e).to_bytes(); + if entry.len() > key.len() && entry.starts_with(key) && entry[key.len()] == b'=' { + found = (*e).add(key.len() + 1) as *mut libc::c_char; + break; + } + e = e.add(1); + } + } + if on() { + let st = state(); + let top = st.frames.last().map_or(0, |f| f.init); + if top != 0 && st.env_reads.len() < 1 << 20 { + st.env_reads.push((top, String::from_utf8_lossy(key).into_owned())); + } + } + found +} + +// --------------------------------------------------------------------------- +// Boundary and report +// --------------------------------------------------------------------------- + +pub(super) fn boundary(at: &'static str) { + let st = state(); + if st.boundary_seq != 0 { + return; + } + charge(st); + st.seq += 1; + st.boundary_seq = st.seq; + st.boundary_at = at; + ANCHOR.on.store(false, Relaxed); + st.d_mode = false; + unsafe { + for_each_tracked_range(st, |b, e| { + libc::mprotect(b as *mut _, e - b, libc::PROT_READ | libc::PROT_WRITE); + }); + } + crate::arena::refresh_inline_allocation_limit(); + crate::gc::gc_unsuppress(); + // Births sorted by address for the census attribution. + st.births.as_mut_slice().sort_unstable_by_key(|b| b.addr); + st.live_by = vec![[0u64; 4]; st.inits.len()]; + write_report(st); +} + +/// The boundary collection's census visits a live object. +pub(super) fn census_live(header: *const u8, _obj_type: u8, size: usize) { + let p = ANCHOR.state.load(Relaxed); + if p.is_null() { + return; + } + let st = unsafe { &mut *p }; + if st.boundary_seq == 0 || st.boundary_done { + return; + } + let a = header as usize; + let b = st.births.as_slice(); + let i = b.partition_point(|x| x.addr <= a); + if i == 0 || a >= b[i - 1].addr + b[i - 1].size as usize { + st.live_unattributed[0] += 1; + st.live_unattributed[1] += size as u64; + return; + } + let init = b[i - 1].init as usize; + let row = &mut st.live_by[init]; + row[0] += 1; + row[1] += size as u64; +} + +pub(super) fn after_boundary_collection() { + let p = ANCHOR.state.load(Relaxed); + if p.is_null() { + return; + } + let st = unsafe { &mut *p }; + if st.boundary_seq == 0 || st.boundary_done { + return; + } + st.boundary_done = true; + use std::io::Write; + let path = format!("{}.live.tsv", st.out); + if let Ok(mut f) = std::fs::File::create(&path) { + let _ = writeln!(f, "#unattributed\t{}\t{}", st.live_unattributed[0], st.live_unattributed[1]); + for (id, row) in st.live_by.iter().enumerate() { + if row[0] != 0 { + let _ = writeln!(f, "{id}\t{}\t{}", row[0], row[1]); + } + } + } +} + +fn write_report(st: &mut State) { + use std::io::Write; + let Ok(mut f) = std::fs::File::create(&st.out) else { + return; + }; + let _ = writeln!( + f, + "#snap\tboundary={}\tseq={}\tentry_body_seq={}\tinits={}\tbirths={}\tw={}\tr={}\tfaults={}\tignored={}\thost_outside={}\twall_ms={}\tperf={}", + st.boundary_at, + st.boundary_seq, + st.entry_body_seq, + st.inits.len() - 1, + st.births.len, + st.w.len, + st.r.len, + st.faults_total, + st.faults_ignored, + st.host_outside, + st.t0.elapsed().as_millis(), + st.perf_fd >= 0, + ); + let _ = writeln!( + f, + "#id\taddr\tparent\treach\tdynamic\tfrom_entry_body\tenter\texit\topen\tthrew\tseeded\tin_d\tir\tbirths\tbirth_bytes\tfaults\thost_count\thost_class\thost_what\thost_pcs\tconflict_rule\tconflict_word\tconflict_write\tconflict_pc\tconflict_owner" + ); + let births = { + let mut v: Vec<(usize, usize, u32)> = st + .births + .as_slice() + .iter() + .map(|b| (b.addr, b.addr + b.size as usize, b.init)) + .collect(); + v.sort_unstable(); + v + }; + let owner = |word: usize| -> String { + let i = births.partition_point(|&(a, _, _)| a <= word); + if i > 0 && word < births[i - 1].1 { + format!("birth:{}@{:x}", births[i - 1].2, births[i - 1].0) + } else { + "static".into() + } + }; + for (id, i) in st.inits.iter().enumerate() { + let (hc, hw, hp) = match i.host { + Some(h) => ( + h.class.to_string(), + h.what.to_string(), + h.pcs + .iter() + .map(|p| format!("{p:x}")) + .collect::>() + .join(","), + ), + None => ("".into(), "".into(), "".into()), + }; + let (cr, cw, cwr, cpc, co) = match i.conflict { + Some(c) => ( + c.rule.to_string(), + format!("{:x}", c.word), + (c.write as u8).to_string(), + format!("{:x}", c.pc), + owner(c.word), + ), + None => Default::default(), + }; + let _ = writeln!( + f, + "{id}\t{:x}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{hc}\t{hw}\t{hp}\t{cr}\t{cw}\t{cwr}\t{cpc}\t{co}", + i.addr, + i.parent, + i.reach as u8, + i.dynamic as u8, + i.from_entry_body as u8, + i.enter_seq, + i.exit_seq, + i.open as u8, + i.threw as u8, + i.seeded as u8, + i.in_d as u8, + i.ir, + i.births, + i.birth_bytes, + i.faults, + i.host_count, + ); + } + for (init, name) in &st.env_reads { + let _ = writeln!(f, "#getenv\t{init}\t{name}"); + } + // The next pass's seed. + if let Ok(mut d) = std::fs::File::create(format!("{}.d", st.out)) { + for i in st.inits.iter().skip(1) { + if i.in_d { + let _ = writeln!(d, "{:x}", i.addr); + } + } + } +} diff --git a/crates/perry-runtime/src/timer.rs b/crates/perry-runtime/src/timer.rs index f22f215e37d..eedb51e4884 100644 --- a/crates/perry-runtime/src/timer.rs +++ b/crates/perry-runtime/src/timer.rs @@ -779,6 +779,7 @@ pub extern "C" fn js_set_timeout_callback(callback: i64, delay_ms: f64) -> i64 { #[no_mangle] pub extern "C" fn js_set_immediate_callback(callback: i64) -> i64 { + crate::effect!(host_write, timer); schedule_js_timer( callback, 0.0, @@ -952,6 +953,7 @@ pub unsafe extern "C" fn js_set_immediate_callback_args( args_ptr: *const f64, n_args: i32, ) -> i64 { + crate::effect!(host_write, timer); schedule_js_timer( callback, 0.0, diff --git a/crates/perry-runtime/src/timer/store.rs b/crates/perry-runtime/src/timer/store.rs index eb618b31e7d..2c1835c9ce0 100644 --- a/crates/perry-runtime/src/timer/store.rs +++ b/crates/perry-runtime/src/timer/store.rs @@ -409,6 +409,7 @@ impl AgentTimers { /// Insert a timers-phase entry. pub(super) fn insert_timer(&mut self, entry: Entry) -> usize { + crate::effect!(host_write, timer); debug_assert!(entry.class.is_timer()); let index = self.alloc(entry); self.heap_push(index); diff --git a/crates/perry-runtime/src/tty.rs b/crates/perry-runtime/src/tty.rs index e3705e5157c..1be93730a97 100644 --- a/crates/perry-runtime/src/tty.rs +++ b/crates/perry-runtime/src/tty.rs @@ -80,11 +80,13 @@ pub(crate) fn scan_tty_resize_callback_root_mut(visitor: &mut crate::gc::Runtime #[cfg(unix)] fn isatty_impl(fd: i32) -> bool { + crate::effect!(host_read, tty); unsafe { libc::isatty(fd) != 0 } } #[cfg(unix)] fn winsize_impl(fd: i32) -> Option<(i32, i32)> { + 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 { @@ -97,6 +99,7 @@ fn winsize_impl(fd: i32) -> Option<(i32, i32)> { #[cfg(windows)] fn isatty_impl(fd: i32) -> bool { + 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`. @@ -106,6 +109,7 @@ fn isatty_impl(fd: i32) -> bool { #[cfg(windows)] fn winsize_impl(fd: i32) -> Option<(i32, i32)> { + 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) @@ -113,11 +117,13 @@ fn winsize_impl(fd: i32) -> Option<(i32, i32)> { #[cfg(not(any(unix, windows)))] fn isatty_impl(_fd: i32) -> bool { + crate::effect!(host_read, tty); false } #[cfg(not(any(unix, windows)))] fn winsize_impl(_fd: i32) -> Option<(i32, i32)> { + crate::effect!(host_read, tty); None } diff --git a/crates/perry-stdlib/src/crypto/random.rs b/crates/perry-stdlib/src/crypto/random.rs index 1006deed5f0..8f1a7075108 100644 --- a/crates/perry-stdlib/src/crypto/random.rs +++ b/crates/perry-stdlib/src/crypto/random.rs @@ -53,6 +53,7 @@ fn validate_random_bytes_size(size: f64) -> usize { pub extern "C" fn js_crypto_random_bytes_buffer( size: f64, ) -> *mut perry_runtime::buffer::BufferHeader { + 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, @@ -81,6 +82,7 @@ pub extern "C" fn js_crypto_random_bytes_buffer( /// callback dispatch is deferred. #[no_mangle] pub unsafe extern "C" fn js_crypto_random_bytes_async(size: f64, callback_bits: f64) -> f64 { + perry_runtime::effect!(host_read, random); let buf = js_crypto_random_bytes_buffer(size); let value = if buf.is_null() { f64::from_bits(JSValue::undefined().bits()) @@ -111,6 +113,7 @@ pub unsafe extern "C" fn js_crypto_random_bytes_async(size: f64, callback_bits: /// crypto.randomBytes(size).toString('hex') -> string #[no_mangle] pub extern "C" fn js_crypto_random_bytes_hex(size: f64) -> *mut StringHeader { + perry_runtime::effect!(host_read, random); let size = validate_random_bytes_size(size); let mut bytes = vec![0u8; size]; @@ -128,6 +131,7 @@ pub extern "C" fn js_crypto_random_bytes_hex(size: f64) -> *mut StringHeader { /// `{ disableEntropyCache: true }`, which draws fresh OS entropy. #[no_mangle] pub unsafe extern "C" fn js_crypto_random_uuid(options_bits: f64) -> *mut StringHeader { + perry_runtime::effect!(host_read, random); let uuid = if validate_random_uuid_options(options_bits) { perry_uuid::v4_uncached() } else { @@ -147,6 +151,7 @@ pub unsafe extern "C" fn js_crypto_random_uuid(options_bits: f64) -> *mut String /// accepted for shape parity but does not change the generated value. #[no_mangle] pub extern "C" fn js_crypto_random_uuidv7() -> *mut StringHeader { + perry_runtime::effect!(host_read, random); let uuid = perry_uuid::v7(); let uuid_bytes = uuid.as_bytes(); js_string_from_bytes(uuid_bytes.as_ptr(), uuid_bytes.len() as u32) @@ -245,6 +250,7 @@ fn validate_random_int_arg(value: f64, arg_name: &str) -> i64 { /// Returns a uniformly distributed integer in `[min, max)`. #[no_mangle] pub extern "C" fn js_crypto_random_int(min_bits: f64, max_bits: f64) -> f64 { + 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 { diff --git a/crates/perry/src/commands/compile/optimized_libs/features.rs b/crates/perry/src/commands/compile/optimized_libs/features.rs index 93cb9a51588..db363cf1100 100644 --- a/crates/perry/src/commands/compile/optimized_libs/features.rs +++ b/crates/perry/src/commands/compile/optimized_libs/features.rs @@ -38,6 +38,7 @@ pub(crate) const GC_INSTRUMENT_KNOBS: &[&str] = &[ "PERRY_GC_VERIFY_CLASSIFIER", "PERRY_STACK_SYMBOLS", "PERRY_FIELD_REPR_VERIFY", + "PERRY_SNAP_CENSUS", ]; fn all_instruments_requested() -> bool { From 1a9d5352d050d84a3769de77f72023b47a483cf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 30 Oct 2026 21:39:45 +0000 Subject: [PATCH 21/84] WIP snap census: exclusions, realm scope, digest, rset --- crates/perry-runtime/src/gc/policy.rs | 4 +- .../src/object/global_this/populate.rs | 2 + crates/perry-runtime/src/snap_census.rs | 45 ++- .../perry-runtime/src/snap_census/runner.rs | 341 +++++++++++++++++- 4 files changed, 370 insertions(+), 22 deletions(-) diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index 8f0ed32e75f..9b210100e19 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -5104,7 +5104,9 @@ pub(super) static GC_UNSAFE_WARNED: std::sync::atomic::AtomicBool = /// worker threads are active (see GC_UNSAFE_ZONES). #[no_mangle] pub extern "C" fn js_gc_collect() { - crate::snap_census::explicit_gc(); + if crate::snap_census::explicit_gc() { + return; + } if manual_gc_blocked_by_unsafe_zone() { return; } diff --git a/crates/perry-runtime/src/object/global_this/populate.rs b/crates/perry-runtime/src/object/global_this/populate.rs index d0b14edc177..56c0717f1f6 100644 --- a/crates/perry-runtime/src/object/global_this/populate.rs +++ b/crates/perry-runtime/src/object/global_this/populate.rs @@ -74,6 +74,8 @@ fn populate_global_this_builtins_inner(singleton_at_entry: *mut ObjectHeader) { if singleton_at_entry.is_null() { return; } + // The realm is the runtime's, whichever init first asks for it. + let _realm = crate::snap_census::runtime_scope(); // #6982: this function installs several hundred builtins, and nearly every // step allocates (name strings, constructor closures, prototype objects). // Any of those allocations can trigger a collection, and an *evacuating* diff --git a/crates/perry-runtime/src/snap_census.rs b/crates/perry-runtime/src/snap_census.rs index 50bf9eae44a..c98200e1641 100644 --- a/crates/perry-runtime/src/snap_census.rs +++ b/crates/perry-runtime/src/snap_census.rs @@ -175,13 +175,19 @@ pub(crate) fn job_exit() { } } -/// An explicit `gc()`: the boundary marker of a census run. +/// An explicit `gc()`: the boundary marker of a census run. True when the +/// collection must not run (`PERRY_SNAP_RSET` keeps the heap in place). #[inline(always)] -pub(crate) fn explicit_gc() { +pub(crate) fn explicit_gc() -> bool { #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] - if runner::on() { - runner::boundary("gc"); + { + if runner::on() || runner::start_if_requested() { + runner::boundary("gc"); + } + return runner::skip_collection(); } + #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] + false } /// The census of the collection the boundary's `gc()` runs has finished. @@ -272,6 +278,35 @@ impl Drop for Bookkeeping { } } +/// Runtime work that an init triggers but does not own (populating the +/// realm on first use): its births, accesses and host touches are the +/// runtime's (init 0), reported apart. +#[inline(always)] +pub(crate) fn runtime_scope() -> RuntimeScope { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + runner::job_enter(0); + return RuntimeScope { active: true }; + } + RuntimeScope { active: false } +} + +pub(crate) struct RuntimeScope { + active: bool, +} + +impl Drop for RuntimeScope { + #[inline(always)] + fn drop(&mut self) { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if self.active { + runner::job_exit(); + } + #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] + let _ = self.active; + } +} + /// Codegen calls this where the entry module's own body starts (after its /// eager imports), in instrumented compiles only (`PERRY_SNAP_CENSUS` set /// while compiling). The census boundary of an ESM entry. @@ -279,7 +314,7 @@ impl Drop for Bookkeeping { pub extern "C" fn js_snap_entry_body() { crate::effect!(pure); #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] - if runner::on() { + if runner::on() || runner::start_if_requested() { runner::entry_body(); } } diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 3a803539da0..ab517308e51 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -300,6 +300,12 @@ struct State { /// Registered module-global root slots (the handler cannot borrow /// `GLOBAL_ROOTS`: the interrupted code may hold it). globals: WordSet, + /// One bit per 8-byte word of the reservation: a birth starts there. + starts: *mut u64, + /// Host touches while the runtime (init 0) works for an init: the realm + /// populating itself on first use. (what, call chain), first per what. + realm_hosts: Vec<(&'static str, [usize; 6])>, + excluded: u64, births: Log, listed: Log, regions: Vec<(usize, usize)>, @@ -319,12 +325,21 @@ struct State { boundary_seq: u32, boundary_at: &'static str, // census at the boundary collection - live_by: Vec<[u64; 4]>, + live_by: std::collections::HashMap<(u32, u8), [u64; 2]>, live_unattributed: [u64; 2], boundary_done: bool, /// Ground truth: every `getenv` by any code while an init runs. env_reads: Vec<(u32, String)>, mark: bool, + /// R5 (`PERRY_SNAP_RSET=1`): after the boundary, the imaged cells stay + /// write-protected and their writes are counted until exit. + rset: bool, + rset_active: bool, + /// Imaged births, sorted by address: (start, end). + cand: Vec<(usize, usize)>, + cand_writes: Vec, + cand_ptr_writes: Vec, + rset_faults: u64, t0: std::time::Instant, } @@ -332,7 +347,7 @@ struct State { // Start // --------------------------------------------------------------------------- -fn start_if_requested() -> bool { +pub(super) fn start_if_requested() -> bool { match ANCHOR.started.load(Relaxed) { 1 => return false, 2 => return true, @@ -378,6 +393,9 @@ unsafe fn start(out: String) { w: WordSet::new(28), r: WordSet::new(28), globals: WordSet::new(22), + starts: map_zeroed::(((res_hi - res_lo) / 8).div_ceil(64).max(1)), + realm_hosts: Vec::new(), + excluded: 0, births: Log::new(1 << 28), listed: Log::new(1 << 26), regions: Vec::new(), @@ -395,11 +413,17 @@ unsafe fn start(out: String) { faults_ignored: 0, boundary_seq: 0, boundary_at: "", - live_by: Vec::new(), + live_by: std::collections::HashMap::new(), live_unattributed: [0; 2], boundary_done: false, env_reads: 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, + cand: Vec::new(), + cand_writes: Vec::new(), + cand_ptr_writes: Vec::new(), + rset_faults: 0, t0: std::time::Instant::now(), }); // Init 0: the runtime's own start and the entry's body. @@ -413,6 +437,7 @@ unsafe fn start(out: String) { // The objects that exist now are the runtime's (init 0). crate::arena::arena_walk_objects_with_block_index(|header, _| { let size = (*(header as *const crate::gc::GcHeader)).size as usize; + set_start(st, header as usize); st.births.push(Birth { addr: header as usize, size: size as u32, @@ -668,6 +693,15 @@ pub(super) fn host(class: Effect, what: &'static str) { let top = st.frames.last().map_or(0, |f| f.init); if top == 0 { st.host_outside += 1; + if class != Effect::TargetFact && !st.realm_hosts.iter().any(|(w, _)| *w == what) { + let mut pcs = [0usize; crate::error::MAX_CAPTURED_FRAMES]; + let n = crate::error::capture_ips(&mut pcs); + let mut six = [0usize; 6]; + for (d, s) in six.iter_mut().zip(pcs[..n].iter().skip(1)) { + *d = *s; + } + st.realm_hosts.push((what, six)); + } return; } let i = &mut st.inits[top as usize]; @@ -693,8 +727,37 @@ pub(super) fn host(class: Effect, what: &'static str) { } } +fn set_start(st: &mut State, addr: usize) { + if let Some(i) = start_bit(st, addr) { + unsafe { *st.starts.add(i / 64) |= 1 << (i % 64) }; + } +} + +fn start_bit(st: &State, addr: usize) -> Option { + let lo = st.heap.lo; + (addr >= lo && addr < lo + st.heap.pages * PAGE).then(|| (addr - lo) / 8) +} + +/// The birth that holds `word` (its header address), from the start bitmap. +fn object_start(st: &State, word: usize) -> Option { + let mut i = start_bit(st, word)?; + let floor = i.saturating_sub(1 << 22); + loop { + let w = unsafe { *st.starts.add(i / 64) } & (u64::MAX >> (63 - (i % 64))); + if w != 0 { + let bit = 63 - w.leading_zeros() as usize; + return Some(st.heap.lo + ((i / 64) * 64 + bit) * 8); + } + if i < 64 || i - (i % 64) <= floor { + return None; + } + i = i - (i % 64) - 1; + } +} + pub(super) fn birth(addr: usize, size: usize) { let st = state(); + set_start(st, addr); let top = st.frames.last().map_or(0, |f| f.init); st.births.push(Birth { addr, @@ -719,6 +782,12 @@ pub(super) fn global_root(slot: usize) { let st = state(); st.globals.insert(slot as u64); mark_tracked(slot, slot + 8); + // Rule 2 names every binding a member of D initializes, written or not. + let top = st.frames.last().map_or(0, |f| f.init) as usize; + if top != 0 && st.inits[top].in_d { + st.w.insert(slot as u64); + flag_page(st, slot & !(PAGE - 1), PF_W); + } if st.d_mode { let page = slot & !(PAGE - 1); unsafe { libc::mprotect(page as *mut _, PAGE, libc::PROT_NONE) }; @@ -870,6 +939,7 @@ static mut PREV_TRAP: std::mem::MaybeUninit = std::mem::MaybeUn std::thread_local! { static PENDING: std::cell::Cell<[usize; 8]> = const { std::cell::Cell::new([0; 8]) }; + static RSET_WORD: std::cell::Cell = const { std::cell::Cell::new(0) }; } unsafe fn install_handlers() { @@ -902,6 +972,29 @@ extern "C" fn on_segv(sig: libc::c_int, info: *mut libc::siginfo_t, ctx: *mut li } let st = &mut *st_ptr; let page = addr & !(PAGE - 1); + if st.rset_active { + let uc = ctx as *mut libc::ucontext_t; + let gregs = &mut (*uc).uc_mcontext.gregs; + if gregs[libc::REG_ERR as usize] & 2 == 0 || st.heap.slot(addr).is_none() { + return chain(std::ptr::addr_of!(PREV_SEGV).cast(), sig); + } + st.rset_faults += 1; + let word = addr & !7; + if let Some(i) = cand_index(st, word) { + st.cand_writes[i] = st.cand_writes[i].saturating_add(1); + RSET_WORD.with(|w| w.set(word | 1)); + } + PENDING.with(|p| { + let mut v = p.get(); + if let Some(slot) = v.iter_mut().find(|s| **s == 0) { + *slot = page; + } + p.set(v) + }); + libc::mprotect(page as *mut _, PAGE, libc::PROT_READ | libc::PROT_WRITE); + gregs[libc::REG_EFL as usize] |= EFLAGS_TF; + 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 { return chain(std::ptr::addr_of!(PREV_SEGV).cast(), sig); @@ -911,8 +1004,8 @@ extern "C" fn on_segv(sig: libc::c_int, info: *mut libc::siginfo_t, ctx: *mut li let write = gregs[libc::REG_ERR as usize] & 2 != 0; let pc = gregs[libc::REG_RIP as usize] as usize; st.faults_total += 1; - note_access(st, addr & !7, write, pc); - // Open the page for one instruction. + // Open the page for one instruction (before recording: the record + // may read an object header on it). let pending = PENDING.with(|p| p.get()); let mut pend = pending; if let Some(slot) = pend.iter_mut().find(|s| **s == 0) { @@ -920,6 +1013,7 @@ extern "C" fn on_segv(sig: libc::c_int, info: *mut libc::siginfo_t, ctx: *mut li } PENDING.with(|p| p.set(pend)); libc::mprotect(page as *mut _, PAGE, libc::PROT_READ | libc::PROT_WRITE); + note_access(st, addr & !7, write, pc); gregs[libc::REG_EFL as usize] |= EFLAGS_TF; } } @@ -931,8 +1025,23 @@ extern "C" fn on_trap(sig: libc::c_int, _info: *mut libc::siginfo_t, ctx: *mut l return chain(std::ptr::addr_of!(PREV_TRAP).cast(), sig); } let st = &mut *ANCHOR.state.load(Relaxed); - for &page in pending.iter().take_while(|p| **p != 0) { - libc::mprotect(page as *mut _, PAGE, policy(st, page)); + if st.rset_active { + let w = RSET_WORD.with(|w| w.replace(0)); + if w != 0 { + let word = w & !1; + if heap_pointer(st, *(word as *const u64)) { + if let Some(i) = cand_index(st, word) { + st.cand_ptr_writes[i] = st.cand_ptr_writes[i].saturating_add(1); + } + } + } + for &page in pending.iter().take_while(|p| **p != 0) { + libc::mprotect(page as *mut _, PAGE, libc::PROT_READ); + } + } else { + for &page in pending.iter().take_while(|p| **p != 0) { + libc::mprotect(page as *mut _, PAGE, policy(st, page)); + } } PENDING.with(|p| p.set([0; 8])); let uc = ctx as *mut libc::ucontext_t; @@ -954,6 +1063,31 @@ fn note_access(st: &mut State, word: usize, write: bool, pc: usize) { st.faults_ignored += 1; return; } + // Immutable value-identity cells are not locations: a string never + // changes after its birth (its cached hash is no observation), and a + // shared canonical key list is an interned trie node. Reaching one + // through a location a member of D wrote is caught at that location. + if st.heap.slot(word).is_some() { + if let Some(h) = object_start(st, word) { + let hp = h & !(PAGE - 1); + let fp = word & !(PAGE - 1); + let (ty, flags) = unsafe { + if hp != fp { + libc::mprotect(hp as *mut _, PAGE, libc::PROT_READ); + } + let g = &*(h as *const crate::gc::GcHeader); + let r = (g.obj_type, g.gc_flags); + if hp != fp { + libc::mprotect(hp as *mut _, PAGE, policy(st, hp)); + } + r + }; + if ty == crate::gc::GC_TYPE_STRING || flags & crate::gc::GC_FLAG_SHAPE_SHARED != 0 { + st.excluded += 1; + return; + } + } + } let top = st.frames.last().map_or(0, |f| f.init) as usize; st.inits[top].faults += 1; let w = word as u64; @@ -1050,6 +1184,12 @@ pub unsafe extern "C" fn getenv(name: *const libc::c_char) -> *mut libc::c_char // Boundary and report // --------------------------------------------------------------------------- +/// True when the collection must not run (R5 keeps the heap in place). +pub(super) fn skip_collection() -> bool { + let p = ANCHOR.state.load(Relaxed); + !p.is_null() && unsafe { (*p).rset_active } +} + pub(super) fn boundary(at: &'static str) { let st = state(); if st.boundary_seq != 0 { @@ -1067,15 +1207,116 @@ pub(super) fn boundary(at: &'static str) { }); } crate::arena::refresh_inline_allocation_limit(); + write_report(st); + if st.rset { + unsafe { arm_rset(st) }; + return; + } crate::gc::gc_unsuppress(); // Births sorted by address for the census attribution. st.births.as_mut_slice().sort_unstable_by_key(|b| b.addr); - st.live_by = vec![[0u64; 4]; st.inits.len()]; - write_report(st); +} + +/// R5: the imaged cells (births of inits outside D, the runtime's included) +/// are write-protected for the rest of the run; collection stays off so they +/// keep their addresses; new births go to fresh blocks. +unsafe fn arm_rset(st: &mut State) { + let mut cand: Vec<(usize, usize)> = st + .births + .as_slice() + .iter() + .filter(|b| b.init == 0 || !st.inits[b.init as usize].in_d) + .map(|b| (b.addr, b.addr + b.size as usize)) + .collect(); + cand.sort_unstable(); + st.cand_writes = vec![0; cand.len()]; + st.cand_ptr_writes = vec![0; cand.len()]; + st.cand = cand; + crate::arena::arena_start_fresh_general_block(); + let mut last = 0usize; + for i in 0..st.cand.len() { + let (a, e) = st.cand[i]; + let mut p = a & !(PAGE - 1); + while p < e { + if p != last { + libc::mprotect(p as *mut _, PAGE, libc::PROT_READ); + last = p; + } + p += PAGE; + } + } + st.rset_active = true; + libc::atexit(rset_report); +} + +fn cand_index(st: &State, word: usize) -> Option { + let i = st.cand.partition_point(|&(a, _)| a <= word); + (i > 0 && word < st.cand[i - 1].1).then(|| i - 1) +} + +fn heap_pointer(st: &State, v: u64) -> bool { + let hi = v >> 48; + if hi != 0 && hi < 0x7FF9 { + return false; + } + let a = (v & 0xFFFF_FFFF_FFFF) as usize; + st.heap.slot(a).is_some() +} + +extern "C" fn rset_report() { + let st = state(); + if !st.rset_active { + return; + } + st.rset_active = false; + use std::io::Write; + let Ok(mut f) = std::fs::File::create(format!("{}.rset.tsv", st.out)) else { + return; + }; + let cells = st.cand_writes.iter().filter(|&&n| n > 0).count(); + let ptr_cells = st.cand_ptr_writes.iter().filter(|&&n| n > 0).count(); + let writes: u64 = st.cand_writes.iter().map(|&n| n as u64).sum(); + let ptr_writes: u64 = st.cand_ptr_writes.iter().map(|&n| n as u64).sum(); + let _ = writeln!( + f, + "#rset\tcandidates={}\tcells_written={cells}\twrites={writes}\tptr_cells={ptr_cells}\tptr_writes={ptr_writes}\tfaults={}", + st.cand.len(), + st.rset_faults + ); + // Writes per cell (histogram) and the most written cells, by birth. + let mut hist = [0u64; 24]; + for &n in &st.cand_writes { + if n > 0 { + hist[(32 - n.leading_zeros()) as usize] += 1; + } + } + for (b, n) in hist.iter().enumerate() { + if *n > 0 { + let _ = writeln!(f, "#hist\t<{}\t{n}", 1u64 << b); + } + } + let mut top: Vec = (0..st.cand.len()).filter(|&i| st.cand_writes[i] > 0).collect(); + top.sort_unstable_by_key(|&i| std::cmp::Reverse(st.cand_writes[i])); + let births = st.births.as_slice(); + for &i in top.iter().take(200) { + let (a, e) = st.cand[i]; + let init = births.iter().find(|b| b.addr == a).map_or(0, |b| b.init); + let ty = unsafe { (*(a as *const crate::gc::GcHeader)).obj_type }; + let _ = writeln!( + f, + "{a:x}\t{}\t{:x}\t{}\t{}\t{}\t{}", + init, + st.inits[init as usize].addr, + crate::gc::gc_type_info(ty).map_or("?", |i| i.name), + e - a, + st.cand_writes[i], + st.cand_ptr_writes[i] + ); + } } /// The boundary collection's census visits a live object. -pub(super) fn census_live(header: *const u8, _obj_type: u8, size: usize) { +pub(super) fn census_live(header: *const u8, obj_type: u8, size: usize) { let p = ANCHOR.state.load(Relaxed); if p.is_null() { return; @@ -1092,8 +1333,8 @@ pub(super) fn census_live(header: *const u8, _obj_type: u8, size: usize) { st.live_unattributed[1] += size as u64; return; } - let init = b[i - 1].init as usize; - let row = &mut st.live_by[init]; + let init = b[i - 1].init; + let row = st.live_by.entry((init, obj_type)).or_insert([0; 2]); row[0] += 1; row[1] += size as u64; } @@ -1112,10 +1353,11 @@ pub(super) fn after_boundary_collection() { let path = format!("{}.live.tsv", st.out); if let Ok(mut f) = std::fs::File::create(&path) { let _ = writeln!(f, "#unattributed\t{}\t{}", st.live_unattributed[0], st.live_unattributed[1]); - for (id, row) in st.live_by.iter().enumerate() { - if row[0] != 0 { - let _ = writeln!(f, "{id}\t{}\t{}", row[0], row[1]); - } + let mut rows: Vec<_> = st.live_by.iter().collect(); + rows.sort(); + for ((id, t), row) in rows { + let name = crate::gc::gc_type_info(*t).map_or("?", |i| i.name); + let _ = writeln!(f, "{id}\t{name}\t{}\t{}", row[0], row[1]); } } } @@ -1207,6 +1449,73 @@ fn write_report(st: &mut State) { i.host_count, ); } + // Step 6: a digest of the imaged heap per init. A birth is named by its + // init's evaluation address and its rank among that init's births; a + // word that holds a pointer into the reservation hashes as the name of + // the object it points into (plus the offset), a word a member of D + // wrote is left out (the image holds it as it was before), any other + // word hashes as itself. + { + let log = st.births.as_slice(); + let mut rank: std::collections::HashMap = std::collections::HashMap::new(); + let mut names: Vec<(usize, usize, u64)> = Vec::with_capacity(log.len()); + for b in log { + let k = rank.entry(b.init).or_insert(0); + let a = st.inits[b.init as usize].addr as u64; + names.push((b.addr, b.addr + b.size as usize, a.wrapping_mul(0x100000001B3) ^ *k)); + *k += 1; + } + names.sort_unstable(); + let name_of = |v: usize| -> Option<(u64, usize)> { + let i = names.partition_point(|&(a, _, _)| a <= v); + (i > 0 && v < names[i - 1].1).then(|| (names[i - 1].2, v - names[i - 1].0)) + }; + let mut per: std::collections::BTreeMap = Default::default(); + let mix = |h: &mut u64, x: u64| { + *h ^= x; + *h = h.wrapping_mul(0x100000001B3); + }; + for b in log { + if b.init != 0 && st.inits[b.init as usize].in_d { + continue; + } + let e = per + .entry(st.inits[b.init as usize].addr) + .or_insert((0xcbf29ce484222325, 0, 0)); + e.1 += 1; + let mut off = 0; + while off + 8 <= b.size as usize { + let word = b.addr + off; + off += 8; + if st.w.contains(word as u64) { + continue; + } + let v = unsafe { *(word as *const u64) }; + e.2 += 1; + let hi = v >> 48; + if hi == 0 || hi >= 0x7FF9 { + if let Some((n, o)) = name_of((v & 0xFFFF_FFFF_FFFF) as usize) { + mix(&mut e.0, hi ^ 0xA5A5); + mix(&mut e.0, n); + mix(&mut e.0, o as u64); + continue; + } + } + mix(&mut e.0, v); + } + } + let mut total = 0xcbf29ce484222325u64; + for (addr, (h, objs, words)) in &per { + mix(&mut total, *h); + let _ = writeln!(f, "#digest\t{addr:x}\t{h:016x}\t{objs}\t{words}"); + } + let _ = writeln!(f, "#digest_total\t{total:016x}\t{}", per.len()); + } + let _ = writeln!(f, "#excluded\t{}", st.excluded); + for (what, pcs) in &st.realm_hosts { + let pcs: Vec = pcs.iter().map(|p| format!("{p:x}")).collect(); + let _ = writeln!(f, "#realm_host\t{what}\t{}", pcs.join(",")); + } for (init, name) in &st.env_reads { let _ = writeln!(f, "#getenv\t{init}\t{name}"); } From 57eef338dacd7fab0e481ab8df0883a2d3bec590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 30 Oct 2026 21:58:17 +0000 Subject: [PATCH 22/84] WIP snap census: top mode, facts dump --- .../perry-runtime/src/snap_census/runner.rs | 116 ++++++++++++++++++ crates/perry-wpfacts/src/units/driver.rs | 46 +++++++ 2 files changed, 162 insertions(+) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index ab517308e51..649602ed620 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -306,6 +306,18 @@ struct State { /// populating itself on first use. (what, call chain), first per what. realm_hosts: Vec<(&'static str, [usize; 6])>, excluded: u64, + /// `PERRY_SNAP_MODE=top`: every member of D may read and write every + /// location that exists when it starts (a ⊤ summary). Members run + /// untracked; any other init that touches such a location joins D. + top_mode: bool, + /// One bit per reservation word, at birth starts: born before the latest + /// member's start (the ⊤ pre-existing set). + pre_bits: *mut u64, + pre_upto: usize, + globals_log: Vec, + pre_globals: WordSet, + pre_globals_upto: usize, + top_armed: bool, births: Log, listed: Log, regions: Vec<(usize, usize)>, @@ -396,6 +408,13 @@ unsafe fn start(out: String) { starts: map_zeroed::(((res_hi - res_lo) / 8).div_ceil(64).max(1)), realm_hosts: Vec::new(), excluded: 0, + top_mode: std::env::var("PERRY_SNAP_MODE").is_ok_and(|v| v == "top"), + pre_bits: map_zeroed::(((res_hi - res_lo) / 8).div_ceil(64).max(1)), + pre_upto: 0, + globals_log: Vec::new(), + pre_globals: WordSet::new(22), + pre_globals_upto: 0, + top_armed: false, births: Log::new(1 << 28), listed: Log::new(1 << 26), regions: Vec::new(), @@ -453,6 +472,7 @@ unsafe fn start(out: String) { crate::gc::for_each_global_root_slot(|slot| slots.push(slot)); for slot in slots { st.globals.insert(slot as u64); + st.globals_log.push(slot); mark_tracked(slot, slot + 8); } install_handlers(); @@ -612,6 +632,9 @@ pub(super) fn init_enter(addr: usize) { || (parent_id != 0 && st.inits[parent_id as usize].from_entry_body); init.seeded = st.seeded.contains(&addr); init.in_d = init.seeded; + if st.top_mode && init.in_d { + unsafe { extend_top(st) }; + } if init.dynamic && st.rule4 && !st.rule4_armed { unsafe { arm_rule4(st) }; } @@ -708,6 +731,9 @@ pub(super) fn host(class: Effect, what: &'static str) { i.host_count += 1; if class != Effect::TargetFact && !i.in_d { i.in_d = true; + if st.top_mode { + unsafe { extend_top(st) }; + } set_mode_for_top(st); } let i = &mut st.inits[top as usize]; @@ -781,6 +807,7 @@ pub(super) fn region_mapped(base: usize, len: usize) { pub(super) fn global_root(slot: usize) { let st = state(); st.globals.insert(slot as u64); + st.globals_log.push(slot); mark_tracked(slot, slot + 8); // Rule 2 names every binding a member of D initializes, written or not. let top = st.frames.last().map_or(0, |f| f.init) as usize; @@ -822,6 +849,13 @@ fn policy(st: &State, page: usize) -> i32 { if f & PF_TRACKED == 0 { return libc::PROT_READ | libc::PROT_WRITE; } + if st.top_mode { + return if !st.d_mode && f & PF_PRE != 0 { + libc::PROT_NONE + } else { + libc::PROT_READ | libc::PROT_WRITE + }; + } if st.d_mode { return libc::PROT_NONE; } @@ -855,6 +889,20 @@ 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 { + for_each_tracked_range(st, |b, e| { + libc::mprotect(b as *mut _, e - b, libc::PROT_READ | libc::PROT_WRITE); + }); + if !st.d_mode { + for &page in st.listed.as_slice() { + let p = policy(st, page); + if p != libc::PROT_READ | libc::PROT_WRITE { + libc::mprotect(page as *mut _, PAGE, p); + } + } + } + return; + } if st.d_mode { for_each_tracked_range(st, |b, e| { libc::mprotect(b as *mut _, e - b, libc::PROT_NONE); @@ -921,6 +969,55 @@ unsafe fn arm_rule4(st: &mut State) { apply_protection(st); } +/// ⊤ mode: everything born or registered so far is pre-existing for the +/// member that starts now. +unsafe fn extend_top(st: &mut State) { + let from = st.pre_upto; + st.pre_upto = st.births.len; + for k in from..st.pre_upto { + let b = *st.births.p.add(k); + if let Some(i) = start_bit(st, b.addr) { + *st.pre_bits.add(i / 64) |= 1 << (i % 64); + } + let mut p = b.addr & !(PAGE - 1); + while p < b.addr + b.size as usize { + flag_page(st, p, PF_PRE); + p += PAGE; + } + } + for k in st.pre_globals_upto..st.globals_log.len() { + let s = st.globals_log[k]; + st.pre_globals.insert(s as u64); + flag_page(st, s & !(PAGE - 1), PF_PRE); + } + st.pre_globals_upto = st.globals_log.len(); + if !st.top_armed { + st.top_armed = true; + let (rb, re) = st.realm; + let mut p = rb & !(PAGE - 1); + while p < re { + flag_page(st, p, PF_PRE); + p += PAGE; + } + } +} + +fn top_pre_existing(st: &State, word: usize) -> bool { + if !st.top_armed { + return false; + } + if word >= st.realm.0 && word < st.realm.1 { + return true; + } + if st.pre_globals.contains(word as u64) { + return true; + } + match object_start(st, word).and_then(|h| start_bit(st, h)) { + Some(i) => unsafe { *st.pre_bits.add(i / 64) & (1 << (i % 64)) != 0 }, + None => false, + } +} + fn pre_existing(st: &State, word: usize) -> bool { let (rb, re) = st.realm; if word >= rb && word < re { @@ -1091,6 +1188,25 @@ fn note_access(st: &mut State, word: usize, write: bool, pc: usize) { let top = st.frames.last().map_or(0, |f| f.init) as usize; st.inits[top].faults += 1; let w = word as u64; + if st.top_mode { + if st.d_mode || top == 0 || !top_pre_existing(st, word) { + return; + } + let i = &mut st.inits[top]; + if i.conflict.is_none() { + i.conflict = Some(Conflict { + rule: if write { 3 } else { 2 }, + word, + pc, + write, + }); + } + i.in_d = true; + unsafe { extend_top(st) }; + st.d_mode = true; + unsafe { apply_protection(st) }; + return; + } if st.d_mode { if write { st.w.insert(w); diff --git a/crates/perry-wpfacts/src/units/driver.rs b/crates/perry-wpfacts/src/units/driver.rs index d1ffb947f57..6eb4dbe9739 100644 --- a/crates/perry-wpfacts/src/units/driver.rs +++ b/crates/perry-wpfacts/src/units/driver.rs @@ -980,6 +980,11 @@ pub(crate) fn run(sorted: &[(&str, &Module)], cfg: &FactsConfig) -> ProgramFacts if cfg.unit_io { pf.unit_io = unit_io(sorted, &solves, &order, &per_unit, &input); } + // SNAP census (dump only, `PERRY_SNAP_FACTS=`): what each + // unit's code may write and read outside itself, at the fixpoint. + if let Some(path) = std::env::var_os("PERRY_SNAP_FACTS") { + snap_facts_dump(std::path::Path::new(&path), sorted, &solves, &per_unit); + } // The cells' writes at the fixpoint, each once per unit writing it // (each unit holds its own), for the tests to read their size. #[cfg(test)] @@ -1230,6 +1235,47 @@ pub(super) fn solve_level( }) } +/// SNAP census dump: per unit, its writes of other units' bindings, its +/// stores into other units' layouts, its shared-object effects (⊤ included), +/// its cells, and what it reads of them. One line per fact, tab-separated. +fn snap_facts_dump( + path: &std::path::Path, + sorted: &[(&str, &Module)], + solves: &[Solve], + per_unit: &[Option], +) { + use std::io::Write; + let Ok(f) = std::fs::File::create(path) else { + return; + }; + let mut f = std::io::BufWriter::new(f); + for (si, s) in solves.iter().enumerate() { + let Some(u) = per_unit[si].as_ref() else { + continue; + }; + let _ = writeln!(f, "unit\t{}\t{}", sorted[s.mi].0, s.key); + for ((p, n), _) in &u.writes_out { + let _ = writeln!(f, "write\t{p}\t{n}"); + } + for (d, k, _) in &u.stores_out { + let _ = writeln!(f, "store\t{d}\t{k}"); + } + for w in &u.shared_out { + let _ = writeln!(f, "shared_w\t{w:?}"); + } + for (k, _) in &u.cells_out { + let _ = writeln!(f, "cell_w\t{k}"); + } + for k in &u.cells_read { + let _ = writeln!(f, "cell_r\t{k}"); + } + for (d, _) in &u.layouts_read { + let _ = writeln!(f, "layout_r\t{d}"); + } + let _ = writeln!(f, "shared_r\t{:?}", u.shared_reads); + } +} + /// `FactsConfig::unit_io` (design §13.3): per unit, a digest of every input /// it reads (the contracts of the units it is ordered after, its closed /// functions' argument contracts, the store effects on and the census From 4d17b80c02317eb603d4eab637634d70a2869f7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 30 Oct 2026 22:19:46 +0000 Subject: [PATCH 23/84] WIP snap census: no ordinary-build footprint (knob list, entry decl) --- crates/perry-codegen/src/codegen/entry.rs | 7 +++++-- crates/perry-codegen/src/runtime_decls/mod.rs | 2 -- crates/perry-runtime/src/gc/instruments.rs | 1 - .../perry/src/commands/compile/optimized_libs/features.rs | 1 - 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index c8706bfbcab..e596d2160ad 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -190,6 +190,9 @@ pub(super) fn compile_module_entry( llmod.declare_function(&format!("{}__init", prefix), VOID, &[]); llmod.declare_function(&format!("__perry_prepare_literals_{}", prefix), VOID, &[]); } + if std::env::var_os("PERRY_SNAP_CENSUS").is_some_and(|v| !v.is_empty()) { + llmod.declare_function("js_snap_entry_body", VOID, &[]); + } // Issue #753: emit a no-op `__init` stub so the // dispatch site in some other module that does `await // import("./entry.ts")` resolves at link time. The entry @@ -696,8 +699,8 @@ pub(super) fn compile_module_entry( } blk.call_void(&format!("{}__init", prefix), &[]); } - // SNAP census, instrumented compiles only (`PERRY_SNAP_CENSUS` - // set while compiling): the entry's own body starts here. + // SNAP census (`PERRY_SNAP_CENSUS` set while compiling, with + // `PERRY_GC_INSTRUMENTS=1`): the entry's own body starts here. if std::env::var_os("PERRY_SNAP_CENSUS").is_some_and(|v| !v.is_empty()) { blk.call_void("js_snap_entry_body", &[]); } diff --git a/crates/perry-codegen/src/runtime_decls/mod.rs b/crates/perry-codegen/src/runtime_decls/mod.rs index 75c137e3202..33536b3dbce 100644 --- a/crates/perry-codegen/src/runtime_decls/mod.rs +++ b/crates/perry-codegen/src/runtime_decls/mod.rs @@ -184,8 +184,6 @@ pub fn declare_phase2_1(module: &mut LlModule) { /// which the mark phase scans alongside the stack. pub fn declare_phase_a_strings(module: &mut LlModule) { module.declare_function("js_gc_register_global_root", VOID, &[I64]); - // SNAP census: the entry body's start (instrumented compiles only). - module.declare_function("js_snap_entry_body", VOID, &[]); // Phase B (core types) additions live here too — split into a separate // function once they grow. diff --git a/crates/perry-runtime/src/gc/instruments.rs b/crates/perry-runtime/src/gc/instruments.rs index 6577f4586c2..c7cc9486b6f 100644 --- a/crates/perry-runtime/src/gc/instruments.rs +++ b/crates/perry-runtime/src/gc/instruments.rs @@ -573,7 +573,6 @@ pub(crate) const INSTRUMENT_KNOBS: &[&str] = &[ "PERRY_GC_VERIFY_CLASSIFIER", "PERRY_STACK_SYMBOLS", "PERRY_FIELD_REPR_VERIFY", - "PERRY_SNAP_CENSUS", ]; /// The first instrument knob set (non-empty) in the environment, if any. diff --git a/crates/perry/src/commands/compile/optimized_libs/features.rs b/crates/perry/src/commands/compile/optimized_libs/features.rs index db363cf1100..93cb9a51588 100644 --- a/crates/perry/src/commands/compile/optimized_libs/features.rs +++ b/crates/perry/src/commands/compile/optimized_libs/features.rs @@ -38,7 +38,6 @@ pub(crate) const GC_INSTRUMENT_KNOBS: &[&str] = &[ "PERRY_GC_VERIFY_CLASSIFIER", "PERRY_STACK_SYMBOLS", "PERRY_FIELD_REPR_VERIFY", - "PERRY_SNAP_CENSUS", ]; fn all_instruments_requested() -> bool { From a29a64bac6b9d984fc5e84460635376a1d44cf2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 30 Oct 2026 22:46:36 +0000 Subject: [PATCH 24/84] WIP snap census: TLS module globals, handler state off TLS/.bss --- .../perry-runtime/src/snap_census/runner.rs | 96 +++++++++++-------- 1 file changed, 55 insertions(+), 41 deletions(-) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 649602ed620..b7f650d1d6b 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -323,6 +323,13 @@ struct State { regions: Vec<(usize, usize)>, heap: PageMap, bin: PageMap, + /// The main thread's static TLS block: module globals live there in a + /// program with a Worker (per-thread init flags and bindings, #10399). + tls: PageMap, + /// Pages opened for the current instruction (the handler's own state is + /// never in TLS: a TLS page may be protected). + pending: [usize; 8], + rset_word: usize, realm: (usize, usize), d_mode: bool, perf_fd: i32, @@ -391,6 +398,14 @@ unsafe fn start(out: String) { let (res_lo, res_hi) = crate::arena::snap_reservation_bounds(); let realm = crate::value::addr_class::realm::bounds(); let (bin_lo, bin_hi) = writable_image_range(realm.0); + let mut fs: usize = 0; + libc::syscall(libc::SYS_arch_prctl, 0x1003 /* ARCH_GET_FS */, &mut fs as *mut usize); + let (tls_lo, tls_hi) = if fs != 0 && !(fs >= bin_lo && fs < bin_hi) { + let (lo, hi) = writable_image_range(fs); + (lo.max(fs.saturating_sub(16 << 20)), hi.min(fs + (1 << 20))) + } else { + (fs, fs + PAGE) + }; let mut st = Box::new(State { out, inits: Vec::with_capacity(4096), @@ -420,6 +435,9 @@ unsafe fn start(out: String) { regions: Vec::new(), heap: PageMap::new(res_lo, res_hi), bin: PageMap::new(bin_lo, bin_hi), + tls: PageMap::new(tls_lo, tls_hi), + pending: [0; 8], + rset_word: 0, realm, d_mode: false, perf_fd: open_instruction_counter(), @@ -815,8 +833,8 @@ pub(super) fn global_root(slot: usize) { st.w.insert(slot as u64); flag_page(st, slot & !(PAGE - 1), PF_W); } - if st.d_mode { - let page = slot & !(PAGE - 1); + 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) }; } } @@ -834,7 +852,7 @@ fn mark_tracked(lo: usize, hi: usize) { #[inline] fn page_flags(st: &State, addr: usize) -> Option<*mut u8> { - st.heap.slot(addr).or_else(|| st.bin.slot(addr)) + st.heap.slot(addr).or_else(|| st.bin.slot(addr)).or_else(|| st.tls.slot(addr)) } // --------------------------------------------------------------------------- @@ -874,16 +892,18 @@ unsafe fn for_each_tracked_range(st: &State, mut f: impl FnMut(usize, usize)) { for &(b, e) in &st.regions { f(b, e); } - let mut i = 0; - while i < st.bin.pages { - if *st.bin.flags.add(i) & PF_TRACKED != 0 { - let s = i; - while i < st.bin.pages && *st.bin.flags.add(i) & PF_TRACKED != 0 { + for m in [&st.bin, &st.tls] { + let mut i = 0; + while i < m.pages { + if *m.flags.add(i) & PF_TRACKED != 0 { + let s = i; + while i < m.pages && *m.flags.add(i) & PF_TRACKED != 0 { + i += 1; + } + f(m.lo + s * PAGE, m.lo + i * PAGE); + } else { i += 1; } - f(st.bin.lo + s * PAGE, st.bin.lo + i * PAGE); - } else { - i += 1; } } } @@ -1031,25 +1051,25 @@ fn pre_existing(st: &State, word: usize) -> bool { // Signals // --------------------------------------------------------------------------- -static mut PREV_SEGV: std::mem::MaybeUninit = std::mem::MaybeUninit::uninit(); -static mut PREV_TRAP: std::mem::MaybeUninit = std::mem::MaybeUninit::uninit(); +/// The dispositions the runner replaced, alone on their page (a page of +/// `.bss` may also hold module globals, which the runner protects). +#[repr(C, align(4096))] +struct Prev(std::mem::MaybeUninit); +static mut PREV_SEGV: Prev = Prev(std::mem::MaybeUninit::uninit()); +static mut PREV_TRAP: Prev = Prev(std::mem::MaybeUninit::uninit()); -std::thread_local! { - static PENDING: std::cell::Cell<[usize; 8]> = const { std::cell::Cell::new([0; 8]) }; - static RSET_WORD: std::cell::Cell = const { std::cell::Cell::new(0) }; -} unsafe fn install_handlers() { let mut a: libc::sigaction = std::mem::zeroed(); a.sa_sigaction = on_segv as *const () as usize; a.sa_flags = libc::SA_SIGINFO; libc::sigemptyset(&mut a.sa_mask); - libc::sigaction(libc::SIGSEGV, &a, std::ptr::addr_of_mut!(PREV_SEGV).cast()); + libc::sigaction(libc::SIGSEGV, &a, std::ptr::addr_of_mut!(PREV_SEGV.0).cast()); let mut t: libc::sigaction = std::mem::zeroed(); t.sa_sigaction = on_trap as *const () as usize; t.sa_flags = libc::SA_SIGINFO; libc::sigemptyset(&mut t.sa_mask); - libc::sigaction(libc::SIGTRAP, &t, std::ptr::addr_of_mut!(PREV_TRAP).cast()); + libc::sigaction(libc::SIGTRAP, &t, std::ptr::addr_of_mut!(PREV_TRAP.0).cast()); } unsafe fn chain(prev: *const libc::sigaction, sig: i32) { @@ -1065,7 +1085,7 @@ extern "C" fn on_segv(sig: libc::c_int, info: *mut libc::siginfo_t, ctx: *mut li let addr = (*info).si_addr() as usize; let st_ptr = ANCHOR.state.load(Relaxed); if st_ptr.is_null() { - return chain(std::ptr::addr_of!(PREV_SEGV).cast(), sig); + return chain(std::ptr::addr_of!(PREV_SEGV.0).cast(), sig); } let st = &mut *st_ptr; let page = addr & !(PAGE - 1); @@ -1073,28 +1093,24 @@ extern "C" fn on_segv(sig: libc::c_int, info: *mut libc::siginfo_t, ctx: *mut li let uc = ctx as *mut libc::ucontext_t; let gregs = &mut (*uc).uc_mcontext.gregs; if gregs[libc::REG_ERR as usize] & 2 == 0 || st.heap.slot(addr).is_none() { - return chain(std::ptr::addr_of!(PREV_SEGV).cast(), sig); + return chain(std::ptr::addr_of!(PREV_SEGV.0).cast(), sig); } st.rset_faults += 1; let word = addr & !7; if let Some(i) = cand_index(st, word) { st.cand_writes[i] = st.cand_writes[i].saturating_add(1); - RSET_WORD.with(|w| w.set(word | 1)); + st.rset_word = word | 1; + } + if let Some(slot) = st.pending.iter_mut().find(|s| **s == 0) { + *slot = page; } - PENDING.with(|p| { - let mut v = p.get(); - if let Some(slot) = v.iter_mut().find(|s| **s == 0) { - *slot = page; - } - p.set(v) - }); libc::mprotect(page as *mut _, PAGE, libc::PROT_READ | libc::PROT_WRITE); gregs[libc::REG_EFL as usize] |= EFLAGS_TF; 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 { - return chain(std::ptr::addr_of!(PREV_SEGV).cast(), sig); + return chain(std::ptr::addr_of!(PREV_SEGV.0).cast(), sig); } let uc = ctx as *mut libc::ucontext_t; let gregs = &mut (*uc).uc_mcontext.gregs; @@ -1103,12 +1119,9 @@ extern "C" fn on_segv(sig: libc::c_int, info: *mut libc::siginfo_t, ctx: *mut li st.faults_total += 1; // Open the page for one instruction (before recording: the record // may read an object header on it). - let pending = PENDING.with(|p| p.get()); - let mut pend = pending; - if let Some(slot) = pend.iter_mut().find(|s| **s == 0) { + if let Some(slot) = st.pending.iter_mut().find(|s| **s == 0) { *slot = page; } - PENDING.with(|p| p.set(pend)); libc::mprotect(page as *mut _, PAGE, libc::PROT_READ | libc::PROT_WRITE); note_access(st, addr & !7, write, pc); gregs[libc::REG_EFL as usize] |= EFLAGS_TF; @@ -1117,13 +1130,14 @@ extern "C" fn on_segv(sig: libc::c_int, info: *mut libc::siginfo_t, ctx: *mut li extern "C" fn on_trap(sig: libc::c_int, _info: *mut libc::siginfo_t, ctx: *mut libc::c_void) { unsafe { - let pending = PENDING.with(|p| p.get()); - if pending[0] == 0 { - return chain(std::ptr::addr_of!(PREV_TRAP).cast(), sig); + let st_ptr = ANCHOR.state.load(Relaxed); + if st_ptr.is_null() || (*st_ptr).pending[0] == 0 { + return chain(std::ptr::addr_of!(PREV_TRAP.0).cast(), sig); } - let st = &mut *ANCHOR.state.load(Relaxed); + let st = &mut *st_ptr; + let pending = st.pending; if st.rset_active { - let w = RSET_WORD.with(|w| w.replace(0)); + let w = std::mem::replace(&mut st.rset_word, 0); if w != 0 { let word = w & !1; if heap_pointer(st, *(word as *const u64)) { @@ -1140,7 +1154,7 @@ extern "C" fn on_trap(sig: libc::c_int, _info: *mut libc::siginfo_t, ctx: *mut l libc::mprotect(page as *mut _, PAGE, policy(st, page)); } } - PENDING.with(|p| p.set([0; 8])); + st.pending = [0; 8]; let uc = ctx as *mut libc::ucontext_t; (*uc).uc_mcontext.gregs[libc::REG_EFL as usize] &= !EFLAGS_TF; } @@ -1153,7 +1167,7 @@ fn note_access(st: &mut State, word: usize, write: bool, pc: usize) { return; } // Words of the binary range count only as global slots or realm cells. - if st.bin.slot(word).is_some() + if (st.bin.slot(word).is_some() || st.tls.slot(word).is_some()) && !(word >= st.realm.0 && word < st.realm.1) && !st.globals.contains(word as u64) { From ddc8363f80b9a0fefd8c182246ebbf2e02ec2ea5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 30 Oct 2026 23:14:49 +0000 Subject: [PATCH 25/84] WIP snap census: handler diagnostics --- .../perry-runtime/src/snap_census/runner.rs | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index b7f650d1d6b..f8c44367f07 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -330,6 +330,8 @@ struct State { /// never in TLS: a TLS page may be protected). pending: [usize; 8], rset_word: usize, + /// Handler depth: a fault inside the handler is reported, not hidden. + in_handler: u32, realm: (usize, usize), d_mode: bool, perf_fd: i32, @@ -438,6 +440,7 @@ unsafe fn start(out: String) { tls: PageMap::new(tls_lo, tls_hi), pending: [0; 8], rset_word: 0, + in_handler: 0, realm, d_mode: false, perf_fd: open_instruction_counter(), @@ -1062,7 +1065,7 @@ static mut PREV_TRAP: Prev = Prev(std::mem::MaybeUninit::uninit()); unsafe fn install_handlers() { let mut a: libc::sigaction = std::mem::zeroed(); a.sa_sigaction = on_segv as *const () as usize; - a.sa_flags = libc::SA_SIGINFO; + a.sa_flags = libc::SA_SIGINFO | libc::SA_NODEFER; libc::sigemptyset(&mut a.sa_mask); libc::sigaction(libc::SIGSEGV, &a, std::ptr::addr_of_mut!(PREV_SEGV.0).cast()); let mut t: libc::sigaction = std::mem::zeroed(); @@ -1072,6 +1075,26 @@ unsafe fn install_handlers() { libc::sigaction(libc::SIGTRAP, &t, std::ptr::addr_of_mut!(PREV_TRAP.0).cast()); } +/// One stderr line of hex words, without allocating (signal context). +unsafe fn diag(words: &[u64]) { + let mut buf = [0u8; 256]; + let mut n = 0; + for s in b"snap census: foreign fault" { + buf[n] = *s; + n += 1; + } + for &w in words { + buf[n] = b' '; + n += 1; + for i in (0..16).rev() { + buf[n] = b"0123456789abcdef"[((w >> (i * 4)) & 15) as usize]; + n += 1; + } + } + buf[n] = b'\n'; + libc::write(2, buf.as_ptr().cast(), n + 1); +} + unsafe fn chain(prev: *const libc::sigaction, sig: i32) { // Not ours: restore the previous disposition; the instruction faults // again and takes it. @@ -1088,6 +1111,27 @@ extern "C" fn on_segv(sig: libc::c_int, info: *mut libc::siginfo_t, ctx: *mut li return chain(std::ptr::addr_of!(PREV_SEGV.0).cast(), sig); } let st = &mut *st_ptr; + 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; + let top = st.frames.last().map_or(0, |f| f.init) as u64; + diag(&[0xdead, addr as u64, pc, top]); + return chain(std::ptr::addr_of!(PREV_SEGV.0).cast(), sig); + } + st.in_handler += 1; + on_segv_inner(st, sig, info, ctx); + st.in_handler -= 1; + } +} + +unsafe fn on_segv_inner( + st: &mut State, + sig: libc::c_int, + info: *mut libc::siginfo_t, + ctx: *mut libc::c_void, +) { + { + let addr = (*info).si_addr() as usize; let page = addr & !(PAGE - 1); if st.rset_active { let uc = ctx as *mut libc::ucontext_t; @@ -1110,6 +1154,11 @@ extern "C" fn on_segv(sig: libc::c_int, info: *mut libc::siginfo_t, ctx: *mut li } 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 { + 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); + let range = if st.heap.slot(addr).is_some() { 1 } else if st.bin.slot(addr).is_some() { 2 } else if st.tls.slot(addr).is_some() { 3 } else { 0 }; + diag(&[addr as u64, pc, flags, range, st.d_mode as u64, st.top_mode as u64]); return chain(std::ptr::addr_of!(PREV_SEGV.0).cast(), sig); } let uc = ctx as *mut libc::ucontext_t; From 2d4e3f08056d5eaad0e7fc5c9c1a3504c713374c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 30 Oct 2026 23:25:36 +0000 Subject: [PATCH 26/84] WIP snap census: exact static TLS block --- .../perry-runtime/src/snap_census/runner.rs | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index f8c44367f07..99a925e7a56 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -402,11 +402,13 @@ unsafe fn start(out: String) { let (bin_lo, bin_hi) = writable_image_range(realm.0); let mut fs: usize = 0; libc::syscall(libc::SYS_arch_prctl, 0x1003 /* ARCH_GET_FS */, &mut fs as *mut usize); - let (tls_lo, tls_hi) = if fs != 0 && !(fs >= bin_lo && fs < bin_hi) { - let (lo, hi) = writable_image_range(fs); - (lo.max(fs.saturating_sub(16 << 20)), hi.min(fs + (1 << 20))) + // The executable's static TLS block sits right below the thread pointer + // (x86_64 variant II): [fs - memsz rounded to its alignment, fs). + let tls_size = executable_tls_size(); + let (tls_lo, tls_hi) = if fs != 0 && tls_size != 0 { + (fs - tls_size, fs) } else { - (fs, fs + PAGE) + (fs, fs) }; let mut st = Box::new(State { out, @@ -560,6 +562,28 @@ fn writable_image_range(inside: usize) -> (usize, usize) { (lo, hi) } +/// `PT_TLS` memsz of the executable, rounded up to its alignment. +fn executable_tls_size() -> usize { + unsafe extern "C" fn each( + info: *mut libc::dl_phdr_info, + _size: libc::size_t, + data: *mut libc::c_void, + ) -> libc::c_int { + 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_TLS { + let align = (ph.p_align as usize).max(1); + *(data as *mut usize) = (ph.p_memsz as usize).div_ceil(align) * align; + } + } + 1 // the executable is the first object; stop + } + let mut size = 0usize; + unsafe { libc::dl_iterate_phdr(Some(each), (&mut size as *mut usize).cast()) }; + size +} + fn open_instruction_counter() -> i32 { // perf_event_attr, version 0 layout (64 bytes). let mut attr = [0u64; 8]; From 0d2b991bbd1057072f13c6c084f3316ab5343944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 30 Oct 2026 23:33:17 +0000 Subject: [PATCH 27/84] WIP snap census: handlers on own stack --- crates/perry-runtime/src/snap_census/runner.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 99a925e7a56..a843c683efb 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -1087,14 +1087,23 @@ static mut PREV_TRAP: Prev = Prev(std::mem::MaybeUninit::uninit()); unsafe fn install_handlers() { + // The handlers run on their own stack: a fault whose frame cannot be + // pushed on the current stack (a protected page) still reaches them. + let size = 1 << 20; + let ss = libc::stack_t { + ss_sp: map_zeroed::(size).cast(), + ss_flags: 0, + ss_size: size, + }; + libc::sigaltstack(&ss, std::ptr::null_mut()); let mut a: libc::sigaction = std::mem::zeroed(); a.sa_sigaction = on_segv as *const () as usize; - a.sa_flags = libc::SA_SIGINFO | libc::SA_NODEFER; + a.sa_flags = libc::SA_SIGINFO | libc::SA_NODEFER | libc::SA_ONSTACK; libc::sigemptyset(&mut a.sa_mask); libc::sigaction(libc::SIGSEGV, &a, std::ptr::addr_of_mut!(PREV_SEGV.0).cast()); let mut t: libc::sigaction = std::mem::zeroed(); t.sa_sigaction = on_trap as *const () as usize; - t.sa_flags = libc::SA_SIGINFO; + t.sa_flags = libc::SA_SIGINFO | libc::SA_ONSTACK; libc::sigemptyset(&mut t.sa_mask); libc::sigaction(libc::SIGTRAP, &t, std::ptr::addr_of_mut!(PREV_TRAP.0).cast()); } From 6ae688eac6f8cbe03774e73d516315f39582207e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 30 Oct 2026 23:45:07 +0000 Subject: [PATCH 28/84] WIP snap census: never protect the thread-pointer page --- crates/perry-runtime/src/snap_census/runner.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index a843c683efb..2c8568bc00c 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -306,6 +306,9 @@ struct State { /// populating itself on first use. (what, call chain), first per what. realm_hosts: Vec<(&'static str, [usize; 6])>, excluded: u64, + /// Global root slots on no tracked page (the thread pointer's page, + /// or not a binding: a root slot elsewhere): not watched. + untracked_slots: u64, /// `PERRY_SNAP_MODE=top`: every member of D may read and write every /// location that exists when it starts (a ⊤ summary). Members run /// untracked; any other init that touches such a location joins D. @@ -405,8 +408,11 @@ unsafe fn start(out: String) { // The executable's static TLS block sits right below the thread pointer // (x86_64 variant II): [fs - memsz rounded to its alignment, fs). let tls_size = executable_tls_size(); + // The page holding the thread pointer is never protected: the kernel + // writes the thread's rseq area (in the TCB, right above it) on every + // return to user space, and a protected page there kills the thread. let (tls_lo, tls_hi) = if fs != 0 && tls_size != 0 { - (fs - tls_size, fs) + (fs - tls_size, fs & !(PAGE - 1)) } else { (fs, fs) }; @@ -427,6 +433,7 @@ unsafe fn start(out: String) { starts: map_zeroed::(((res_hi - res_lo) / 8).div_ceil(64).max(1)), realm_hosts: Vec::new(), excluded: 0, + untracked_slots: 0, top_mode: std::env::var("PERRY_SNAP_MODE").is_ok_and(|v| v == "top"), pre_bits: map_zeroed::(((res_hi - res_lo) / 8).div_ceil(64).max(1)), pre_upto: 0, @@ -494,6 +501,9 @@ unsafe fn start(out: String) { let mut slots = Vec::new(); crate::gc::for_each_global_root_slot(|slot| slots.push(slot)); for slot in slots { + if page_flags(st, slot).is_none() { + st.untracked_slots += 1; + } st.globals.insert(slot as u64); st.globals_log.push(slot); mark_tracked(slot, slot + 8); @@ -851,6 +861,9 @@ pub(super) fn region_mapped(base: usize, len: usize) { pub(super) fn global_root(slot: usize) { let st = state(); + if page_flags(st, slot).is_none() { + st.untracked_slots += 1; + } st.globals.insert(slot as u64); st.globals_log.push(slot); mark_tracked(slot, slot + 8); @@ -1724,6 +1737,7 @@ fn write_report(st: &mut State) { let _ = writeln!(f, "#digest_total\t{total:016x}\t{}", per.len()); } let _ = writeln!(f, "#excluded\t{}", st.excluded); + let _ = writeln!(f, "#untracked_slots\t{}\tof\t{}", st.untracked_slots, st.globals.len); for (what, pcs) in &st.realm_hosts { let pcs: Vec = pcs.iter().map(|p| format!("{p:x}")).collect(); let _ = writeln!(f, "#realm_host\t{what}\t{}", pcs.join(",")); From bcfe3b1f83769648635725ddac5674da880e933f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 30 Oct 2026 23:59:57 +0000 Subject: [PATCH 29/84] WIP snap census: whole TLS pages only --- crates/perry-runtime/src/snap_census/runner.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 2c8568bc00c..e49836e8160 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -412,7 +412,8 @@ unsafe fn start(out: String) { // writes the thread's rseq area (in the TCB, right above it) on every // return to user space, and a protected page there kills the thread. let (tls_lo, tls_hi) = if fs != 0 && tls_size != 0 { - (fs - tls_size, fs & !(PAGE - 1)) + // Whole pages only: the page below the block holds libc static TLS. + ((fs - tls_size + PAGE - 1) & !(PAGE - 1), fs & !(PAGE - 1)) } else { (fs, fs) }; From 4a5e7d3a8a8070a393b37cf6a73bb3a9d6bc1522 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 31 Oct 2026 00:35:19 +0000 Subject: [PATCH 30/84] WIP snap census: main thread only --- crates/perry-runtime/src/snap_census/runner.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index e49836e8160..286895100b3 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -335,6 +335,9 @@ struct State { rset_word: usize, /// Handler depth: a fault inside the handler is reported, not hidden. in_handler: u32, + /// The thread that runs module evaluation; the runner tracks only its + /// memory (a worker agent has its own heap regions). + main_tid: i64, realm: (usize, usize), d_mode: bool, perf_fd: i32, @@ -451,6 +454,7 @@ unsafe fn start(out: String) { pending: [0; 8], rset_word: 0, in_handler: 0, + main_tid: libc::syscall(libc::SYS_gettid), realm, d_mode: false, perf_fd: open_instruction_counter(), @@ -853,6 +857,9 @@ pub(super) fn birth(addr: usize, size: usize) { pub(super) fn region_mapped(base: usize, len: usize) { let st = state(); + if unsafe { libc::syscall(libc::SYS_gettid) } != st.main_tid { + return; + } st.regions.push((base, base + len)); mark_tracked(base, base + len); if st.d_mode { @@ -1158,6 +1165,13 @@ extern "C" fn on_segv(sig: libc::c_int, info: *mut libc::siginfo_t, ctx: *mut li return chain(std::ptr::addr_of!(PREV_SEGV.0).cast(), sig); } let st = &mut *st_ptr; + let tid = libc::syscall(libc::SYS_gettid); + if tid != st.main_tid { + let uc = ctx as *mut libc::ucontext_t; + let pc = (*uc).uc_mcontext.gregs[libc::REG_RIP as usize] as u64; + diag(&[0x7d, tid as u64, addr as u64, pc]); + return chain(std::ptr::addr_of!(PREV_SEGV.0).cast(), sig); + } 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; From e9e6b5008a2f412ee0d779ea69cc0ca020979841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 31 Oct 2026 01:12:00 +0000 Subject: [PATCH 31/84] WIP snap census: step other threads through --- .../perry-runtime/src/snap_census/runner.rs | 45 +++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 286895100b3..54bcb5439a0 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -338,6 +338,10 @@ struct State { /// The thread that runs module evaluation; the runner tracks only its /// memory (a worker agent has its own heap regions). main_tid: i64, + /// Single steps of other threads: (tid, page). Those accesses belong + /// to no init; they are stepped through and counted. + foreign: [(i64, usize); 64], + foreign_faults: u64, realm: (usize, usize), d_mode: bool, perf_fd: i32, @@ -455,6 +459,8 @@ unsafe fn start(out: String) { rset_word: 0, in_handler: 0, main_tid: libc::syscall(libc::SYS_gettid), + foreign: [(0, 0); 64], + foreign_faults: 0, realm, d_mode: false, perf_fd: open_instruction_counter(), @@ -1167,10 +1173,21 @@ extern "C" fn on_segv(sig: libc::c_int, info: *mut libc::siginfo_t, ctx: *mut li let st = &mut *st_ptr; let tid = libc::syscall(libc::SYS_gettid); if tid != st.main_tid { + let page = addr & !(PAGE - 1); + let tracked = page_flags(st, page).is_some_and(|f| *f & PF_TRACKED != 0); + let free = st.foreign.iter().position(|e| e.0 == 0); + if !tracked || free.is_none() { + let uc = ctx as *mut libc::ucontext_t; + let pc = (*uc).uc_mcontext.gregs[libc::REG_RIP as usize] as u64; + diag(&[0x7d, tid as u64, addr as u64, pc]); + return chain(std::ptr::addr_of!(PREV_SEGV.0).cast(), sig); + } + st.foreign_faults += 1; + st.foreign[free.unwrap()] = (tid, page); + libc::mprotect(page as *mut _, PAGE, libc::PROT_READ | libc::PROT_WRITE); let uc = ctx as *mut libc::ucontext_t; - let pc = (*uc).uc_mcontext.gregs[libc::REG_RIP as usize] as u64; - diag(&[0x7d, tid as u64, addr as u64, pc]); - return chain(std::ptr::addr_of!(PREV_SEGV.0).cast(), sig); + (*uc).uc_mcontext.gregs[libc::REG_EFL as usize] |= EFLAGS_TF; + return; } if st.in_handler > 0 { let uc = ctx as *mut libc::ucontext_t; @@ -1241,6 +1258,27 @@ unsafe fn on_segv_inner( extern "C" fn on_trap(sig: libc::c_int, _info: *mut libc::siginfo_t, ctx: *mut libc::c_void) { unsafe { let st_ptr = ANCHOR.state.load(Relaxed); + if !st_ptr.is_null() { + let st = &mut *st_ptr; + let tid = libc::syscall(libc::SYS_gettid); + if tid != st.main_tid { + let mut mine = false; + for k in 0..st.foreign.len() { + if st.foreign[k].0 == tid { + let page = st.foreign[k].1; + st.foreign[k] = (0, 0); + libc::mprotect(page as *mut _, PAGE, policy(st, page)); + mine = true; + } + } + if mine { + let uc = ctx as *mut libc::ucontext_t; + (*uc).uc_mcontext.gregs[libc::REG_EFL as usize] &= !EFLAGS_TF; + return; + } + return chain(std::ptr::addr_of!(PREV_TRAP.0).cast(), sig); + } + } if st_ptr.is_null() || (*st_ptr).pending[0] == 0 { return chain(std::ptr::addr_of!(PREV_TRAP.0).cast(), sig); } @@ -1752,6 +1790,7 @@ fn write_report(st: &mut State) { let _ = writeln!(f, "#digest_total\t{total:016x}\t{}", per.len()); } let _ = writeln!(f, "#excluded\t{}", st.excluded); + let _ = writeln!(f, "#foreign_thread_faults\t{}", st.foreign_faults); let _ = writeln!(f, "#untracked_slots\t{}\tof\t{}", st.untracked_slots, st.globals.len); for (what, pcs) in &st.realm_hosts { let pcs: Vec = pcs.iter().map(|p| format!("{p:x}")).collect(); From 1e31a04eae7f3fec29c1dff14cc3dfff10be30fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 31 Oct 2026 01:47:15 +0000 Subject: [PATCH 32/84] WIP snap census: free space, realm cells, noprotect --- .../perry-runtime/src/snap_census/runner.rs | 128 +++++++++++++++++- 1 file changed, 124 insertions(+), 4 deletions(-) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 54bcb5439a0..9279e84b21e 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -145,6 +145,59 @@ impl WordSet { } } +/// Open-addressing map from a birth's start to its size. +struct SizeMap { + keys: *mut u64, + vals: *mut u32, + mask: usize, + len: usize, +} + +impl SizeMap { + unsafe fn new(log2: u32) -> Self { + let n = 1usize << log2; + SizeMap { + keys: map_zeroed::(n), + vals: map_zeroed::(n), + mask: n - 1, + len: 0, + } + } + fn insert(&mut self, k: u64, v: u32) { + assert!(self.len * 2 < self.mask, "snap census: size map full"); + let mut i = WordSet::hash(k) & self.mask; + unsafe { + loop { + let s = *self.keys.add(i); + if s == k || s == 0 { + if s == 0 { + self.len += 1; + } + *self.keys.add(i) = k; + *self.vals.add(i) = v; + return; + } + i = (i + 1) & self.mask; + } + } + } + fn get(&self, k: u64) -> Option { + let mut i = WordSet::hash(k) & self.mask; + unsafe { + loop { + let s = *self.keys.add(i); + if s == k { + return Some(*self.vals.add(i)); + } + if s == 0 { + return None; + } + i = (i + 1) & self.mask; + } + } + } +} + /// Append-only log of fixed records. struct Log { p: *mut T, @@ -306,6 +359,12 @@ struct State { /// populating itself on first use. (what, call chain), first per what. realm_hosts: Vec<(&'static str, [usize; 6])>, excluded: u64, + /// Size of every birth and realm image cell, by start. + sizes: SizeMap, + /// One bit per 8-byte word of the realm section: an image cell starts. + realm_starts: *mut u64, + free_space: u64, + noprotect: bool, /// Global root slots on no tracked page (the thread pointer's page, /// or not a binding: a root slot elsewhere): not watched. untracked_slots: u64, @@ -441,6 +500,10 @@ unsafe fn start(out: String) { starts: map_zeroed::(((res_hi - res_lo) / 8).div_ceil(64).max(1)), realm_hosts: Vec::new(), excluded: 0, + sizes: SizeMap::new(26), + realm_starts: map_zeroed::(((realm.1 - realm.0) / 8).div_ceil(64).max(1)), + free_space: 0, + noprotect: std::env::var("PERRY_SNAP_NOPROTECT").is_ok_and(|v| v == "1"), untracked_slots: 0, top_mode: std::env::var("PERRY_SNAP_MODE").is_ok_and(|v| v == "top"), pre_bits: map_zeroed::(((res_hi - res_lo) / 8).div_ceil(64).max(1)), @@ -498,6 +561,7 @@ unsafe fn start(out: String) { crate::arena::arena_walk_objects_with_block_index(|header, _| { let size = (*(header as *const crate::gc::GcHeader)).size as usize; set_start(st, header as usize); + st.sizes.insert(header as u64, size as u32); st.births.push(Birth { addr: header as usize, size: size as u32, @@ -519,11 +583,51 @@ unsafe fn start(out: String) { st.globals_log.push(slot); mark_tracked(slot, slot + 8); } + walk_realm_cells(st); install_handlers(); ANCHOR.on.store(true, Relaxed); crate::arena::refresh_inline_allocation_limit(); } +/// Record the realm section's image cells (header + size, back to back, +/// zero padding between them skipped). +unsafe fn walk_realm_cells(st: &mut State) { + let (lo, hi) = st.realm; + let mut p = (lo + 7) & !7; + while p + 8 <= hi { + let g = &*(p as *const crate::gc::GcHeader); + let size = g.size as usize; + if size >= 8 && size % 8 == 0 && p + size <= hi && g.obj_type != 0 { + let i = (p - lo) / 8; + *st.realm_starts.add(i / 64) |= 1 << (i % 64); + st.sizes.insert(p as u64, size as u32); + p += size; + } else { + p += 8; + } + } +} + +/// The realm image cell that holds `word`. +fn realm_cell(st: &State, word: usize) -> Option { + let (lo, hi) = st.realm; + if word < lo || word >= hi { + return None; + } + let mut i = (word - lo) / 8; + loop { + let w = unsafe { *st.realm_starts.add(i / 64) } & (u64::MAX >> (63 - (i % 64))); + if w != 0 { + let bit = 63 - w.leading_zeros() as usize; + return Some(lo + ((i / 64) * 64 + bit) * 8); + } + if i < 64 { + return None; + } + i = i - (i % 64) - 1; + } +} + fn blank_init(addr: usize, parent: u32, reach: Reach) -> Init { Init { addr, @@ -655,7 +759,7 @@ fn set_mode_for_top(st: &mut State) { // A syscall trace (strace) attributes what follows to this init. unsafe { libc::syscall(libc::SYS_lseek, -1 as libc::c_int, top as libc::c_long, 0x5a) }; } - let want = top != 0 && st.inits[top].in_d; + let want = top != 0 && st.inits[top].in_d && !st.noprotect; if want != st.d_mode { st.d_mode = want; unsafe { apply_protection(st) }; @@ -850,6 +954,7 @@ fn object_start(st: &State, word: usize) -> Option { pub(super) fn birth(addr: usize, size: usize) { let st = state(); set_start(st, addr); + st.sizes.insert(addr as u64, size as u32); let top = st.frames.last().map_or(0, |f| f.init); st.births.push(Birth { addr, @@ -868,7 +973,7 @@ pub(super) fn region_mapped(base: usize, len: usize) { } st.regions.push((base, base + len)); mark_tracked(base, base + len); - if st.d_mode { + if st.d_mode && !st.noprotect { unsafe { libc::mprotect(base as *mut _, len, libc::PROT_NONE) }; } } @@ -1326,8 +1431,22 @@ fn note_access(st: &mut State, word: usize, write: bool, pc: usize) { // changes after its birth (its cached hash is no observation), and a // shared canonical key list is an interned trie node. Reaching one // through a location a member of D wrote is caught at that location. - if st.heap.slot(word).is_some() { - if let Some(h) = object_start(st, word) { + let cell = if st.heap.slot(word).is_some() { + object_start(st, word) + } else { + realm_cell(st, word) + }; + if st.heap.slot(word).is_some() || cell.is_some() { + // A word outside every born object is free space the allocator + // touched (block setup), not a location. + let inside = cell.is_some_and(|h| { + st.sizes.get(h as u64).is_some_and(|s| word < h + s as usize) + }); + if !inside { + st.free_space += 1; + return; + } + if let Some(h) = cell { let hp = h & !(PAGE - 1); let fp = word & !(PAGE - 1); let (ty, flags) = unsafe { @@ -1790,6 +1909,7 @@ fn write_report(st: &mut State) { let _ = writeln!(f, "#digest_total\t{total:016x}\t{}", per.len()); } let _ = writeln!(f, "#excluded\t{}", st.excluded); + let _ = writeln!(f, "#free_space\t{}", st.free_space); let _ = writeln!(f, "#foreign_thread_faults\t{}", st.foreign_faults); let _ = writeln!(f, "#untracked_slots\t{}\tof\t{}", st.untracked_slots, st.globals.len); for (what, pcs) in &st.realm_hosts { From f0c34b913c8483a7e9626f5a8ff501ced1fabf7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 31 Oct 2026 03:06:16 +0000 Subject: [PATCH 33/84] WIP snap census: births at free-list reuse, owner kinds --- crates/perry-runtime/src/arena/allocators.rs | 1 + crates/perry-runtime/src/gc/old_free.rs | 12 ++++- .../perry-runtime/src/snap_census/runner.rs | 51 ++++++++++++++++++- 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/crates/perry-runtime/src/arena/allocators.rs b/crates/perry-runtime/src/arena/allocators.rs index 9cdffd3841b..e6956e4b4e7 100644 --- a/crates/perry-runtime/src/arena/allocators.rs +++ b/crates/perry-runtime/src/arena/allocators.rs @@ -678,6 +678,7 @@ fn arena_alloc_gc_slow(size: usize, align: usize, obj_type: u8, total: usize) -> }; if let Some(user_ptr) = reused { + crate::snap_census::birth(unsafe { user_ptr.sub(GC_HEADER_SIZE) }, total); ARENA.with(|a| unsafe { (&mut *a.get()).note_allocation(total, total >= 16 * 1024) }); // Reusing a free-list slot: the GcHeader is already in place (before user_ptr) // Just update it diff --git a/crates/perry-runtime/src/gc/old_free.rs b/crates/perry-runtime/src/gc/old_free.rs index 485b9340422..2212d345b42 100644 --- a/crates/perry-runtime/src/gc/old_free.rs +++ b/crates/perry-runtime/src/gc/old_free.rs @@ -330,11 +330,19 @@ pub(crate) fn old_free_take_exact( total_size: usize, excluded_pages: Option<&crate::fast_hash::PtrHashSet>, ) -> Option { - old_free_take_exact_class(total_size, excluded_pages, false) + let user = old_free_take_exact_class(total_size, excluded_pages, false); + if let Some(user) = user { + crate::snap_census::birth((user - crate::gc::GC_HEADER_SIZE) as *mut u8, total_size); + } + user } pub(crate) fn old_free_take_exact_collecting_holder(total_size: usize) -> Option { - old_free_take_exact_class(total_size, None, true) + let user = old_free_take_exact_class(total_size, None, true); + if let Some(user) = user { + crate::snap_census::birth((user - crate::gc::GC_HEADER_SIZE) as *mut u8, total_size); + } + user } fn old_free_take_exact_class( diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 9279e84b21e..c7148a841b3 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -89,6 +89,9 @@ unsafe fn map_zeroed(count: usize) -> *mut T { p.cast() } +/// A removed [`WordSet`] slot (no word is all ones). +const TOMBSTONE: u64 = u64::MAX; + /// Open-addressing set of nonzero words. struct WordSet { slots: *mut u64, @@ -128,6 +131,23 @@ impl WordSet { } } } + /// Drop `w` (a tombstone keeps later probes intact). + fn remove(&mut self, w: u64) { + let mut i = Self::hash(w) & self.mask; + unsafe { + loop { + let s = *self.slots.add(i); + if s == w { + *self.slots.add(i) = TOMBSTONE; + return; + } + if s == 0 { + return; + } + i = (i + 1) & self.mask; + } + } + } fn contains(&self, w: u64) -> bool { let mut i = Self::hash(w) & self.mask; unsafe { @@ -364,6 +384,7 @@ struct State { /// One bit per 8-byte word of the realm section: an image cell starts. realm_starts: *mut u64, free_space: u64, + reborn: u64, noprotect: bool, /// Global root slots on no tracked page (the thread pointer's page, /// or not a binding: a root slot elsewhere): not watched. @@ -503,6 +524,7 @@ unsafe fn start(out: String) { sizes: SizeMap::new(26), realm_starts: map_zeroed::(((realm.1 - realm.0) / 8).div_ceil(64).max(1)), free_space: 0, + reborn: 0, noprotect: std::env::var("PERRY_SNAP_NOPROTECT").is_ok_and(|v| v == "1"), untracked_slots: 0, top_mode: std::env::var("PERRY_SNAP_MODE").is_ok_and(|v| v == "top"), @@ -953,6 +975,23 @@ fn object_start(st: &State, word: usize) -> Option { pub(super) fn birth(addr: usize, size: usize) { let st = state(); + // 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. + let earlier = object_start(st, addr + size - 1) + .is_some_and(|h| st.sizes.get(h as u64).is_some_and(|s| addr < h + s as usize)); + if earlier { + st.reborn += 1; + for w in (addr..addr + size).step_by(8) { + st.w.remove(w as u64); + st.r.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 { + unsafe { *st.starts.add(i / 64) &= !(1 << (i % 64)) }; + } + } + } set_start(st, addr); st.sizes.insert(addr as u64, size as u32); let top = st.frames.last().map_or(0, |f| f.init); @@ -1791,13 +1830,20 @@ fn write_report(st: &mut State) { .iter() .map(|b| (b.addr, b.addr + b.size as usize, b.init)) .collect(); - v.sort_unstable(); + v.sort_by_key(|b| b.0); v }; let owner = |word: usize| -> String { let i = births.partition_point(|&(a, _, _)| a <= word); if i > 0 && word < births[i - 1].1 { - format!("birth:{}@{:x}", births[i - 1].2, births[i - 1].0) + let (a, e, init) = births[i - 1]; + let ty = unsafe { (*(a as *const crate::gc::GcHeader)).obj_type }; + format!( + "birth:{init}@{a:x}:{}:{}+{}", + crate::gc::gc_type_info(ty).map_or("?", |i| i.name), + e - a, + word - a + ) } else { "static".into() } @@ -1910,6 +1956,7 @@ fn write_report(st: &mut State) { } let _ = writeln!(f, "#excluded\t{}", st.excluded); let _ = writeln!(f, "#free_space\t{}", st.free_space); + let _ = writeln!(f, "#reborn\t{}", st.reborn); let _ = writeln!(f, "#foreign_thread_faults\t{}", st.foreign_faults); let _ = writeln!(f, "#untracked_slots\t{}\tof\t{}", st.untracked_slots, st.globals.len); for (what, pcs) in &st.realm_hosts { From 0da65305c428a356da4cb94b4387fe0cf9304bd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 31 Oct 2026 03:41:58 +0000 Subject: [PATCH 34/84] WIP snap census: silent stores are reads --- .../perry-runtime/src/snap_census/runner.rs | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index c7148a841b3..ed6389af806 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -385,6 +385,7 @@ struct State { realm_starts: *mut u64, free_space: u64, reborn: u64, + silent_stores: u64, noprotect: bool, /// Global root slots on no tracked page (the thread pointer's page, /// or not a binding: a root slot elsewhere): not watched. @@ -412,6 +413,9 @@ struct State { /// Pages opened for the current instruction (the handler's own state is /// never in TLS: a TLS page may be protected). pending: [usize; 8], + /// Stores the faulting instruction is about to make: (word, value + /// before, pc). Classified once it has run (see `on_trap`). + stores: [(usize, u64, usize); 4], rset_word: usize, /// Handler depth: a fault inside the handler is reported, not hidden. in_handler: u32, @@ -525,6 +529,7 @@ unsafe fn start(out: String) { realm_starts: map_zeroed::(((realm.1 - realm.0) / 8).div_ceil(64).max(1)), free_space: 0, reborn: 0, + silent_stores: 0, noprotect: std::env::var("PERRY_SNAP_NOPROTECT").is_ok_and(|v| v == "1"), untracked_slots: 0, top_mode: std::env::var("PERRY_SNAP_MODE").is_ok_and(|v| v == "top"), @@ -541,6 +546,7 @@ unsafe fn start(out: String) { bin: PageMap::new(bin_lo, bin_hi), tls: PageMap::new(tls_lo, tls_hi), pending: [0; 8], + stores: [(0, 0, 0); 4], rset_word: 0, in_handler: 0, main_tid: libc::syscall(libc::SYS_gettid), @@ -1394,7 +1400,11 @@ unsafe fn on_segv_inner( *slot = page; } libc::mprotect(page as *mut _, PAGE, libc::PROT_READ | libc::PROT_WRITE); - note_access(st, addr & !7, write, pc); + 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), + } gregs[libc::REG_EFL as usize] |= EFLAGS_TF; } } @@ -1442,6 +1452,16 @@ extern "C" fn on_trap(sig: libc::c_int, _info: *mut libc::siginfo_t, ctx: *mut l libc::mprotect(page as *mut _, PAGE, libc::PROT_READ); } } else { + // 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]); + 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; + } + note_access(st, word, changed, pc); + } for &page in pending.iter().take_while(|p| **p != 0) { libc::mprotect(page as *mut _, PAGE, policy(st, page)); } @@ -1957,6 +1977,7 @@ fn write_report(st: &mut State) { let _ = writeln!(f, "#excluded\t{}", st.excluded); let _ = writeln!(f, "#free_space\t{}", st.free_space); let _ = writeln!(f, "#reborn\t{}", st.reborn); + let _ = writeln!(f, "#silent_stores\t{}", st.silent_stores); let _ = writeln!(f, "#foreign_thread_faults\t{}", st.foreign_faults); let _ = writeln!(f, "#untracked_slots\t{}\tof\t{}", st.untracked_slots, st.globals.len); for (what, pcs) in &st.realm_hosts { From 351d15246acc6c387083dea8d49b08242e2147b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 31 Oct 2026 04:00:34 +0000 Subject: [PATCH 35/84] WIP snap census: transparent-init what-if knob --- .../perry-runtime/src/snap_census/runner.rs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index ed6389af806..5884c4cc18c 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -297,6 +297,7 @@ struct Init { parts: u8, threw: bool, seeded: bool, + transparent: bool, ir: u64, births: u64, birth_bytes: u64, @@ -365,6 +366,15 @@ struct State { seeded: Vec, rule4: bool, sabotage_env: bool, + /// `PERRY_SNAP_TRANSPARENT`: evaluation bodies whose objects and + /// globals are not locations (a what-if: the CJS loader's shared state + /// split per module). Their objects' overflow and meta cells follow. + transparent: Vec, + transp: WordSet, + transp_no: WordSet, + transp_globals: WordSet, + transp_objs: Vec<(usize, usize)>, + transparent_hits: u64, entry_body_seq: u32, require_depth: i32, bookkeeping: i32, @@ -516,6 +526,16 @@ unsafe fn start(out: String) { seeded, rule4: std::env::var("PERRY_SNAP_RULE4").is_ok_and(|v| v == "1"), sabotage_env: std::env::var("PERRY_SNAP_SABOTAGE").is_ok_and(|v| v == "env"), + transparent: std::env::var("PERRY_SNAP_TRANSPARENT") + .unwrap_or_default() + .split(',') + .filter_map(|a| usize::from_str_radix(a.trim().trim_start_matches("0x"), 16).ok()) + .collect(), + transp: WordSet::new(18), + transp_no: WordSet::new(20), + transp_globals: WordSet::new(14), + transp_objs: Vec::with_capacity(1 << 15), + transparent_hits: 0, entry_body_seq: 0, require_depth: 0, bookkeeping: 0, @@ -669,6 +689,7 @@ fn blank_init(addr: usize, parent: u32, reach: Reach) -> Init { parts: 0, threw: false, seeded: false, + transparent: false, ir: 0, births: 0, birth_bytes: 0, @@ -829,6 +850,7 @@ pub(super) fn init_enter(addr: usize) { init.from_entry_body = (parent.is_none() && st.entry_body_seq != 0) || (parent_id != 0 && st.inits[parent_id as usize].from_entry_body); init.seeded = st.seeded.contains(&addr); + init.transparent = st.transparent.contains(&addr); init.in_d = init.seeded; if st.top_mode && init.in_d { unsafe { extend_top(st) }; @@ -1001,6 +1023,10 @@ pub(super) fn birth(addr: usize, size: usize) { set_start(st, addr); st.sizes.insert(addr as u64, size as u32); let top = st.frames.last().map_or(0, |f| f.init); + if st.inits[top as usize].transparent { + st.transp.insert(addr as u64); + st.transp_objs.push((addr, addr + size)); + } st.births.push(Birth { addr, size: size as u32, @@ -1030,6 +1056,9 @@ pub(super) fn global_root(slot: usize) { } st.globals.insert(slot as u64); st.globals_log.push(slot); + if st.inits[st.frames.last().map_or(0, |f| f.init) as usize].transparent { + st.transp_globals.insert(slot as u64); + } mark_tracked(slot, slot + 8); // Rule 2 names every binding a member of D initializes, written or not. let top = st.frames.last().map_or(0, |f| f.init) as usize; @@ -1473,6 +1502,46 @@ extern "C" fn on_trap(sig: libc::c_int, _info: *mut libc::siginfo_t, ctx: *mut l } /// One access to a tracked word by the top frame. +/// A word on any page, whatever its protection. +unsafe fn peek(st: &State, word: usize) -> u64 { + let page = word & !(PAGE - 1); + let closed = policy(st, page) & libc::PROT_READ == 0; + if closed { + libc::mprotect(page as *mut _, PAGE, libc::PROT_READ); + } + let v = *(word as *const u64); + if closed { + libc::mprotect(page as *mut _, PAGE, policy(st, page)); + } + v +} + +/// `h` is born by a transparent init, or is the overflow table or meta cell +/// one of those objects points at (decided once per cell). +fn transparent_cell(st: &mut State, h: usize, ty: u8) -> bool { + if st.transp.contains(h as u64) { + return true; + } + if (ty != crate::gc::GC_TYPE_OVERFLOW && ty != crate::gc::GC_TYPE_OBJECT_META) + || st.transp_no.contains(h as u64) + { + return false; + } + let user = (h + crate::gc::GC_HEADER_SIZE) as u64; + let found = st.transp_objs.iter().any(|&(a, e)| { + (a..e).step_by(8).any(|w| { + let v = unsafe { peek(st, w) }; + v == user || v == h as u64 + }) + }); + if found { + st.transp.insert(h as u64); + } else { + st.transp_no.insert(h as u64); + } + found +} + fn note_access(st: &mut State, word: usize, write: bool, pc: usize) { if st.bookkeeping > 0 { st.faults_ignored += 1; @@ -1486,6 +1555,10 @@ fn note_access(st: &mut State, word: usize, write: bool, pc: usize) { st.faults_ignored += 1; return; } + if st.transp_globals.contains(word as u64) { + st.transparent_hits += 1; + return; + } // Immutable value-identity cells are not locations: a string never // changes after its birth (its cached hash is no observation), and a // shared canonical key list is an interned trie node. Reaching one @@ -1523,6 +1596,10 @@ fn note_access(st: &mut State, word: usize, write: bool, pc: usize) { st.excluded += 1; return; } + if !st.transparent.is_empty() && transparent_cell(st, h, ty) { + st.transparent_hits += 1; + return; + } } } let top = st.frames.last().map_or(0, |f| f.init) as usize; @@ -1978,6 +2055,7 @@ fn write_report(st: &mut State) { let _ = writeln!(f, "#free_space\t{}", st.free_space); let _ = writeln!(f, "#reborn\t{}", st.reborn); let _ = writeln!(f, "#silent_stores\t{}", st.silent_stores); + let _ = writeln!(f, "#transparent\t{}\t{}\t{}", st.transparent.len(), st.transp.len, st.transparent_hits); let _ = writeln!(f, "#foreign_thread_faults\t{}", st.foreign_faults); let _ = writeln!(f, "#untracked_slots\t{}\tof\t{}", st.untracked_slots, st.globals.len); for (what, pcs) in &st.realm_hosts { From 0853e13568dfc38711b9334c22d6ae49d3fb421a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 2 Nov 2026 04:36:35 +0000 Subject: [PATCH 36/84] WIP snap census: cascade attribution, dyn-code counts, attached cells, GC hold, lazy class mint as runtime work --- crates/perry-runtime/src/dyn_eval/mod.rs | 2 + crates/perry-runtime/src/gc/policy.rs | 6 + .../src/object/class_registry/state.rs | 15 +- .../perry-runtime/src/object/class_value.rs | 3 + crates/perry-runtime/src/object/overflow.rs | 1 + crates/perry-runtime/src/snap_census.rs | 36 ++++ .../perry-runtime/src/snap_census/runner.rs | 165 +++++++++++++++++- 7 files changed, 222 insertions(+), 6 deletions(-) diff --git a/crates/perry-runtime/src/dyn_eval/mod.rs b/crates/perry-runtime/src/dyn_eval/mod.rs index 31a196947b4..4b02af2f47d 100644 --- a/crates/perry-runtime/src/dyn_eval/mod.rs +++ b/crates/perry-runtime/src/dyn_eval/mod.rs @@ -456,6 +456,7 @@ pub(crate) fn function_from_strings_in_with_codegen( strings_allowed: bool, wasm_allowed: bool, ) -> f64 { + crate::snap_census::dyn_code(0); // Interpreted code reaches `process`/`console` by name. crate::object::js_install_global_value_surfaces(); let base = roots_len(); @@ -509,6 +510,7 @@ pub(crate) fn eval_script_in_with_codegen( strings_allowed: bool, wasm_allowed: bool, ) -> f64 { + crate::snap_census::dyn_code(1); // Interpreted code reaches `process`/`console` by name. crate::object::js_install_global_value_surfaces(); interp_entry(|| { diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index 9b210100e19..c346c2034d8 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -1749,6 +1749,12 @@ pub fn gc_suppress() { /// Resume GC triggers after suppression. pub fn gc_unsuppress() { + // The SNAP census holds collection off until its boundary (an address + // names one object for the whole run); a parse window's unsuppress must + // not end that hold. + if crate::snap_census::holds_collection() { + return; + } GC_FLAGS.with(|f| f.set(f.get() & !GC_FLAG_SUPPRESSED)); } diff --git a/crates/perry-runtime/src/object/class_registry/state.rs b/crates/perry-runtime/src/object/class_registry/state.rs index 64e50b4d082..b36d546ba5b 100644 --- a/crates/perry-runtime/src/object/class_registry/state.rs +++ b/crates/perry-runtime/src/object/class_registry/state.rs @@ -1127,7 +1127,17 @@ fn decl_prototype_class_id(ptr: usize) -> Option { // SAFETY: the header names an ordinary object at `ptr`; its first word // is the class id. let class_id = unsafe { (*(ptr as *const ObjectHeader)).class_id }; - (class_id != 0 + if class_id == 0 { + return None; + } + // The link of a class that is not `ptr`'s observes nothing: the compare + // fails whatever the link holds. Only a match reads it (the SNAP census + // counts that read as the init's; the miss is bookkeeping). + let link = { + let _census = crate::snap_census::bookkeeping(); + crate::object::class_value::class_decl_prototype_link(class_id) as usize + }; + (link == ptr && crate::object::class_value::class_decl_prototype_link(class_id) as usize == ptr) .then_some(class_id) } @@ -1710,6 +1720,9 @@ pub(crate) fn class_decl_prototype_value_selected(class_id: u32) -> f64 { return crate::value::js_nanbox_pointer(existing as i64); } + // Materializing the declared prototype is a first-use build of static + // declaration data: the SNAP census counts it as the runtime's. + let _census = crate::snap_census::runtime_scope(); // The [[Prototype]] it links to, resolved before the object exists (a // parent that cannot be one throws before anything is linked). let scope = crate::gc::RuntimeHandleScope::new(); diff --git a/crates/perry-runtime/src/object/class_value.rs b/crates/perry-runtime/src/object/class_value.rs index 587d44b54f0..809ed818ae2 100644 --- a/crates/perry-runtime/src/object/class_value.rs +++ b/crates/perry-runtime/src/object/class_value.rs @@ -377,6 +377,9 @@ pub(crate) fn class_value_is_first_evaluation(class_id: u32) -> bool { #[cold] #[inline(never)] fn class_value_mint(class_id: u32) -> *mut ClosureHeader { + // A class function object is a static cell built on first use, the same + // whichever init asks first: the SNAP census counts it as the runtime's. + let _census = crate::snap_census::runtime_scope(); // Any class id may own one: a native class whose prototype carries // registered members (fs.Stats' Date getters) keeps its prototype link // here like a compiled class. Its band, not its magnitude, places it. diff --git a/crates/perry-runtime/src/object/overflow.rs b/crates/perry-runtime/src/object/overflow.rs index b56174dba87..0bd2668de32 100644 --- a/crates/perry-runtime/src/object/overflow.rs +++ b/crates/perry-runtime/src/object/overflow.rs @@ -245,6 +245,7 @@ unsafe fn install(obj: *mut ObjectHeader, t: *mut Table) { ); (*obj).parent_class_id |= SHADOW_BIT; super::proto_validity::note_object_own_keys_changed(obj as usize); + crate::snap_census::attached(obj as usize, &[meta as usize, t as usize]); } /// Establish order authority once, copying keys and recording slot references. diff --git a/crates/perry-runtime/src/snap_census.rs b/crates/perry-runtime/src/snap_census.rs index c98200e1641..7d62e8c1020 100644 --- a/crates/perry-runtime/src/snap_census.rs +++ b/crates/perry-runtime/src/snap_census.rs @@ -85,6 +85,42 @@ pub fn host(class: Effect, what: &'static str) { let _ = (class, what); } +/// Dynamic code is made: `kind` 0 = a `new Function` body, 1 = an eval +/// script. Counted per init (design §2.6: these cannot be image cells +/// until build-time compilation); `PERRY_SNAP_DYN=defer` makes them defer +/// the init (option (a), host write by kind). +#[inline(always)] +pub(crate) fn dyn_code(kind: u8) { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + runner::dyn_code(kind); + } + #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] + let _ = kind; +} + +/// The census runner is on and has not reached its boundary: collection +/// stays suppressed (see `gc::gc_unsuppress`). +#[inline(always)] +pub(crate) fn holds_collection() -> bool { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + return runner::on(); + #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] + false +} + +/// `cells` (an object's metadata record, its overflow table) now belong to +/// `obj`: the cells of a transparent object are transparent too. +#[inline(always)] +pub(crate) fn attached(obj: usize, cells: &[usize]) { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + runner::attached(obj, cells); + } + #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] + let _ = (obj, cells); +} + /// A module evaluation (`run_path_initializer`) starts: `addr` is its /// evaluation body (a guarded module's `__init_evaluate`, a path /// module's initializer). diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 5884c4cc18c..23e01fc1d35 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -218,6 +218,64 @@ impl SizeMap { } } +/// Open-addressing map from a word to the member of D that recorded it +/// (`init << 32 | pc`, the pc truncated to 32 bits; pc 0 = a binding the +/// member initialized). The cascade attribution: which member's access a +/// later init conflicted with. +struct CauseMap { + keys: *mut u64, + vals: *mut u64, + mask: usize, + len: usize, +} + +impl CauseMap { + unsafe fn new(log2: u32) -> Self { + let n = 1usize << log2; + CauseMap { + keys: map_zeroed::(n), + vals: map_zeroed::(n), + mask: n - 1, + len: 0, + } + } + fn insert(&mut self, k: u64, init: u32, pc: usize) { + assert!(self.len * 2 < self.mask, "snap census: cause map full"); + let v = (init as u64) << 32 | (pc as u64 & 0xffff_ffff); + let mut i = WordSet::hash(k) & self.mask; + unsafe { + loop { + let s = *self.keys.add(i); + if s == k || s == 0 { + if s == 0 { + self.len += 1; + } + *self.keys.add(i) = k; + *self.vals.add(i) = v; + return; + } + i = (i + 1) & self.mask; + } + } + } + fn get(&self, k: u64) -> Option<(u32, usize)> { + let mut i = WordSet::hash(k) & self.mask; + unsafe { + loop { + let s = *self.keys.add(i); + if s == k { + let v = *self.vals.add(i); + return Some(((v >> 32) as u32, (v & 0xffff_ffff) as usize)); + } + if s == 0 { + return None; + } + i = (i + 1) & self.mask; + } + } + } +} + /// Append-only log of fixed records. struct Log { p: *mut T, @@ -280,6 +338,10 @@ struct Conflict { word: usize, pc: usize, write: bool, + /// The member of D whose recorded access this one conflicts with (its + /// last write of the word, or its first read), and that access's pc. + cause: u32, + cause_pc: usize, } #[derive(Clone, Copy)] @@ -306,6 +368,9 @@ struct Init { conflict: Option, in_d: bool, faults: u64, + /// `new Function` bodies and eval scripts made while this init ran. + dyn_fns: u32, + evals: u32, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -366,6 +431,8 @@ struct State { seeded: Vec, rule4: bool, sabotage_env: bool, + /// `PERRY_SNAP_DYN=defer`: dynamic code defers the init (design §2.6 (a)). + dyn_defer: bool, /// `PERRY_SNAP_TRANSPARENT`: evaluation bodies whose objects and /// globals are not locations (a what-if: the CJS loader's shared state /// split per module). Their objects' overflow and meta cells follow. @@ -380,6 +447,9 @@ struct State { bookkeeping: i32, w: WordSet, r: WordSet, + /// Who recorded each `w` / `r` word (the cascade attribution). + wby: CauseMap, + rby: CauseMap, /// Registered module-global root slots (the handler cannot borrow /// `GLOBAL_ROOTS`: the interrupted code may hold it). globals: WordSet, @@ -526,6 +596,7 @@ unsafe fn start(out: String) { seeded, rule4: std::env::var("PERRY_SNAP_RULE4").is_ok_and(|v| v == "1"), sabotage_env: std::env::var("PERRY_SNAP_SABOTAGE").is_ok_and(|v| v == "env"), + dyn_defer: std::env::var("PERRY_SNAP_DYN").is_ok_and(|v| v == "defer"), transparent: std::env::var("PERRY_SNAP_TRANSPARENT") .unwrap_or_default() .split(',') @@ -541,6 +612,8 @@ unsafe fn start(out: String) { bookkeeping: 0, w: WordSet::new(28), r: WordSet::new(28), + wby: CauseMap::new(28), + rby: CauseMap::new(28), globals: WordSet::new(22), starts: map_zeroed::(((res_hi - res_lo) / 8).div_ceil(64).max(1)), realm_hosts: Vec::new(), @@ -696,6 +769,8 @@ fn blank_init(addr: usize, parent: u32, reach: Reach) -> Init { host_count: 0, host: None, conflict: None, + dyn_fns: 0, + evals: 0, in_d: false, faults: 0, } @@ -928,6 +1003,42 @@ pub(super) fn entry_body() { } } +pub(super) fn attached(obj: usize, cells: &[usize]) { + let st = state(); + if st.transparent.is_empty() { + return; + } + let Some(h) = object_start(st, obj) else { return }; + if !st.transp.contains(h as u64) { + return; + } + for &c in cells { + if let Some(ch) = object_start(st, c) { + st.transp.insert(ch as u64); + st.transp_no.remove(ch as u64); + if let Some(s) = st.sizes.get(ch as u64) { + st.transp_objs.push((ch, ch + s as usize)); + } + } + } +} + +pub(super) fn dyn_code(kind: u8) { + let st = state(); + let top = st.frames.last().map_or(0, |f| f.init) as usize; + if top == 0 { + return; + } + if kind == 0 { + st.inits[top].dyn_fns += 1; + } else { + st.inits[top].evals += 1; + } + if st.dyn_defer { + host(Effect::HostWrite, if kind == 0 { "new_function" } else { "eval" }); + } +} + pub(super) fn host(class: Effect, what: &'static str) { let st = state(); if st.sabotage_env && what == "env" { @@ -1064,6 +1175,7 @@ pub(super) fn global_root(slot: usize) { let top = st.frames.last().map_or(0, |f| f.init) as usize; if top != 0 && st.inits[top].in_d { st.w.insert(slot as u64); + st.wby.insert(slot as u64, top as u32, 0); flag_page(st, slot & !(PAGE - 1), PF_W); } let page = slot & !(PAGE - 1); @@ -1616,6 +1728,8 @@ fn note_access(st: &mut State, word: usize, write: bool, pc: usize) { word, pc, write, + cause: 0, + cause_pc: 0, }); } i.in_d = true; @@ -1625,11 +1739,36 @@ fn note_access(st: &mut State, word: usize, write: bool, pc: usize) { return; } 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 + // tree for an init the seed put in D from its start. + if top != 0 && st.inits[top].conflict.is_none() { + let hit = if st.w.contains(w) { + st.wby.get(w).map(|c| (if write { 3 } else { 2 }, c)) + } else if write && st.r.contains(w) { + st.rby.get(w).map(|c| (3, c)) + } else { + None + }; + if let Some((rule, (cause, cause_pc))) = hit.filter(|h| h.1 .0 != top as u32) { + st.inits[top].conflict = Some(Conflict { + rule, + word, + pc, + write, + cause, + cause_pc, + }); + } + } if write { st.w.insert(w); + st.wby.insert(w, top as u32, pc); flag_page(st, word & !(PAGE - 1), PF_W); } else if !st.w.contains(w) { - st.r.insert(w); + if st.r.insert(w) { + st.rby.insert(w, top as u32, pc); + } flag_page(st, word & !(PAGE - 1), PF_R); } return; @@ -1651,6 +1790,13 @@ fn note_access(st: &mut State, word: usize, write: bool, pc: usize) { None }; if let Some(rule) = rule { + let (cause, cause_pc) = match rule { + 2 => st.wby.get(w), + 3 if st.w.contains(w) => st.wby.get(w), + 3 => st.rby.get(w), + _ => None, + } + .unwrap_or((0, 0)); let i = &mut st.inits[top]; if i.conflict.is_none() { i.conflict = Some(Conflict { @@ -1658,6 +1804,8 @@ fn note_access(st: &mut State, word: usize, write: bool, pc: usize) { word, pc, write, + cause, + cause_pc, }); } i.in_d = true; @@ -1666,9 +1814,12 @@ fn note_access(st: &mut State, word: usize, write: bool, pc: usize) { // This access is the member's first recorded one. if write { st.w.insert(w); + st.wby.insert(w, top as u32, pc); flag_page(st, word & !(PAGE - 1), PF_W); } else { - st.r.insert(w); + if st.r.insert(w) { + st.rby.insert(w, top as u32, pc); + } flag_page(st, word & !(PAGE - 1), PF_R); } } @@ -1918,7 +2069,7 @@ fn write_report(st: &mut State) { ); let _ = writeln!( f, - "#id\taddr\tparent\treach\tdynamic\tfrom_entry_body\tenter\texit\topen\tthrew\tseeded\tin_d\tir\tbirths\tbirth_bytes\tfaults\thost_count\thost_class\thost_what\thost_pcs\tconflict_rule\tconflict_word\tconflict_write\tconflict_pc\tconflict_owner" + "#id\taddr\tparent\treach\tdynamic\tfrom_entry_body\tenter\texit\topen\tthrew\tseeded\tin_d\tir\tbirths\tbirth_bytes\tfaults\thost_count\thost_class\thost_what\thost_pcs\tconflict_rule\tconflict_word\tconflict_write\tconflict_pc\tconflict_owner\tconflict_cause\tconflict_cause_pc\tdyn_fns\tevals" ); let births = { let mut v: Vec<(usize, usize, u32)> = st @@ -1958,19 +2109,21 @@ fn write_report(st: &mut State) { ), None => ("".into(), "".into(), "".into()), }; - let (cr, cw, cwr, cpc, co) = match i.conflict { + let (cr, cw, cwr, cpc, co, cc, ccpc) = match i.conflict { Some(c) => ( c.rule.to_string(), format!("{:x}", c.word), (c.write as u8).to_string(), format!("{:x}", c.pc), owner(c.word), + c.cause.to_string(), + format!("{:x}", c.cause_pc), ), None => Default::default(), }; let _ = writeln!( f, - "{id}\t{:x}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{hc}\t{hw}\t{hp}\t{cr}\t{cw}\t{cwr}\t{cpc}\t{co}", + "{id}\t{:x}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{hc}\t{hw}\t{hp}\t{cr}\t{cw}\t{cwr}\t{cpc}\t{co}\t{cc}\t{ccpc}\t{}\t{}", i.addr, i.parent, i.reach as u8, @@ -1987,6 +2140,8 @@ fn write_report(st: &mut State) { i.birth_bytes, i.faults, i.host_count, + i.dyn_fns, + i.evals, ); } // Step 6: a digest of the imaged heap per init. A birth is named by its From d1c74ade9f053d9c486f2928124680570dd96fcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 2 Nov 2026 07:01:26 +0000 Subject: [PATCH 37/84] merge fallout: take main's inline metadata reader (landed as 44ee7cab8) --- crates/perry-codegen/src/dialect/tests.rs | 51 ----------- crates/perry-codegen/src/dialect/text.rs | 105 ---------------------- 2 files changed, 156 deletions(-) diff --git a/crates/perry-codegen/src/dialect/tests.rs b/crates/perry-codegen/src/dialect/tests.rs index a1456936c0a..b9c7b98446a 100644 --- a/crates/perry-codegen/src/dialect/tests.rs +++ b/crates/perry-codegen/src/dialect/tests.rs @@ -1085,54 +1085,3 @@ fn birth_tables_and_frame_shape_tables_round_trip_through_the_reader() { ); assert!(roundtrip_ir(&worker, "frame_shapes_roundtrip") > 0); } - -/// The indexed-owner loop (`stmt/indexed_owner_loop.rs`) declares its alias -/// scopes inline: a `noalias.scope.decl` call whose operand is a nested -/// scope list, and `!alias.scope`/`!noalias` on its loads and stores. The -/// reader took only `!{!"NAME"}` operands and failed the whole unit -/// ("unsupported metadata operand"), oxfmt's yaml bundle in the Supabase -/// CLI. The domain string here carries `,`, `}` and an escaped quote. -#[test] -fn inline_alias_scope_metadata_round_trips_through_the_reader() { - let domain = r#"indexed.owner.f, {x}\22q.3"#; - let scope = |id: u32| format!("!{{!\"owner.{id}\", !{{!\"{domain}\"}}}}"); - let ir = format!( - r#" -declare void @llvm.experimental.noalias.scope.decl(metadata) - -define double @scoped(ptr %a, ptr %b) {{ -entry: - call void @llvm.experimental.noalias.scope.decl(metadata !{{{s1}}}) - call void @llvm.experimental.noalias.scope.decl(metadata !{{{s2}}}) - %v = load double, ptr %a, align 8, !alias.scope !{{{s1}}}, !noalias !{{{s2}}} - store double %v, ptr %b, align 8, !alias.scope !{{{s2}}}, !noalias !{{{s1}}} - ret double %v -}} -"#, - s1 = scope(1), - s2 = scope(2) - ); - let (skeleton, fns) = split_corpus(&ir); - let ctx = Context::create(); - let module = crate::inprocess::parse_ir_text(&ctx, &skeleton, "scopes").expect("skeleton"); - for f in &fns { - predeclare_function_from_text(&ctx, &module, f).unwrap_or_else(|e| panic!("{e:#}")); - } - for f in &fns { - add_function_from_text(&ctx, &module, f).unwrap_or_else(|e| panic!("{e:#}")); - } - module - .verify() - .unwrap_or_else(|e| panic!("verifier: {}", e.to_string())); - let text = module.print_to_string().to_string(); - assert_eq!( - text.matches("call void @llvm.experimental.noalias.scope.decl(metadata !") - .count(), - 2, - "{text}" - ); - assert_eq!(text.matches("!alias.scope").count(), 2, "{text}"); - assert_eq!(text.matches("!noalias").count(), 2, "{text}"); - assert!(text.contains(r#"!"owner.1""#), "{text}"); - assert!(text.contains(r#"indexed.owner.f, {x}\22q.3"#), "{text}"); -} diff --git a/crates/perry-codegen/src/dialect/text.rs b/crates/perry-codegen/src/dialect/text.rs index 9fce10d2739..aa84e4d8ffe 100644 --- a/crates/perry-codegen/src/dialect/text.rs +++ b/crates/perry-codegen/src/dialect/text.rs @@ -316,108 +316,3 @@ pub(super) fn apply_flags(inst: Option>, flags: &[&str]) { unsafe { llvm_sys::core::LLVMSetFastMathFlags(inst.as_value_ref(), fmf) }; } } - -/// Inline metadata as Perry writes it: `!"text"` (LLVM's `\XX` escapes) and -/// `!{a, b, ..}` tuples of those, nested (`!{!{!"owner.1", !{!"domain"}}}`, -/// an alias scope list). Quote-aware: a string may hold `,` or braces. -/// Returns the node and the bytes of `s` it spans. -pub(super) fn inline_metadata<'ctx>( - ctx: &'ctx Context, - s: &str, -) -> Result<(inkwell::values::MetadataValue<'ctx>, usize)> { - fn value<'ctx>( - ctx: &'ctx Context, - s: &[u8], - at: &mut usize, - ) -> Result> { - while *at < s.len() && s[*at].is_ascii_whitespace() { - *at += 1; - } - if s.get(*at) != Some(&b'!') { - bail!("metadata expected at byte {at}"); - } - *at += 1; - match s.get(*at) { - Some(b'"') => { - *at += 1; - let mut bytes = Vec::new(); - loop { - match s.get(*at) { - None => bail!("unterminated metadata string"), - Some(b'"') => break, - Some(b'\\') => { - let hex = s - .get(*at + 1..*at + 3) - .and_then(|h| std::str::from_utf8(h).ok()) - .and_then(|h| u8::from_str_radix(h, 16).ok()) - .ok_or_else(|| anyhow!("bad metadata string escape"))?; - bytes.push(hex); - *at += 3; - } - Some(&b) => { - bytes.push(b); - *at += 1; - } - } - } - *at += 1; - let text = String::from_utf8(bytes) - .map_err(|_| anyhow!("metadata string is not UTF-8"))?; - Ok(ctx.metadata_string(&text)) - } - Some(b'{') => { - *at += 1; - let mut items: Vec> = Vec::new(); - loop { - while *at < s.len() && s[*at].is_ascii_whitespace() { - *at += 1; - } - if s.get(*at) == Some(&b'}') { - *at += 1; - break; - } - items.push(value(ctx, s, at)?.into()); - while *at < s.len() && s[*at].is_ascii_whitespace() { - *at += 1; - } - match s.get(*at) { - Some(b',') => *at += 1, - Some(b'}') => {} - _ => bail!("bad metadata tuple"), - } - } - Ok(ctx.metadata_node(&items)) - } - _ => bail!("unsupported metadata form"), - } - } - let mut at = 0; - let node = value(ctx, s.as_bytes(), &mut at).with_context(|| format!("metadata `{s}`"))?; - Ok((node, at)) -} - -/// Attach the instruction metadata `kind` (`alias.scope`, `noalias`) an -/// instruction's text carries inline (`, !alias.scope !{..}`), as the -/// textual IR would. -pub(super) fn set_inline_attachment( - ctx: &Context, - inst: InstructionValue<'_>, - text: &str, - kind: &str, -) -> Result<()> { - use inkwell::context::AsContextRef; - let marker = format!(", !{kind} "); - let Some(pos) = text.find(&marker) else { - return Ok(()); - }; - let (node, _) = inline_metadata(ctx, &text[pos + marker.len()..])?; - let id = unsafe { - llvm_sys::core::LLVMGetMDKindIDInContext( - ctx.as_ctx_ref(), - kind.as_ptr() as *const std::os::raw::c_char, - kind.len() as u32, - ) - }; - inst.set_metadata(node, id) - .map_err(|e| anyhow!("attach !{kind}: {e}")) -} From e875bace724fa428c82f43509e5e7c0ad46d1a54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 2 Nov 2026 07:10:56 +0000 Subject: [PATCH 38/84] SNAP3 census: env reads as cells and one image guard, entry body in D, write containment, check run (instrumented only) --- .../perry-codegen/src/expr/array_methods.rs | 10 +- crates/perry-codegen/src/expr/dispatch.rs | 3 + crates/perry-codegen/src/expr/mod.rs | 1 + .../perry-codegen/src/expr/snap_env_site.rs | 96 +++ .../src/runtime_decls/strings.rs | 4 + crates/perry-codegen/src/stmt/mod.rs | 1 + crates/perry-runtime/src/module_require.rs | 3 + crates/perry-runtime/src/process/env_misc.rs | 36 +- crates/perry-runtime/src/snap_census.rs | 95 +++ .../perry-runtime/src/snap_census/runner.rs | 585 +++++++++++++++++- 10 files changed, 816 insertions(+), 18 deletions(-) create mode 100644 crates/perry-codegen/src/expr/snap_env_site.rs diff --git a/crates/perry-codegen/src/expr/array_methods.rs b/crates/perry-codegen/src/expr/array_methods.rs index 398b3e60a63..f330aacd0ce 100644 --- a/crates/perry-codegen/src/expr/array_methods.rs +++ b/crates/perry-codegen/src/expr/array_methods.rs @@ -436,7 +436,10 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // js_getenv_value returns `undefined` (nullish) for an unset // var, not a STRING_TAG'd null pointer — so `?? default` // applies and typeof/JSON.stringify agree (#1312). - Ok(blk.call(DOUBLE, "js_getenv_value", &[(I64, &key_handle)])) + super::snap_env_site::emit(ctx, expr); + Ok(ctx + .block() + .call(DOUBLE, "js_getenv_value", &[(I64, &key_handle)])) } Expr::EnvGetDynamic(name_expr) => { let key_box = lower_expr(ctx, name_expr)?; @@ -445,8 +448,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // `process.env[shortName]`); `js_getenv` dereferences it as // `*StringHeader`. #214 SSO bug class. let key_handle = unbox_str_handle(blk, &key_box); + super::snap_env_site::emit(ctx, expr); // `undefined` for unset vars — see EnvGet above (#1312). - Ok(blk.call(DOUBLE, "js_getenv_value", &[(I64, &key_handle)])) + Ok(ctx + .block() + .call(DOUBLE, "js_getenv_value", &[(I64, &key_handle)])) } Expr::ProcessEnv => { // `process.env` (or `globalThis.process.env`) as a value. diff --git a/crates/perry-codegen/src/expr/dispatch.rs b/crates/perry-codegen/src/expr/dispatch.rs index 7400b8d5444..3eb18cb9bbf 100644 --- a/crates/perry-codegen/src/expr/dispatch.rs +++ b/crates/perry-codegen/src/expr/dispatch.rs @@ -46,6 +46,9 @@ 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/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 8ea2e0fda56..bc292e2b444 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -94,6 +94,7 @@ pub(crate) fn note_site(kind: &str) { } } pub(crate) mod array_length; +pub(crate) mod snap_env_site; mod array_literal; pub(crate) mod array_proto_guard; mod bitset_test; diff --git a/crates/perry-codegen/src/expr/snap_env_site.rs b/crates/perry-codegen/src/expr/snap_env_site.rs new file mode 100644 index 00000000000..d497e4fcc78 --- /dev/null +++ b/crates/perry-codegen/src/expr/snap_env_site.rs @@ -0,0 +1,96 @@ +//! SNAP census: the consumer kind of a `process.env` read site +//! (`secret-tests/nightly/CW/SNAP_DESIGN_2026-10-30.md` §2.10.5), emitted in +//! instrumented compiles only (`PERRY_SNAP_CENSUS` set while compiling, the +//! switch `js_snap_entry_body` uses). An ordinary compile emits nothing. +//! +//! A read is a *store* site when its value is directly the stored value of a +//! module-global binding (`let`/`const` lowered to a module global, an +//! assignment to one, `GlobalSet`), a property or index set, or an element of +//! an array or object literal. Every other read is an *observe* site. Codegen +//! 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; + +use perry_hir::Expr; + +use crate::types::I32; + +use super::FnCtx; + +/// `js_snap_env_site` kinds (the runtime reads the same numbers). +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()) }; +} + +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(_)) +} + +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); + } + }); + } +} + +/// `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)), + 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)); + } + } +} + +/// Emit the site hook for the env read `expr`, right before its runtime call. +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 kind = if store { STORE } else { OBSERVE }; + ctx.block() + .call_void("js_snap_env_site", &[(I32, &kind.to_string())]); +} diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index 77c6c6d4c0f..31eb7d76cb5 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -946,6 +946,10 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { ); module.declare_function("js_getenv", I64, &[I64]); module.declare_function("js_getenv_value", DOUBLE, &[I64]); + // 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]); + } // #1344: process.env.X = v / delete process.env.X. module.declare_function("js_setenv", VOID, &[I64, DOUBLE]); module.declare_function("js_removeenv", VOID, &[I64]); diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index 5e452fe8bd2..22997772d57 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -615,6 +615,7 @@ 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()); lower_let(ctx, *id, name, init.as_ref(), ty, *mutable)?; // Object model step 1: a family prototype's birth. crate::layout_desc::create_families::lower_binding(ctx, *id)?; diff --git a/crates/perry-runtime/src/module_require.rs b/crates/perry-runtime/src/module_require.rs index 4d93b1f0800..e08f4eadc85 100644 --- a/crates/perry-runtime/src/module_require.rs +++ b/crates/perry-runtime/src/module_require.rs @@ -1234,6 +1234,9 @@ pub extern "C" fn js_register_path_module(path_value: f64, exports: f64) { #[no_mangle] pub unsafe extern "C" fn js_run_module_init_catching(init_addr: i64) { crate::snap_census::init_part(); + if crate::snap_census::skip_body() { + return; + } let init_fn: extern "C" fn() = std::mem::transmute::(init_addr as usize); let boundary = MODULE_PATH_REGISTRY.with(|r| r.begin_module_boundary()); let outcome = crate::exception::js_call_catching(|| { diff --git a/crates/perry-runtime/src/process/env_misc.rs b/crates/perry-runtime/src/process/env_misc.rs index c2592641bf7..9960178e313 100644 --- a/crates/perry-runtime/src/process/env_misc.rs +++ b/crates/perry-runtime/src/process/env_misc.rs @@ -851,7 +851,8 @@ pub extern "C" fn js_process_constrained_memory() -> f64 { /// Returns a string pointer, or null (0) if not found #[no_mangle] pub extern "C" fn js_getenv(name_ptr: *const StringHeader) -> *mut StringHeader { - crate::effect!(host_read, env); + // Reads the env: the census records a cell or a guard member + // (`snap_census::env_var`, design §2.10), not a host read. unsafe { let name = match env_name_from_header(name_ptr) { Some(name) => name, @@ -862,9 +863,14 @@ pub extern "C" fn js_getenv(name_ptr: *const StringHeader) -> *mut StringHeader Ok(value) => { // Create a JS string from the value let bytes = value.as_bytes(); - js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32) + let s = js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32); + crate::snap_census::env_var(&name, Some(&value), s as usize); + s + } + Err(_) => { + crate::snap_census::env_var(&name, None, 0); + std::ptr::null_mut() // Not found, return null } - Err(_) => std::ptr::null_mut(), // Not found, return null } } } @@ -885,7 +891,7 @@ pub extern "C" fn js_getenv(name_ptr: *const StringHeader) -> *mut StringHeader /// Node, so `??` won't clobber a legitimately empty value. #[no_mangle] pub extern "C" fn js_getenv_value(name_ptr: *const StringHeader) -> f64 { - crate::effect!(host_read, env); + // The env read is `js_getenv`'s (`snap_census::env_var`). let ptr = js_getenv(name_ptr); let val = if ptr.is_null() { JSValue::undefined() @@ -1168,6 +1174,19 @@ pub extern "C" fn js_removeenv(name_ptr: *const StringHeader) { /// it straight to subsequent PropertyGet dispatch. #[no_mangle] pub extern "C" fn js_process_env() -> f64 { + // SNAP census: the env object and the IPC probe are the runtime's own + // population (init 0); the program taking the object is + // `snap_census::env_object`, not a host read (design §2.10.3). + #[cfg(perry_gc_instruments)] + { + let boxed = { + let _census = crate::snap_census::runtime_scope(); + js_process_env_impl() + }; + crate::snap_census::env_object(boxed); + return boxed; + } + #[cfg(not(perry_gc_instruments))] js_process_env_impl() } @@ -1411,6 +1430,10 @@ pub(crate) fn process_env_has_field( return None; } let name = unsafe { env_name_from_header(key) }; + #[cfg(perry_gc_instruments)] + if let Some(name) = &name { + crate::snap_census::env_has(name, std::env::var_os(name).is_some()); + } Some(name.is_some_and(|name| std::env::var_os(name).is_some())) } @@ -1425,7 +1448,8 @@ fn env_name_is_settable(name: &str) -> bool { } fn js_process_env_impl() -> f64 { - crate::effect!(host_read, env); + // Not a host read for the census: `js_process_env` runs this as the + // runtime's population and records the object (`snap_census::env_object`). super::ipc_hooks::ensure_initialized(); let cached = CACHED_ENV.with(|c| c.get()); if cached != 0.0 { @@ -1436,6 +1460,7 @@ fn js_process_env_impl() -> f64 { // Pad alloc_limit so small env sets still have headroom; large // environments (CI runners) spill to the overflow Vec path. let alloc_limit = std::cmp::max(vars.len() as u32, crate::object::INLINE_SLOT_FLOOR as u32); + let census_mark = crate::snap_census::env_storage_begin(); let obj = crate::realm_decl! { birth crate::object::js_object_alloc(0, alloc_limit); realm { process.env, kind = exotic, members = { }; } }; for (k, v) in &vars { #[cfg(windows)] @@ -1450,6 +1475,7 @@ fn js_process_env_impl() -> f64 { let val_f64 = f64::from_bits(JSValue::string_ptr(val).bits()); crate::object::js_object_set_field_by_name(obj, key, val_f64); } + crate::snap_census::env_storage_end(census_mark, obj as usize); let boxed = f64::from_bits(JSValue::pointer(obj as *const u8).bits()); // The per-OBJECT half of `ANY_PROCESS_ENV_OBJECT`, for the same reason the // arguments registry sets it beside its insert. diff --git a/crates/perry-runtime/src/snap_census.rs b/crates/perry-runtime/src/snap_census.rs index 7d62e8c1020..b09694422eb 100644 --- a/crates/perry-runtime/src/snap_census.rs +++ b/crates/perry-runtime/src/snap_census.rs @@ -354,3 +354,98 @@ pub extern "C" fn js_snap_entry_body() { runner::entry_body(); } } + +// --------------------------------------------------------------------------- +// SNAP3: env reads as startup-filled cells and one image guard (design +// §2.10). The three env funnels call these instead of declaring +// `host_read env`; an init that reads the env through them keeps its image +// and the runner records a cell or a guard member. +// --------------------------------------------------------------------------- + +/// The env object (`process.env` as a value) was handed to the program: +/// `boxed` is its NaN-boxed bits. Taking it is not an observation; its words +/// in the image are `env_object` cells. +#[inline(always)] +pub(crate) fn env_object(boxed: f64) { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + runner::env_object(boxed.to_bits()); + } + #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] + let _ = boxed; +} + +/// The env object's storage is about to be populated (the birth-log mark for +/// [`env_storage_end`]). +#[inline(always)] +pub(crate) fn env_storage_begin() -> usize { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + return runner::births_len(); + } + 0 +} + +/// The env object `obj` was populated: what was born since `begin` (its key +/// and value strings, key lists, overflow) and the object's slots are the +/// env block's words. An init that reads or writes one enumerates or writes +/// the env (rule 1). +#[inline(always)] +pub(crate) fn env_storage_end(begin: usize, obj: usize) { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + runner::env_storage(begin, obj); + } + #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] + let _ = (begin, obj); +} + +/// One var read by name (`js_getenv`): `value` is its text and `ptr` the +/// string `js_getenv` returned (0 when the var is unset). +#[inline(always)] +pub(crate) fn env_var(name: &str, value: Option<&str>, ptr: usize) { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + runner::env_var(name, value, ptr); + } + #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] + let _ = (name, value, ptr); +} + +/// A presence test of one var (`"X" in env`, `hasOwnProperty`, `Reflect.has`). +#[inline(always)] +pub(crate) fn env_has(name: &str, present: bool) { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + runner::env_has(name, present); + } + #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] + let _ = (name, present); +} + +/// Codegen calls this right before a `process.env.X` / `process.env[k]` +/// read, in instrumented compiles only (`expr/snap_env_site.rs`): `kind` 1 +/// when the read's value is stored straight into a module global, a +/// property, an element or a literal; 0 otherwise. Kept for the frame's +/// next `js_getenv`. +#[no_mangle] +pub extern "C" fn js_snap_env_site(kind: i32) { + crate::effect!(pure); + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + runner::env_site(kind); + } + #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] + let _ = kind; +} + +/// The check run (`PERRY_SNAP_SKIP=`): true when the evaluation now +/// running is a member of D, whose body parts are skipped. +#[inline(always)] +pub(crate) fn skip_body() -> bool { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + return runner::skip_body(); + } + false +} diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 23e01fc1d35..1df5f743c87 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -371,6 +371,8 @@ struct Init { /// `new Function` bodies and eval scripts made while this init ran. dyn_fns: u32, evals: u32, + /// The check run skipped this member's body parts. + skipped: bool, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -399,6 +401,11 @@ const PF_R: u8 = 2; const PF_PRE: u8 = 4; const PF_LISTED: u8 = 8; const PF_TRACKED: u8 = 16; +/// The page holds watched env words (`env_words`): closed whatever runs. +const PF_ENV: u8 = 32; + +/// The evaluation address of the entry body's pseudo-init (design §2.10.7). +const ENTRY_BODY_ADDR: usize = 1; struct PageMap { lo: usize, @@ -523,8 +530,34 @@ struct State { live_by: std::collections::HashMap<(u32, u8), [u64; 2]>, live_unattributed: [u64; 2], boundary_done: bool, - /// Ground truth: every `getenv` by any code while an init runs. - env_reads: Vec<(u32, String)>, + /// Ground truth: every `getenv` by any code while the census runs + /// (init 0 included), with the value it found. + env_reads: Vec<(u32, String, Option)>, + /// SNAP3 (b): env reads are cells or guard members, not rule 1 + /// (`PERRY_SNAP_ENV=0`: rule 1 as before). + env_rule: bool, + /// SNAP3 (a): write containment for the `import()` subgraph + /// (`PERRY_SNAP_Q7=0`: every init of the subgraph defers). + q7: bool, + /// The kind `js_snap_env_site` left for the frame's next `js_getenv`. + env_site: Option<(u32, u8)>, + env_obj: u64, + env_obj_last: u32, + env_obs: Vec, + /// Watched words: the env block (`ENV_BLOCK`), store-site string bodies + /// (their `env_obs` index), poisoned slots (`POISON`). + env_words: SizeMap, + env_storage: Vec<(usize, usize)>, + /// The init that registered each `globals_log` slot. + slot_owner: Vec, + /// The check run (`PERRY_SNAP_SKIP=`): D's evaluation addresses + /// (sorted), whose bodies are skipped. + skip: Vec, + check_fail: Vec<(u32, usize, usize, bool)>, + /// The check run's digest leaves out the words the census pass's D wrote + /// (`PERRY_SNAP_WD`, by logical name). + wd_names: std::collections::HashSet<(u64, usize)>, + entry_init: u32, mark: bool, /// R5 (`PERRY_SNAP_RSET=1`): after the boundary, the imaged cells stay /// write-protected and their writes are counted until exit. @@ -594,7 +627,7 @@ unsafe fn start(out: String) { frames: Vec::with_capacity(256), seq: 0, seeded, - rule4: std::env::var("PERRY_SNAP_RULE4").is_ok_and(|v| v == "1"), + rule4: !std::env::var("PERRY_SNAP_Q7").is_ok_and(|v| v == "0"), sabotage_env: std::env::var("PERRY_SNAP_SABOTAGE").is_ok_and(|v| v == "env"), dyn_defer: std::env::var("PERRY_SNAP_DYN").is_ok_and(|v| v == "defer"), transparent: std::env::var("PERRY_SNAP_TRANSPARENT") @@ -661,6 +694,34 @@ unsafe fn start(out: String) { live_unattributed: [0; 2], boundary_done: false, env_reads: Vec::new(), + env_rule: !std::env::var("PERRY_SNAP_ENV").is_ok_and(|v| v == "0"), + q7: !std::env::var("PERRY_SNAP_Q7").is_ok_and(|v| v == "0"), + env_site: None, + env_obj: 0, + env_obj_last: 0, + env_obs: Vec::new(), + env_words: SizeMap::new(20), + env_storage: Vec::new(), + slot_owner: Vec::new(), + skip: { + let mut v = std::env::var("PERRY_SNAP_SKIP").map(|p| read_hex_lines(&p)).unwrap_or_default(); + v.sort_unstable(); + v + }, + check_fail: Vec::new(), + wd_names: std::env::var("PERRY_SNAP_WD") + .ok() + .and_then(|p| std::fs::read_to_string(p).ok()) + .map(|t| { + t.lines() + .filter_map(|l| { + let (n, o) = l.split_once('\t')?; + Some((u64::from_str_radix(n, 16).ok()?, o.parse().ok()?)) + }) + .collect() + }) + .unwrap_or_default(), + entry_init: 0, 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, @@ -702,9 +763,20 @@ unsafe fn start(out: String) { } st.globals.insert(slot as u64); st.globals_log.push(slot); + st.slot_owner.push(0); mark_tracked(slot, slot + 8); } walk_realm_cells(st); + // The check run: the global slots the census pass's D registered are + // poisoned (any access is a check failure). + if let Ok(p) = std::env::var("PERRY_SNAP_POISON") { + for slot in read_hex_lines(&p) { + if page_flags(st, slot).is_some() { + mark_tracked(slot, slot + 8); + watch_env_words(st, slot, slot + 8, POISON); + } + } + } install_handlers(); ANCHOR.on.store(true, Relaxed); crate::arena::refresh_inline_allocation_limit(); @@ -773,6 +845,7 @@ fn blank_init(addr: usize, parent: u32, reach: Reach) -> Init { evals: 0, in_d: false, faults: 0, + skipped: false, } } @@ -901,7 +974,11 @@ pub(super) fn init_enter(addr: usize) { let parent_in_body = match parent { None => st.entry_body_seq != 0, Some(f) if f.kind == FrameKind::Job => true, - Some(f) => st.inits[f.init as usize].parts >= 2 || st.inits[f.init as usize].addr == 0, + Some(f) => { + st.inits[f.init as usize].parts >= 2 + || st.inits[f.init as usize].addr == 0 + || st.inits[f.init as usize].addr == ENTRY_BODY_ADDR + } }; let reach = if st.require_depth > 0 { Reach::Require @@ -922,11 +999,21 @@ pub(super) fn init_enter(addr: usize) { init.open = true; let parent_dynamic = parent_id != 0 && st.inits[parent_id as usize].dynamic; init.dynamic = reach == Reach::DynImport || parent_dynamic; + if init.dynamic && !st.q7 { + // Without write containment an init of the `import()` subgraph has + // no proof of its own: it runs at the import, after the entry body. + init.in_d = true; + init.host = Some(Hit { + class: Effect::HostWrite as u8, + what: "dyn_import", + pcs: [0; 6], + }); + } init.from_entry_body = (parent.is_none() && st.entry_body_seq != 0) || (parent_id != 0 && st.inits[parent_id as usize].from_entry_body); init.seeded = st.seeded.contains(&addr); init.transparent = st.transparent.contains(&addr); - init.in_d = init.seeded; + init.in_d = init.seeded || init.in_d; if st.top_mode && init.in_d { unsafe { extend_top(st) }; } @@ -1000,6 +1087,28 @@ pub(super) fn entry_body() { if st.entry_body_seq == 0 { st.seq += 1; st.entry_body_seq = st.seq; + // The entry's own body runs at startup in every run: a pseudo-init in + // D (design §2.10.7), so what it writes and reads is D's, and the + // inits it reaches (its `import()`) are checked against it. + charge(st); + let id = st.inits.len() as u32; + let mut init = blank_init(ENTRY_BODY_ADDR, 0, Reach::EntryEager); + init.enter_seq = st.seq; + init.open = true; + init.in_d = true; + init.from_entry_body = true; + init.host = Some(Hit { + class: Effect::HostRead as u8, + what: "entry_body", + pcs: [0; 6], + }); + st.inits.push(init); + st.entry_init = id; + st.frames.push(Frame { + init: id, + kind: FrameKind::Init, + }); + set_mode_for_top(st); } } @@ -1167,6 +1276,7 @@ pub(super) fn global_root(slot: usize) { } st.globals.insert(slot as u64); st.globals_log.push(slot); + st.slot_owner.push(st.frames.last().map_or(0, |f| f.init)); if st.inits[st.frames.last().map_or(0, |f| f.init) as usize].transparent { st.transp_globals.insert(slot as u64); } @@ -1212,6 +1322,9 @@ fn policy(st: &State, page: usize) -> i32 { if f & PF_TRACKED == 0 { return libc::PROT_READ | libc::PROT_WRITE; } + if f & PF_ENV != 0 && !st.noprotect { + return libc::PROT_NONE; + } if st.top_mode { return if !st.d_mode && f & PF_PRE != 0 { libc::PROT_NONE @@ -1659,6 +1772,12 @@ fn note_access(st: &mut State, word: usize, write: bool, pc: usize) { st.faults_ignored += 1; return; } + if st.env_words.len > 0 { + if let Some(v) = st.env_words.get(word as u64) { + env_word_access(st, v, word, write, pc); + return; + } + } // Words of the binary range count only as global slots or realm cells. if (st.bin.slot(word).is_some() || st.tls.slot(word).is_some()) && !(word >= st.realm.0 && word < st.realm.1) @@ -1857,8 +1976,10 @@ pub unsafe extern "C" fn getenv(name: *const libc::c_char) -> *mut libc::c_char if on() { let st = state(); let top = st.frames.last().map_or(0, |f| f.init); - if top != 0 && st.env_reads.len() < 1 << 20 { - st.env_reads.push((top, String::from_utf8_lossy(key).into_owned())); + 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)); } } found @@ -2154,12 +2275,29 @@ fn write_report(st: &mut State) { let log = st.births.as_slice(); let mut rank: std::collections::HashMap = std::collections::HashMap::new(); let mut names: Vec<(usize, usize, u64)> = Vec::with_capacity(log.len()); + let mut ranks: Vec = Vec::with_capacity(log.len()); for b in log { let k = rank.entry(b.init).or_insert(0); let a = st.inits[b.init as usize].addr as u64; - names.push((b.addr, b.addr + b.size as usize, a.wrapping_mul(0x100000001B3) ^ *k)); + // The runtime's births (init 0) happen when an init first uses a + // runtime object, so their order depends on D: they are named by + // type and size only. + let name = if b.init == 0 { + let ty = unsafe { (*(b.addr as *const crate::gc::GcHeader)).obj_type } as u64; + 0x5254_0000_0000_0000 ^ (ty << 32) ^ b.size as u64 + } else { + a.wrapping_mul(0x100000001B3) ^ *k + }; + names.push((b.addr, b.addr + b.size as usize, name)); + ranks.push(*k); *k += 1; } + let birth_names: Vec = names.iter().map(|n| n.2).collect(); + let mut wd_out = if st.skip.is_empty() { + std::fs::File::create(format!("{}.wd", st.out)).ok() + } else { + None + }; names.sort_unstable(); let name_of = |v: usize| -> Option<(u64, usize)> { let i = names.partition_point(|&(a, _, _)| a <= v); @@ -2170,19 +2308,28 @@ fn write_report(st: &mut State) { *h ^= x; *h = h.wrapping_mul(0x100000001B3); }; - for b in log { + for (bk, b) in log.iter().enumerate() { if b.init != 0 && st.inits[b.init as usize].in_d { continue; } + let bname = birth_names[bk]; let e = per .entry(st.inits[b.init as usize].addr) .or_insert((0xcbf29ce484222325, 0, 0)); e.1 += 1; - let mut off = 0; + // The GC header (mark and move-request bits) is no JS state. + let mut off = crate::gc::GC_HEADER_SIZE; while off + 8 <= b.size as usize { let word = b.addr + off; off += 8; if st.w.contains(word as u64) { + if let Some(o) = wd_out.as_mut() { + use std::io::Write; + let _ = writeln!(o, "{bname:x}\t{}", off - 8); + } + continue; + } + if st.wd_names.contains(&(bname, off - 8)) { continue; } let v = unsafe { *(word as *const u64) }; @@ -2199,12 +2346,35 @@ fn write_report(st: &mut State) { mix(&mut e.0, v); } } + // The module-global slots each imaged init registered. + for (k, &slot) in st.globals_log.iter().enumerate() { + let owner = st.slot_owner.get(k).copied().unwrap_or(0); + if owner == 0 || st.inits[owner as usize].in_d || st.w.contains(slot as u64) { + continue; + } + let e = per + .entry(st.inits[owner as usize].addr) + .or_insert((0xcbf29ce484222325, 0, 0)); + let v = unsafe { *(slot as *const u64) }; + e.2 += 1; + let hi = v >> 48; + if hi == 0 || hi >= 0x7FF9 { + if let Some((n, o)) = name_of((v & 0xFFFF_FFFF_FFFF) as usize) { + mix(&mut e.0, hi ^ 0xA5A5); + mix(&mut e.0, n); + mix(&mut e.0, o as u64); + continue; + } + } + mix(&mut e.0, v); + } let mut total = 0xcbf29ce484222325u64; for (addr, (h, objs, words)) in &per { mix(&mut total, *h); let _ = writeln!(f, "#digest\t{addr:x}\t{h:016x}\t{objs}\t{words}"); } let _ = writeln!(f, "#digest_total\t{total:016x}\t{}", per.len()); + write_env_report(st, &mut f, &ranks); } let _ = writeln!(f, "#excluded\t{}", st.excluded); let _ = writeln!(f, "#free_space\t{}", st.free_space); @@ -2217,7 +2387,7 @@ fn write_report(st: &mut State) { let pcs: Vec = pcs.iter().map(|p| format!("{p:x}")).collect(); let _ = writeln!(f, "#realm_host\t{what}\t{}", pcs.join(",")); } - for (init, name) in &st.env_reads { + for (init, name, _) in &st.env_reads { let _ = writeln!(f, "#getenv\t{init}\t{name}"); } // The next pass's seed. @@ -2229,3 +2399,396 @@ fn write_report(st: &mut State) { } } } + +// --------------------------------------------------------------------------- +// SNAP3: env cells, the image guard (design §2.10.3-§2.10.4) +// --------------------------------------------------------------------------- + +/// What an env observation was (`EnvObs::kind`). +const OBS_OBJECT: u8 = 0; +/// A store-site read of a set var: a cell while no imaged init reads the +/// string's body. +const OBS_STORE: u8 = 1; +const OBS_OBSERVE: u8 = 2; +const OBS_HAS: u8 = 3; +/// A store-site read of an unset var (no pointer to find: a guard member). +const OBS_STORE_UNSET: u8 = 4; +/// A store-site string whose body an imaged init read. +const OBS_STORE_READ: u8 = 5; + +/// `env_words` value of an env block word (the env object's storage). +const ENV_BLOCK: u32 = u32::MAX; +/// `env_words` value of a module-global slot a member of D registered in the +/// census pass (the check run poisons it). +const POISON: u32 = u32::MAX - 1; + +struct EnvObs { + init: u32, + kind: u8, + name: String, + value: Option, + /// The string a store-site read returned (header address). + ptr: usize, + /// The imaged init that read a store-site string's body. + by: u32, +} + +fn top_init(st: &State) -> u32 { + st.frames.last().map_or(0, |f| f.init) +} + +/// Watch `[lo, hi)` as env words with value `v`: their pages stay closed +/// whatever runs. +fn watch_env_words(st: &mut State, lo: usize, hi: usize, v: u32) { + let mut w = lo & !7; + while w < hi { + st.env_words.insert(w as u64, v); + w += 8; + } + let mut p = lo & !(PAGE - 1); + while p < hi { + flag_page(st, p, PF_ENV); + if !st.noprotect { + unsafe { libc::mprotect(p as *mut _, PAGE, libc::PROT_NONE) }; + } + p += PAGE; + } +} + +pub(super) fn births_len() -> usize { + state().births.len +} + +pub(super) fn env_site(kind: i32) { + let st = state(); + st.env_site = Some((top_init(st), kind as u8)); +} + +pub(super) fn env_object(bits: u64) { + let st = state(); + if !st.env_rule { + return host(Effect::HostRead, "env"); + } + st.env_obj = bits; + let top = top_init(st); + if top != 0 && st.env_obj_last != top { + st.env_obj_last = top; + st.env_obs.push(EnvObs { + init: top, + kind: OBS_OBJECT, + name: String::new(), + value: None, + ptr: 0, + by: 0, + }); + } +} + +pub(super) fn env_storage(begin: usize, obj: usize) { + 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)..] + .iter() + .map(|b| (b.addr, b.addr + b.size as usize)) + .collect(); + for (a, e) in new { + st.env_storage.push((a, e)); + // The env object's header (class, shape, meta) is what dispatch and + // identity read; its slots are the env block. + let lo = if a == h { + obj + std::mem::size_of::() + } else { + a + crate::gc::GC_HEADER_SIZE + }; + watch_env_words(st, lo, e, ENV_BLOCK); + } +} + +pub(super) fn env_var(name: &str, value: Option<&str>, ptr: usize) { + let st = state(); + let top = top_init(st); + let site = st.env_site.take().filter(|s| s.0 == top).map(|s| s.1); + if !st.env_rule { + return host(Effect::HostRead, "env"); + } + let mut kind = match (site, ptr) { + (Some(1), 0) => OBS_STORE_UNSET, + (Some(1), _) => OBS_STORE, + _ => OBS_OBSERVE, + }; + let idx = st.env_obs.len() as u32; + if kind == OBS_STORE { + let h = ptr - crate::gc::GC_HEADER_SIZE; + match st.sizes.get(h as u64) { + Some(size) => watch_env_words(st, ptr, h + size as usize, idx), + // Not a logged birth: nothing to watch, so the value is observed. + None => kind = OBS_OBSERVE, + } + } + st.env_obs.push(EnvObs { + init: top, + kind, + name: name.to_owned(), + value: value.map(str::to_owned), + ptr, + by: 0, + }); +} + +pub(super) fn env_has(name: &str, present: bool) { + let st = state(); + if !st.env_rule { + return host(Effect::HostRead, "env"); + } + let top = top_init(st); + st.env_obs.push(EnvObs { + init: top, + kind: OBS_HAS, + name: name.to_owned(), + value: present.then(|| "".to_owned()), + ptr: 0, + by: 0, + }); +} + +/// An access to a watched env word (`note_access`, signal context: no +/// allocation). +fn env_word_access(st: &mut State, v: u32, word: usize, write: bool, pc: usize) { + let top = top_init(st); + if v == POISON { + if st.check_fail.len() < 4096 { + st.check_fail.push((top, word, pc, write)); + } + return; + } + if top == 0 || st.inits[top as usize].in_d { + return; + } + if v == ENV_BLOCK { + // Enumeration or a write of the env block: rule 1. + let i = &mut st.inits[top as usize]; + i.host_count += 1; + i.in_d = true; + if i.host.is_none_or(|h| h.class == Effect::TargetFact as u8) { + i.host = Some(Hit { + class: Effect::HostRead as u8, + what: if write { "env_write" } else { "env_enum" }, + pcs: [pc, 0, 0, 0, 0, 0], + }); + } + if st.top_mode { + unsafe { extend_top(st) }; + } + set_mode_for_top(st); + return; + } + if let Some(o) = st.env_obs.get_mut(v as usize) { + if o.kind == OBS_STORE { + o.kind = OBS_STORE_READ; + o.by = top; + } + } +} + +/// The check run: the evaluation now on top is a member of D. +pub(super) fn skip_body() -> bool { + let st = state(); + if st.skip.is_empty() { + return false; + } + match st.frames.last() { + Some(f) if f.kind == FrameKind::Init => { + let i = &mut st.inits[f.init as usize]; + if st.skip.binary_search(&i.addr).is_ok() { + i.skipped = true; + return true; + } + false + } + _ => false, + } +} + +fn read_hex_lines(path: &str) -> Vec { + std::fs::read_to_string(path) + .map(|t| { + t.lines() + .filter_map(|l| { + usize::from_str_radix(l.split('\t').next()?.trim().trim_start_matches("0x"), 16) + .ok() + }) + .collect() + }) + .unwrap_or_default() +} + +/// A logical name for a module-global slot: its symbol (or the image +/// offset) from `dladdr`, never its address. +fn slot_name(slot: usize) -> String { + unsafe { + let mut info: libc::Dl_info = std::mem::zeroed(); + if libc::dladdr(slot as *const _, &mut info) != 0 { + let base = info.dli_fbase as usize; + if !info.dli_sname.is_null() && !info.dli_saddr.is_null() { + let s = std::ffi::CStr::from_ptr(info.dli_sname).to_string_lossy(); + return format!("g:{s}+{}", slot - info.dli_saddr as usize); + } + return format!("g:+{:x}", slot - base); + } + } + format!("g:?{slot:x}") +} + +fn tsv_text(s: &str) -> String { + s.chars() + .map(|c| if c == '\t' || c == '\n' || c == '\r' { ' ' } else { c }) + .collect() +} + +/// The `#cell`, `#guard`, `#knob` lines (design §2.10.8) and the per-init +/// slot owners for the check run. +fn write_env_report(st: &State, f: &mut std::fs::File, rank: &[u64]) { + use std::io::Write; + let in_d = |i: u32| st.inits[i as usize].in_d; + let addr_of = |i: u32| st.inits[i as usize].addr; + // Cells: every image word that holds the env object or a store-site + // string. The image is the births of inits outside D and the global + // slots they registered; the env block itself and words a member of D + // wrote are not image words. + let mut targets: std::collections::HashMap> = Default::default(); + if st.env_obj != 0 { + targets.entry(st.env_obj & 0xFFFF_FFFF_FFFF).or_default().push(-1); + } + for (k, o) in st.env_obs.iter().enumerate() { + if o.kind == OBS_STORE && !in_d(o.init) { + targets.entry(o.ptr as u64 & 0xFFFF_FFFF_FFFF).or_default().push(k as i64); + } + } + let mut found = vec![0u32; st.env_obs.len()]; + let mut env_obj_cells = 0u32; + let mut cell = |what: &[i64], name: String, f: &mut std::fs::File| { + for &t in what { + if t < 0 { + env_obj_cells += 1; + let _ = writeln!(f, "#cell\tenv_object\t{name}\t\t"); + } else { + let o = &st.env_obs[t as usize]; + found[t as usize] += 1; + let _ = writeln!(f, "#cell\tenv_var\t{name}\t{}\t{}", o.name, o.init); + } + } + }; + let probe = |v: u64| -> Option<&Vec> { + let hi = v >> 48; + if hi == 0 || hi >= 0x7FF9 { + targets.get(&(v & 0xFFFF_FFFF_FFFF)) + } else { + None + } + }; + if !targets.is_empty() { + let mut storage = st.env_storage.clone(); + storage.sort_unstable(); + let log = st.births.as_slice(); + for (k, b) in log.iter().enumerate() { + if (b.init != 0 && in_d(b.init)) + || storage.binary_search_by_key(&b.addr, |r| r.0).is_ok() + { + continue; + } + let mut off = crate::gc::GC_HEADER_SIZE; + while off + 8 <= b.size as usize { + let word = b.addr + off; + if !st.w.contains(word as u64) { + let v = unsafe { *(word as *const u64) }; + if let Some(t) = probe(v) { + let t = t.clone(); + cell( + &t, + format!("b:{:x}#{}+{}", addr_of(b.init), rank[k], off), + f, + ); + } + } + off += 8; + } + } + for (k, &slot) in st.globals_log.iter().enumerate() { + let owner = st.slot_owner.get(k).copied().unwrap_or(0); + if (owner != 0 && in_d(owner)) || st.w.contains(slot as u64) { + continue; + } + let v = unsafe { *(slot as *const u64) }; + if let Some(t) = probe(v) { + let t = t.clone(); + cell(&t, slot_name(slot), f); + } + } + } + let _ = writeln!(f, "#env_object_cells\t{env_obj_cells}"); + // The guard: every other observation by an imaged init (the runtime's + // own, init 0, included: the IPC probe decides the process object). + let mut seen: std::collections::HashSet<(String, u32, &'static str)> = Default::default(); + let mut guard = |name: &str, value: Option<&str>, init: u32, consumer: &'static str, f: &mut std::fs::File| { + if init != 0 && in_d(init) { + return; + } + if seen.insert((name.to_owned(), init, consumer)) { + let _ = writeln!( + f, + "#guard\t{}\t{}\t{init}\t{:x}\t{consumer}", + tsv_text(name), + value.map_or("".to_owned(), tsv_text), + addr_of(init) + ); + } + }; + for (k, o) in st.env_obs.iter().enumerate() { + match o.kind { + OBS_OBSERVE => guard(&o.name, o.value.as_deref(), o.init, "observe", f), + OBS_HAS => guard(&o.name, o.value.as_deref(), o.init, "has", f), + OBS_STORE_UNSET => guard(&o.name, None, o.init, "store_unset", f), + OBS_STORE_READ => guard(&o.name, o.value.as_deref(), o.init, "store_read", f), + OBS_STORE if found[k] == 0 && !in_d(o.init) => { + let _ = writeln!(f, "#cell_unstored\t{}\t{}", o.name, o.init); + } + _ => {} + } + } + for (init, name, value) in &st.env_reads { + if name.starts_with("PERRY_") || name.starts_with("RUST_") { + let _ = writeln!(f, "#knob\t{name}\t{init}"); + continue; + } + let explained = st + .env_obs + .iter() + .any(|o| o.init == *init && o.kind != OBS_OBJECT && o.name == *name); + if !explained { + guard(name, value.as_deref(), *init, "libc", f); + } + } + let takers = st.env_obs.iter().filter(|o| o.kind == OBS_OBJECT).count(); + let _ = writeln!(f, "#env\trule={}\tq7={}\tobs={}\tobject_takers={takers}\tcheck={}", st.env_rule as u8, st.q7 as u8, st.env_obs.len(), !st.skip.is_empty() as u8); + for &(init, word, pc, write) in &st.check_fail { + let _ = writeln!(f, "#checkfail\tpoison\t{init}\t{:x}\t{}\t{pc:x}\t{}", addr_of(init), slot_name(word), write as u8); + } + if !st.skip.is_empty() { + for (id, i) in st.inits.iter().enumerate().skip(1) { + if i.in_d && !i.skipped && i.addr != ENTRY_BODY_ADDR { + let _ = writeln!(f, "#checkfail\tjoined_d\t{id}\t{:x}", i.addr); + } + } + } + // The next check run's poison: the global slots members of D registered. + if st.skip.is_empty() { + if let Ok(mut d) = std::fs::File::create(format!("{}.dslots", st.out)) { + for (k, &slot) in st.globals_log.iter().enumerate() { + let owner = st.slot_owner.get(k).copied().unwrap_or(0); + if owner != 0 && in_d(owner) { + let _ = writeln!(d, "{slot:x}\t{owner}"); + } + } + } + } +} From 75e577ef593086959e2ec7f0399844681b31e64e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 2 Nov 2026 07:16:43 +0000 Subject: [PATCH 39/84] merge fallout: drop the pre-landing copies of #13477 driver helpers (main has them) --- crates/perry-wpfacts/src/units/driver.rs | 159 ----------------------- 1 file changed, 159 deletions(-) diff --git a/crates/perry-wpfacts/src/units/driver.rs b/crates/perry-wpfacts/src/units/driver.rs index 9cc8002a7ff..197c35080ab 100644 --- a/crates/perry-wpfacts/src/units/driver.rs +++ b/crates/perry-wpfacts/src/units/driver.rs @@ -2289,162 +2289,3 @@ fn capture_diagnostics( _ => None, } } - -/// The rounds after which the driver names what keeps it going (the corpus -/// reaches its fixpoint in at most ~15). A round count that keeps growing is -/// a unit whose outputs never settle: each report at round 64, 128, 256, ... -/// names the units that round solved and what each changed. -const LONG_ROUNDS: u32 = 64; - -/// What a unit's solve changed in its outputs against its last solve: each -/// contract added (`+`), dropped (`-`) or changed, with the fields that -/// differ. -fn output_changes(u: &ProgramFacts, old: &ProgramFacts) -> Vec { - fn keyed( - out: &mut Vec, - what: &str, - new: &[(K, &C)], - old: &[(K, &C)], - ) { - for (k, c) in new { - match old.iter().find(|(ok, _)| ok == k) { - None => out.push(format!("+{what} {k}")), - Some((_, p)) if p != c => { - out.push(format!("{what} {k} [{}]", changed_fields(*p, *c).join(" "))) - } - Some(_) => {} - } - } - for (k, _) in old { - if !new.iter().any(|(nk, _)| nk == k) { - out.push(format!("-{what} {k}")); - } - } - } - fn own(v: &ProgramFacts) -> Vec<(String, &FnContract)> { - v.own_fns_out.iter().map(|c| (c.name.clone(), c)).collect() - } - fn named(v: &[((String, String), C)]) -> Vec<(String, &C)> { - v.iter().map(|(k, c)| (format!("{}:{}", k.0, k.1), c)).collect() - } - // A cell keeps the unit's last write joined with its latest - // (DECISIONS 197): what it holds after this solve is that join. - let joined: Vec<(String, ValueContract)> = old - .cells_out - .iter() - .map(|(k, c)| { - let mut j = c.clone(); - if let Some((_, n)) = u.cells_out.iter().find(|(q, _)| q == k) { - j.join(n); - } - (k.clone(), j) - }) - .chain( - u.cells_out - .iter() - .filter(|(k, _)| !old.cells_out.iter().any(|(q, _)| q == k)) - .cloned(), - ) - .collect(); - fn cells(v: &[(String, ValueContract)]) -> Vec<(String, &ValueContract)> { - v.iter().map(|(k, c)| (k.clone(), c)).collect() - } - let mut out = Vec::new(); - keyed(&mut out, "own_fn", &own(u), &own(old)); - keyed(&mut out, "fn", &named(&u.fn_contracts_out), &named(&old.fn_contracts_out)); - keyed(&mut out, "class", &named(&u.contracts_out), &named(&old.contracts_out)); - keyed(&mut out, "value", &named(&u.values_out), &named(&old.values_out)); - keyed(&mut out, "cell", &cells(&joined), &cells(&old.cells_out)); - // Every other output the driver publishes or feeds back to the units: - // a change in any of them re-seeds a reader, so each is compared (a - // forwarded layout replaced each round once read as "nothing"). - fn by_display(v: &[ClassContract]) -> Vec<(String, &ClassContract)> { - v.iter().map(|c| (c.display.clone(), c)).collect() - } - fn plain(v: &[(String, C)]) -> Vec<(String, &C)> { - v.iter().map(|(k, c)| (k.clone(), c)).collect() - } - fn stores(v: &[(String, String, ValueContract)]) -> Vec<(String, &ValueContract)> { - v.iter().map(|(d, k, c)| (format!("{d}.{k}"), c)).collect() - } - keyed(&mut out, "layout", &by_display(&u.layouts_out), &by_display(&old.layouts_out)); - keyed(&mut out, "args", &plain(&u.args_out), &plain(&old.args_out)); - keyed(&mut out, "ctor_args", &named(&u.ctor_args_out), &named(&old.ctor_args_out)); - keyed(&mut out, "store", &stores(&u.stores_out), &stores(&old.stores_out)); - keyed(&mut out, "write", &named(&u.writes_out), &named(&old.writes_out)); - keyed(&mut out, "this_args", &plain(&u.this_args_out), &plain(&old.this_args_out)); - keyed(&mut out, "method_args", &plain(&u.method_args_out), &plain(&old.method_args_out)); - keyed(&mut out, "ctor_runs", &plain(&u.ctor_runs_out), &plain(&old.ctor_runs_out)); - for (what, new, was) in [ - ("fn defined", &u.fns_defined, &old.fns_defined), - ("method run", &u.method_runs_out, &old.method_runs_out), - ("forwarded layout", &u.layouts_fwd, &old.layouts_fwd), - ("report", &u.reports, &old.reports), - ] { - for n in new.iter().filter(|n| !was.contains(n)) { - out.push(format!("+{what} {n}")); - } - for n in was.iter().filter(|n| !new.contains(n)) { - out.push(format!("-{what} {n}")); - } - } - let added = u.shared_out.iter().filter(|w| !old.shared_out.contains(w)).count(); - let gone = old.shared_out.iter().filter(|w| !u.shared_out.contains(w)).count(); - if added + gone != 0 { - out.push(format!("shared writes +{added} -{gone}")); - } - out -} - -/// The top-level fields of `a` and `b` (one type) whose values differ, read -/// from their derived `Debug` form: no list of fields to keep in step. -pub(crate) fn changed_fields(a: &T, b: &T) -> Vec { - fn fields(s: &str) -> Vec<(String, String)> { - let mut v: Vec<(String, String)> = Vec::new(); - for line in s.lines() { - let top = line.starts_with(" ") && !line.starts_with(" "); - match line[line.len().min(4)..].split_once(": ") { - Some((name, rest)) if top => v.push((name.to_string(), rest.to_string())), - _ => { - if let Some(last) = v.last_mut() { - last.1.push('\n'); - last.1.push_str(line); - } - } - } - } - v - } - let (fa, fb) = (fields(&format!("{a:#?}")), fields(&format!("{b:#?}"))); - let mut out: Vec = fa - .iter() - .filter(|(n, x)| fb.iter().find(|(m, _)| m == n).is_none_or(|(_, y)| x != y)) - .map(|(n, _)| n.clone()) - .collect(); - out.extend(fb.iter().filter(|(n, _)| !fa.iter().any(|(m, _)| m == n)).map(|(n, _)| n.clone())); - out -} - -/// The no-fixpoint report of round `round`: the units it solved and what -/// each changed (at most 16 units, 8 changes each). -fn no_fixpoint_report( - round: u32, - solves: &[Solve<'_>], - changed: &[(usize, Vec)], - seeds: usize, -) { - eprintln!( - "perry: wpfacts: no fixpoint after {round} rounds: round {round} solved {} units, {seeds} seeded for the next", - changed.len() - ); - for (si, cs) in changed.iter().take(16) { - let shown: Vec<&str> = cs.iter().take(8).map(|c| c.as_str()).collect(); - eprintln!( - "perry: wpfacts: {} changed {}: {}{}", - solves[*si].key, - cs.len(), - if shown.is_empty() { "nothing".to_string() } else { shown.join("; ") }, - if cs.len() > 8 { "; ..." } else { "" } - ); - } -} From a916ecc8e825371d15be13fe4464414dc4b50c64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 2 Nov 2026 07:17:21 +0000 Subject: [PATCH 40/84] SNAP3b state: cargo check passes (production and instr cfg); release + instr build running; census code untested Built: env funnel hooks (env_object/env_storage/env_var/env_has, js_snap_env_site, skip_body), codegen site hook (expr/snap_env_site.rs, PERRY_SNAP_CENSUS compiles only), runner rule (b) cells/guard/knobs, entry-body pseudo-init in D, write containment (PERRY_SNAP_Q7, default on), check run (PERRY_SNAP_SKIP/POISON/WD). Not run yet: fixtures, fastify/Supabase census, irdiff, runtime tests. From 8f3d5dfde844a72f8c23f70df7b404fbe6621977 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 2 Nov 2026 08:34:53 +0000 Subject: [PATCH 41/84] SNAP3c census fixes: literal-shape store sites, guarded vars drop cells, check run ends at the boundary, digest names env storage and malloc pointers - codegen snap_env_site: a value of a literal-shape construction (an object literal lowered to new __AnonShape_*) is a store site. - runner: a var an imaged init observes is a guard member; its store-site reads produce no cell. #guard lines carry the store_read reader and pc. - runner: the check run exits at the boundary (what runs after it reads skipped members bindings). - runner digest: the env object and its storage are one name (env block, filled at startup); pointers into malloc memory hash as their tag (mimalloc arena hints are random per run). PERRY_SNAP_DIGEST_DUMP=1 writes every hashed word to .dig. - production: env_has/env_object/job_owner/job_enter allow(dead_code) outside instrumented builds; the reservation re-export only in instrumented cfg. Fixtures pass with the rule on and off except the digest changes (untested: built after the last fixture run). fastify, Supabase, irdiff, tests not run. --- .../perry-codegen/src/expr/snap_env_site.rs | 11 +++ crates/perry-runtime/src/arena/mod.rs | 2 +- crates/perry-runtime/src/snap_census.rs | 4 + .../perry-runtime/src/snap_census/runner.rs | 94 ++++++++++++++++--- 4 files changed, 97 insertions(+), 14 deletions(-) diff --git a/crates/perry-codegen/src/expr/snap_env_site.rs b/crates/perry-codegen/src/expr/snap_env_site.rs index d497e4fcc78..916bc6a26b6 100644 --- a/crates/perry-codegen/src/expr/snap_env_site.rs +++ b/crates/perry-codegen/src/expr/snap_env_site.rs @@ -60,6 +60,17 @@ pub(crate) fn note_store_parent(ctx: &FnCtx<'_>, expr: &Expr) { note(std::iter::once(&**value)) } Expr::Object(fields) => note(fields.iter().map(|(_, v)| 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()), _ => {} } diff --git a/crates/perry-runtime/src/arena/mod.rs b/crates/perry-runtime/src/arena/mod.rs index 50c65968ca5..4c6c0e38838 100644 --- a/crates/perry-runtime/src/arena/mod.rs +++ b/crates/perry-runtime/src/arena/mod.rs @@ -231,7 +231,7 @@ pub(crate) use region::new_test_block_backing; pub(crate) use region::release_test_block_backing; pub(crate) use inline::refresh_inline_allocation_limit; -#[cfg(target_os = "linux")] +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] pub(crate) use region::reservation::{ for_each_mapped as snap_reservation_regions, payload_bounds as snap_reservation_bounds, }; diff --git a/crates/perry-runtime/src/snap_census.rs b/crates/perry-runtime/src/snap_census.rs index b09694422eb..2408a57284f 100644 --- a/crates/perry-runtime/src/snap_census.rs +++ b/crates/perry-runtime/src/snap_census.rs @@ -183,6 +183,7 @@ impl Drop for RequireScope { /// The init a job enqueued now belongs to (0: none). #[inline(always)] +#[cfg_attr(not(perry_gc_instruments), allow(dead_code))] pub(crate) fn job_owner() -> u32 { #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] if runner::on() { @@ -193,6 +194,7 @@ pub(crate) fn job_owner() -> u32 { /// A job owned by `owner` (from [`job_owner`] at its enqueue) starts. #[inline(always)] +#[cfg_attr(not(perry_gc_instruments), allow(dead_code))] pub(crate) fn job_enter(owner: u32) { #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] if runner::on() { @@ -366,6 +368,7 @@ pub extern "C" fn js_snap_entry_body() { /// `boxed` is its NaN-boxed bits. Taking it is not an observation; its words /// in the image are `env_object` cells. #[inline(always)] +#[cfg_attr(not(perry_gc_instruments), allow(dead_code))] pub(crate) fn env_object(boxed: f64) { #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] if runner::on() { @@ -414,6 +417,7 @@ pub(crate) fn env_var(name: &str, value: Option<&str>, ptr: usize) { /// A presence test of one var (`"X" in env`, `hasOwnProperty`, `Reflect.has`). #[inline(always)] +#[cfg_attr(not(perry_gc_instruments), allow(dead_code))] pub(crate) fn env_has(name: &str, present: bool) { #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] if runner::on() { diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 1df5f743c87..506f34bc7cd 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -2013,6 +2013,11 @@ pub(super) fn boundary(at: &'static str) { } crate::arena::refresh_inline_allocation_limit(); write_report(st); + // 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() { + unsafe { libc::_exit(0) }; + } if st.rset { unsafe { arm_rset(st) }; return; @@ -2276,13 +2281,20 @@ fn write_report(st: &mut State) { let mut rank: std::collections::HashMap = std::collections::HashMap::new(); let mut names: Vec<(usize, usize, u64)> = Vec::with_capacity(log.len()); let mut ranks: Vec = Vec::with_capacity(log.len()); + // The env object and its storage are the env block, filled at + // startup (an `env_object` cell): one name, never hashed by value. + let mut env_store = st.env_storage.clone(); + env_store.sort_unstable(); + let is_env = |a: usize| env_store.binary_search_by_key(&a, |r| r.0).is_ok(); for b in log { let k = rank.entry(b.init).or_insert(0); let a = st.inits[b.init as usize].addr as u64; // The runtime's births (init 0) happen when an init first uses a // runtime object, so their order depends on D: they are named by // type and size only. - let name = if b.init == 0 { + let name = if is_env(b.addr) { + ENV_NAME + } else if b.init == 0 { let ty = unsafe { (*(b.addr as *const crate::gc::GcHeader)).obj_type } as u64; 0x5254_0000_0000_0000 ^ (ty << 32) ^ b.size as u64 } else { @@ -2304,12 +2316,33 @@ fn write_report(st: &mut State) { (i > 0 && v < names[i - 1].1).then(|| (names[i - 1].2, v - names[i - 1].0)) }; let mut per: std::collections::BTreeMap = Default::default(); + // Diagnostic: `PERRY_SNAP_DIGEST_DUMP=1` writes every hashed word + // (`.dig`: init, birth name, offset, token) to diff two runs. + let mut dig_out = std::env::var_os("PERRY_SNAP_DIGEST_DUMP") + .and_then(|_| std::fs::File::create(format!("{}.dig", st.out)).ok()); + let mut dump = |init: usize, bname: u64, off: usize, tok: String| { + if let Some(o) = dig_out.as_mut() { + use std::io::Write; + let _ = writeln!(o, "{init:x}\t{bname:x}\t{off}\t{tok}"); + } + }; let mix = |h: &mut u64, x: u64| { *h ^= x; *h = h.wrapping_mul(0x100000001B3); }; + // A pointer outside the reservation and the binary points into + // malloc memory (mimalloc picks randomized arena hints, so its + // addresses differ per run even with ASLR off): it hashes as its tag. + let foreign = |v: u64| -> bool { + let a = (v & 0xFFFF_FFFF_FFFF) as usize; + let hi = v >> 48; + (hi == 0x7FFD || hi == 0x7FFC || hi == 0x7FFF || hi == 0x7FFE) + && st.heap.slot(a).is_none() + && st.bin.slot(a).is_none() + && st.tls.slot(a).is_none() + }; for (bk, b) in log.iter().enumerate() { - if b.init != 0 && st.inits[b.init as usize].in_d { + if (b.init != 0 && st.inits[b.init as usize].in_d) || is_env(b.addr) { continue; } let bname = birth_names[bk]; @@ -2340,10 +2373,17 @@ fn write_report(st: &mut State) { mix(&mut e.0, hi ^ 0xA5A5); mix(&mut e.0, n); mix(&mut e.0, o as u64); + dump(st.inits[b.init as usize].addr, bname, off - 8, format!("p{hi:x}:{n:x}+{o}")); continue; } } + if foreign(v) { + mix(&mut e.0, v >> 48); + dump(st.inits[b.init as usize].addr, bname, off - 8, format!("f{:x}", v >> 48)); + continue; + } mix(&mut e.0, v); + dump(st.inits[b.init as usize].addr, bname, off - 8, format!("v{v:x}")); } } // The module-global slots each imaged init registered. @@ -2363,10 +2403,17 @@ fn write_report(st: &mut State) { mix(&mut e.0, hi ^ 0xA5A5); mix(&mut e.0, n); mix(&mut e.0, o as u64); + dump(st.inits[owner as usize].addr, 0, slot, format!("p{hi:x}:{n:x}+{o}")); continue; } } + if foreign(v) { + mix(&mut e.0, v >> 48); + dump(st.inits[owner as usize].addr, 0, slot, format!("f{:x}", v >> 48)); + continue; + } mix(&mut e.0, v); + dump(st.inits[owner as usize].addr, 0, slot, format!("v{v:x}")); } let mut total = 0xcbf29ce484222325u64; for (addr, (h, objs, words)) in &per { @@ -2429,8 +2476,9 @@ struct EnvObs { value: Option, /// The string a store-site read returned (header address). ptr: usize, - /// The imaged init that read a store-site string's body. + /// The imaged init that read a store-site string's body, and where. by: u32, + by_pc: usize, } fn top_init(st: &State) -> u32 { @@ -2480,6 +2528,7 @@ pub(super) fn env_object(bits: u64) { value: None, ptr: 0, by: 0, + by_pc: 0, }); } } @@ -2532,6 +2581,7 @@ pub(super) fn env_var(name: &str, value: Option<&str>, ptr: usize) { value: value.map(str::to_owned), ptr, by: 0, + by_pc: 0, }); } @@ -2548,6 +2598,7 @@ pub(super) fn env_has(name: &str, present: bool) { value: present.then(|| "".to_owned()), ptr: 0, by: 0, + by_pc: 0, }); } @@ -2586,6 +2637,7 @@ fn env_word_access(st: &mut State, v: u32, word: usize, write: bool, pc: usize) if o.kind == OBS_STORE { o.kind = OBS_STORE_READ; o.by = top; + o.by_pc = pc; } } } @@ -2647,6 +2699,9 @@ fn tsv_text(s: &str) -> String { /// The `#cell`, `#guard`, `#knob` lines (design §2.10.8) and the per-init /// slot owners for the check run. +/// The digest name of the env object and its storage. +const ENV_NAME: u64 = 0x454E_5600_0000_0000; + fn write_env_report(st: &State, f: &mut std::fs::File, rank: &[u64]) { use std::io::Write; let in_d = |i: u32| st.inits[i as usize].in_d; @@ -2659,8 +2714,19 @@ fn write_env_report(st: &State, f: &mut std::fs::File, rank: &[u64]) { if st.env_obj != 0 { targets.entry(st.env_obj & 0xFFFF_FFFF_FFFF).or_default().push(-1); } + // A var some imaged init observes is a guard member: the guard makes + // every imaged string of it right, so its store-site reads need no cell. + let guarded: std::collections::HashSet<&str> = st + .env_obs + .iter() + .filter(|o| { + matches!(o.kind, OBS_OBSERVE | OBS_HAS | OBS_STORE_UNSET | OBS_STORE_READ) + && (o.init == 0 || !in_d(o.init)) + }) + .map(|o| o.name.as_str()) + .collect(); for (k, o) in st.env_obs.iter().enumerate() { - if o.kind == OBS_STORE && !in_d(o.init) { + if o.kind == OBS_STORE && !in_d(o.init) && !guarded.contains(o.name.as_str()) { targets.entry(o.ptr as u64 & 0xFFFF_FFFF_FFFF).or_default().push(k as i64); } } @@ -2729,27 +2795,29 @@ fn write_env_report(st: &State, f: &mut std::fs::File, rank: &[u64]) { // The guard: every other observation by an imaged init (the runtime's // own, init 0, included: the IPC probe decides the process object). let mut seen: std::collections::HashSet<(String, u32, &'static str)> = Default::default(); - let mut guard = |name: &str, value: Option<&str>, init: u32, consumer: &'static str, f: &mut std::fs::File| { + let mut guard = |name: &str, value: Option<&str>, init: u32, consumer: &'static str, by: (u32, usize), f: &mut std::fs::File| { if init != 0 && in_d(init) { return; } if seen.insert((name.to_owned(), init, consumer)) { let _ = writeln!( f, - "#guard\t{}\t{}\t{init}\t{:x}\t{consumer}", + "#guard\t{}\t{}\t{init}\t{:x}\t{consumer}\t{}\t{:x}", tsv_text(name), value.map_or("".to_owned(), tsv_text), - addr_of(init) + addr_of(init), + by.0, + by.1 ); } }; for (k, o) in st.env_obs.iter().enumerate() { match o.kind { - OBS_OBSERVE => guard(&o.name, o.value.as_deref(), o.init, "observe", f), - OBS_HAS => guard(&o.name, o.value.as_deref(), o.init, "has", f), - OBS_STORE_UNSET => guard(&o.name, None, o.init, "store_unset", f), - OBS_STORE_READ => guard(&o.name, o.value.as_deref(), o.init, "store_read", f), - OBS_STORE if found[k] == 0 && !in_d(o.init) => { + OBS_OBSERVE => guard(&o.name, o.value.as_deref(), o.init, "observe", (0, 0), f), + OBS_HAS => guard(&o.name, o.value.as_deref(), o.init, "has", (0, 0), f), + OBS_STORE_UNSET => guard(&o.name, None, o.init, "store_unset", (0, 0), f), + OBS_STORE_READ => guard(&o.name, o.value.as_deref(), o.init, "store_read", (o.by, o.by_pc), f), + OBS_STORE if found[k] == 0 && !in_d(o.init) && !guarded.contains(o.name.as_str()) => { let _ = writeln!(f, "#cell_unstored\t{}\t{}", o.name, o.init); } _ => {} @@ -2765,7 +2833,7 @@ fn write_env_report(st: &State, f: &mut std::fs::File, rank: &[u64]) { .iter() .any(|o| o.init == *init && o.kind != OBS_OBJECT && o.name == *name); if !explained { - guard(name, value.as_deref(), *init, "libc", f); + guard(name, value.as_deref(), *init, "libc", (0, 0), f); } } let takers = st.env_obs.iter().filter(|o| o.kind == OBS_OBJECT).count(); From a6100859c449e77ecbe7df5ad3d811c35032322f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 2 Nov 2026 09:01:53 +0000 Subject: [PATCH 42/84] SNAP3d: the string alias-hint word is no env_var observation (cell reports alias=); raw mimalloc pointers hash as foreign in the digest --- .../perry-runtime/src/snap_census/runner.rs | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 506f34bc7cd..d940f6e3779 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -2333,9 +2333,15 @@ fn write_report(st: &mut State) { // A pointer outside the reservation and the binary points into // malloc memory (mimalloc picks randomized arena hints, so its // addresses differ per run even with ASLR off): it hashes as its tag. + // A raw (untagged) word that points into mimalloc's arenas is such + // a pointer too: runtime objects keep native pointers (a closure's + // Rust-side state) in raw words. let foreign = |v: u64| -> bool { let a = (v & 0xFFFF_FFFF_FFFF) as usize; let hi = v >> 48; + if hi == 0 { + return in_malloc(a); + } (hi == 0x7FFD || hi == 0x7FFC || hi == 0x7FFF || hi == 0x7FFE) && st.heap.slot(a).is_none() && st.bin.slot(a).is_none() @@ -2634,6 +2640,14 @@ fn env_word_access(st: &mut State, v: u32, word: usize, write: bool, pc: usize) return; } if let Some(o) = st.env_obs.get_mut(v as usize) { + // The word holding `capacity` and the `refcount` alias hint is the + // string's ownership state, not its value: a store barrier demotes + // the string to shared there (`js_string_addref`). The value words + // (lengths, flags, bytes) are what an observation reads; the hint is + // reported with the cell so the startup fill sets it the same way. + if word == (o.ptr + std::mem::offset_of!(crate::StringHeader, refcount)) & !7 { + return; + } if o.kind == OBS_STORE { o.kind = OBS_STORE_READ; o.by = top; @@ -2702,6 +2716,19 @@ fn tsv_text(s: &str) -> String { /// The digest name of the env object and its storage. const ENV_NAME: u64 = 0x454E_5600_0000_0000; +/// Whether `a` lies in mimalloc's heap memory (raw native pointers). +fn in_malloc(a: usize) -> bool { + #[cfg(feature = "alloc-mimalloc")] + { + a >= 0x1_0000 && unsafe { libmimalloc_sys::mi_is_in_heap_region(a as *const _) } + } + #[cfg(not(feature = "alloc-mimalloc"))] + { + let _ = a; + false + } +} + fn write_env_report(st: &State, f: &mut std::fs::File, rank: &[u64]) { use std::io::Write; let in_d = |i: u32| st.inits[i as usize].in_d; @@ -2740,7 +2767,9 @@ fn write_env_report(st: &State, f: &mut std::fs::File, rank: &[u64]) { } else { let o = &st.env_obs[t as usize]; found[t as usize] += 1; - let _ = writeln!(f, "#cell\tenv_var\t{name}\t{}\t{}", o.name, o.init); + // The alias hint at the boundary (0 shared, 1 unique). + let alias = unsafe { (*(o.ptr as *const crate::StringHeader)).refcount }; + let _ = writeln!(f, "#cell\tenv_var\t{name}\t{}\t{}\talias={alias}", o.name, o.init); } } }; From 349b9d1a39b721a348a8e4760e8976ef20619f90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 2 Nov 2026 09:08:08 +0000 Subject: [PATCH 43/84] SNAP3d: a literal CommonJS require edge marks its target evaluation as reached by require (js_snap_require_edge, census compiles only), not import() --- .../lower_call/native/native_runtime_branch.rs | 9 +++++++++ .../perry-codegen/src/runtime_decls/strings.rs | 1 + crates/perry-runtime/src/snap_census.rs | 16 ++++++++++++++++ crates/perry-runtime/src/snap_census/runner.rs | 16 +++++++++++++++- 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs b/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs index f9741638486..9e7ee402cbc 100644 --- a/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs +++ b/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs @@ -374,7 +374,16 @@ return crate::lower_array_record_length::lower(ctx, &args[0]); let init_fn = format!("{prefix}__init"); ctx.pending_declares .push((init_fn.clone(), crate::types::VOID, vec![])); + // SNAP census: the evaluation this guard starts was reached + // by `require`, not by `import()` (instrumented compiles only). + let census = crate::expr::snap_env_site::census_compile(); + if census { + ctx.block().call_void("js_snap_require_edge", &[(crate::types::I32, "1")]); + } ctx.block().call_void(&init_fn, &[]); + if census { + ctx.block().call_void("js_snap_require_edge", &[(crate::types::I32, "0")]); + } return lower_expr(ctx, &args[0]); } "isBuiltin" => { diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index 31eb7d76cb5..d30ae7c07a8 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -949,6 +949,7 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { // SNAP census site hook (`expr/snap_env_site.rs`): instrumented compiles only. if crate::expr::snap_env_site::census_compile() { module.declare_function("js_snap_env_site", VOID, &[I32]); + module.declare_function("js_snap_require_edge", VOID, &[I32]); } // #1344: process.env.X = v / delete process.env.X. module.declare_function("js_setenv", VOID, &[I64, DOUBLE]); diff --git a/crates/perry-runtime/src/snap_census.rs b/crates/perry-runtime/src/snap_census.rs index 2408a57284f..1b8d2b5885b 100644 --- a/crates/perry-runtime/src/snap_census.rs +++ b/crates/perry-runtime/src/snap_census.rs @@ -443,6 +443,22 @@ pub extern "C" fn js_snap_env_site(kind: i32) { let _ = kind; } +/// 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 +/// edge), not by `import()`: it is no member of a dynamic subgraph (design +/// §2.10.7). A missing mark costs precision, never soundness. +#[no_mangle] +pub extern "C" fn js_snap_require_edge(on: i32) { + crate::effect!(pure); + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + runner::require_edge(on != 0); + } + #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] + let _ = on; +} + /// The check run (`PERRY_SNAP_SKIP=`): true when the evaluation now /// running is a member of D, whose body parts are skipped. #[inline(always)] diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index d940f6e3779..0a97d7875ea 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -451,6 +451,10 @@ struct State { transparent_hits: u64, entry_body_seq: u32, require_depth: i32, + /// A literal CommonJS require edge is calling its target's init guard + /// from this frame's init (`js_snap_require_edge`): the next evaluation + /// that frame starts was reached by `require`, not by `import()`. + require_edge: Option, bookkeeping: i32, w: WordSet, r: WordSet, @@ -642,6 +646,7 @@ unsafe fn start(out: String) { transparent_hits: 0, entry_body_seq: 0, require_depth: 0, + require_edge: None, bookkeeping: 0, w: WordSet::new(28), r: WordSet::new(28), @@ -980,7 +985,8 @@ pub(super) fn init_enter(addr: usize) { || st.inits[f.init as usize].addr == ENTRY_BODY_ADDR } }; - let reach = if st.require_depth > 0 { + let edge = st.require_edge.take() == Some(parent_id); + let reach = if st.require_depth > 0 || edge { Reach::Require } else if !parent_in_body { if parent.is_none() { @@ -1059,6 +1065,14 @@ pub(super) fn require_depth(delta: i32) { state().require_depth += delta; } +/// `js_snap_require_edge`: 1 before a literal CommonJS require edge calls its +/// 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 st = state(); + st.require_edge = on.then(|| top_init(st)); +} + pub(super) fn bookkeeping_depth(delta: i32) { state().bookkeeping += delta; } From 310e4e1fcdc494786d54f94a654d4820cd891679 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 2 Nov 2026 09:49:44 +0000 Subject: [PATCH 44/84] SNAP3e: the digest names a ShapeId by the shape it names and leaves a Map store's native indexes out The fastify check run differed in 14 imaged digests and runtime(0). Two kinds of word, neither program state: - the ShapeId in an object's or closure's first header word: ids are handed out in creation order and the env object's keys take ids too, so with a different env block every later shape had another id (+2 here). It now hashes as the shape's descriptor (key list, counts, flags, prototype link, kind, rep). - a MapStore's native indexes (hash tables, malloc pointers, and the uninitialized bytes of an absent dense index, which held a stack address): derived from the entries, now left out of the digest. Fastify check run: differ=0 and runtime(0) same, twice (ab and ab2). --- crates/perry-runtime/src/map.rs | 2 + crates/perry-runtime/src/map/store.rs | 10 ++++ .../perry-runtime/src/snap_census/runner.rs | 53 +++++++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/crates/perry-runtime/src/map.rs b/crates/perry-runtime/src/map.rs index 6d4374f372b..76a85c006ae 100644 --- a/crates/perry-runtime/src/map.rs +++ b/crates/perry-runtime/src/map.rs @@ -318,6 +318,8 @@ mod store; mod string_key; use store::*; +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +pub(crate) use store::snap_native_index_range; #[cfg(test)] pub(crate) use store::{ finalize_map_side_allocation_for_gc, test_init_owned_map_store, test_map_index_bytes, diff --git a/crates/perry-runtime/src/map/store.rs b/crates/perry-runtime/src/map/store.rs index d03a29b23a1..3c387215e1a 100644 --- a/crates/perry-runtime/src/map/store.rs +++ b/crates/perry-runtime/src/map/store.rs @@ -60,6 +60,16 @@ impl MapStore { } } +/// The SNAP census digest's view of a store: the payload words that are JS +/// state (receiver word, entries alias, capacity, used; then the inline +/// entries). The native indexes between them are derived from the entries, +/// hold malloc pointers, and an absent index leaves its bytes uninitialized, +/// so the digest leaves `[start, end)` out (payload offsets). +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +pub(crate) const fn snap_native_index_range() -> (usize, usize) { + (std::mem::offset_of!(MapStore, numeric), std::mem::size_of::()) +} + /// Bounded native publication: a creating caller allocates the store before /// anything unrooted is live; a growing caller keeps its receiver and values in /// roots and holds a no-move scope until the new store is linked. diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 0a97d7875ea..f8362ea9ba4 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -2370,11 +2370,24 @@ fn write_report(st: &mut State) { .entry(st.inits[b.init as usize].addr) .or_insert((0xcbf29ce484222325, 0, 0)); e.1 += 1; + let ty = unsafe { (*(b.addr as *const crate::gc::GcHeader)).obj_type }; + dump(st.inits[b.init as usize].addr, bname, 0, format!("t{ty}:{}", b.size)); // The GC header (mark and move-request bits) is no JS state. let mut off = crate::gc::GC_HEADER_SIZE; + // A Map store's native indexes are derived state (see + // `map::snap_native_index_range`): left out. + let skip = if ty == crate::gc::GC_TYPE_MAP_STORE { + let (s, e) = crate::map::snap_native_index_range(); + (crate::gc::GC_HEADER_SIZE + s, crate::gc::GC_HEADER_SIZE + e) + } else { + (0, 0) + }; while off + 8 <= b.size as usize { let word = b.addr + off; off += 8; + if word - b.addr >= skip.0 && word - b.addr < skip.1 { + continue; + } if st.w.contains(word as u64) { if let Some(o) = wd_out.as_mut() { use std::io::Write; @@ -2387,6 +2400,46 @@ fn write_report(st: &mut State) { } let v = unsafe { *(word as *const u64) }; e.2 += 1; + // An ordinary object's first header word holds its class id + // and its ShapeId (a closure's: capture count and ShapeId). A ShapeId is the shape's NAME: ids are + // handed out in creation order, and the env object's keys + // (filled at startup) take ids too, so the same shape gets + // another id when the env block differs. It hashes as the + // shape it names: key list, counts, flags, prototype link, + // kind and representation. + if off - 8 == crate::gc::GC_HEADER_SIZE + && (ty == perry_abi::GC_TYPE_OBJECT || ty == perry_abi::GC_TYPE_CLOSURE) + { + let sid = (v >> 32) as u32; + if let Some(d) = crate::object::shapes::shape_descriptor_for_word( + crate::codegen_abi::LayoutWord(sid), + ) { + let pn = |w: u64| { + name_of((w & 0xFFFF_FFFF_FFFF) as usize).map_or(w, |(n, o)| { + n ^ (o as u64).rotate_left(17) ^ (w >> 48).rotate_left(40) + }) + }; + let sname = [ + pn(d.keys), + d.logical_key_count as u64, + d.live_inline_slot_count as u64, + d.integrity_flags as u64, + pn(d.proto_id), + d.object_kind as u8 as u64, + d.hole_count as u64, + d.summary as u64, + d.rep, + d.special_constfn_mask as u64, + ] + .iter() + .fold(0xcbf29ce484222325u64, |h, &x| (h ^ x).wrapping_mul(0x100000001B3)); + mix(&mut e.0, v & 0xFFFF_FFFF); + mix(&mut e.0, sname); + let tok = format!("s{:x}:{sname:x}", v & 0xFFFF_FFFF); + dump(st.inits[b.init as usize].addr, bname, off - 8, tok); + continue; + } + } let hi = v >> 48; if hi == 0 || hi >= 0x7FF9 { if let Some((n, o)) = name_of((v & 0xFFFF_FFFF_FFFF) as usize) { From 07965bf95dcc387dfbf948a507094eb245d5709f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 2 Nov 2026 10:05:29 +0000 Subject: [PATCH 45/84] SNAP3f: the async-context test snapshot fills the census field by default --- crates/perry-runtime/src/async_context.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/perry-runtime/src/async_context.rs b/crates/perry-runtime/src/async_context.rs index bef737a001e..26765cab940 100644 --- a/crates/perry-runtime/src/async_context.rs +++ b/crates/perry-runtime/src/async_context.rs @@ -695,6 +695,7 @@ pub(crate) fn test_snapshot_with_store(store: f64) -> AsyncContextSnapshot { token: payload.token, stores: vec![store], }], + ..Default::default() } } From c0766546c4d324d48d42967bcb5746b3493cc0b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 2 Nov 2026 10:57:15 +0000 Subject: [PATCH 46/84] SNAP3g: production builds carry no census code: main's instanceof (the pre-#13546 callable-instance walk from the snap1 merge is gone), one declared-prototype link read, js_snap_* exports and census-only helpers under perry_gc_instruments --- .../src/arena/region/reservation.rs | 2 ++ crates/perry-runtime/src/gc/mod.rs | 1 + crates/perry-runtime/src/gc/roots.rs | 1 + .../src/object/class_registry/state.rs | 26 +++++++++++------- .../src/object/instanceof/static_dispatch.rs | 27 ------------------- crates/perry-runtime/src/snap_census.rs | 3 +++ 6 files changed, 24 insertions(+), 36 deletions(-) diff --git a/crates/perry-runtime/src/arena/region/reservation.rs b/crates/perry-runtime/src/arena/region/reservation.rs index 10a3026d684..1883df489ab 100644 --- a/crates/perry-runtime/src/arena/region/reservation.rs +++ b/crates/perry-runtime/src/arena/region/reservation.rs @@ -441,11 +441,13 @@ pub(super) unsafe fn map(kind: Kind, mapped: usize) -> *mut u8 { } /// `[base, base + len)` of the reservation's payload (empty before it /// exists). The SNAP census sizes its page map with it. +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] pub(crate) fn payload_bounds() -> (usize, usize) { initialized().map_or((0, 0), |r| (r.base, r.base + r.len)) } /// Every mapped region, `[base, end)`, once each. +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] pub(crate) fn for_each_mapped(mut f: impl FnMut(usize, usize)) { let Some(r) = existing() else { return; diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index d48c4b8cdb2..56718c95c8b 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -92,6 +92,7 @@ pub use malloc::*; mod hot_tls; pub(crate) use hot_tls::*; mod roots; +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] pub(crate) use roots::for_each_global_root_slot; pub use roots::*; mod full_trace; diff --git a/crates/perry-runtime/src/gc/roots.rs b/crates/perry-runtime/src/gc/roots.rs index 60ae7ad91ed..6aa379e7903 100644 --- a/crates/perry-runtime/src/gc/roots.rs +++ b/crates/perry-runtime/src/gc/roots.rs @@ -572,6 +572,7 @@ pub(super) fn register_global_root(root: *mut u64) { } /// Every registered module-global root slot (the SNAP census tracks them). +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] pub(crate) fn for_each_global_root_slot(mut f: impl FnMut(usize)) { GLOBAL_ROOTS.with(|roots| { for &root in roots.borrow().iter() { diff --git a/crates/perry-runtime/src/object/class_registry/state.rs b/crates/perry-runtime/src/object/class_registry/state.rs index b36d546ba5b..9aaa7c2482c 100644 --- a/crates/perry-runtime/src/object/class_registry/state.rs +++ b/crates/perry-runtime/src/object/class_registry/state.rs @@ -1127,19 +1127,27 @@ fn decl_prototype_class_id(ptr: usize) -> Option { // SAFETY: the header names an ordinary object at `ptr`; its first word // is the class id. let class_id = unsafe { (*(ptr as *const ObjectHeader)).class_id }; - if class_id == 0 { - return None; - } - // The link of a class that is not `ptr`'s observes nothing: the compare - // fails whatever the link holds. Only a match reads it (the SNAP census - // counts that read as the init's; the miss is bookkeeping). + (class_id != 0 && decl_prototype_link_observed(class_id, ptr) == ptr).then_some(class_id) +} + +/// `class_id`'s declared-prototype link, as `decl_prototype_class_id` +/// compares it with `ptr`. The link of a class that is not `ptr`'s observes +/// nothing: the compare fails whatever the link holds. In instrumented +/// builds the SNAP census counts the read as the init's only on a match (it +/// reads the link again outside the bookkeeping scope); a production build +/// reads it once. +#[inline(always)] +fn decl_prototype_link_observed(class_id: u32, ptr: usize) -> usize { let link = { let _census = crate::snap_census::bookkeeping(); crate::object::class_value::class_decl_prototype_link(class_id) as usize }; - (link == ptr - && crate::object::class_value::class_decl_prototype_link(class_id) as usize == ptr) - .then_some(class_id) + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if link == ptr { + return crate::object::class_value::class_decl_prototype_link(class_id) as usize; + } + let _ = ptr; + link } /// #7757: a monomorphized specialization (`Gen$num`) must present the GENERIC's diff --git a/crates/perry-runtime/src/object/instanceof/static_dispatch.rs b/crates/perry-runtime/src/object/instanceof/static_dispatch.rs index dd00f8543d7..10b60e3ebab 100644 --- a/crates/perry-runtime/src/object/instanceof/static_dispatch.rs +++ b/crates/perry-runtime/src/object/instanceof/static_dispatch.rs @@ -178,33 +178,6 @@ fn instanceof_dispatch( false_val }; } - // A function object given a class's prototype (a callable class - // instance: `Object.setPrototypeOf(fn.bind(this), - // new.target.prototype)`, ArkType's `Callable`) has no class shape: - // OrdinaryHasInstance walks its prototype chain for the class's - // prototype object. - if crate::closure::is_closure_bits(value.to_bits()) { - if let Some(first) = crate::closure::closure_static_prototype(addr) { - let scope = crate::gc::RuntimeHandleScope::new(); - let target = scope.root_nanbox_f64( - super::super::class_registry::class_decl_prototype_value(class_id), - ); - let mut proto = scope.root_nanbox_u64(first); - for _ in 0..1024 { - let bits = proto.get_nanbox_u64(); - let current = crate::value::JSValue::from_bits(bits); - if current.is_null() || current.is_undefined() { - break; - } - if bits == target.get_nanbox_u64() { - return true_val; - } - let next = crate::object::js_object_get_prototype_of(f64::from_bits(bits)); - proto = scope.root_nanbox_u64(next.to_bits()); - } - return false_val; - } - } } native_with_constructor(value, class_id, constructor) } diff --git a/crates/perry-runtime/src/snap_census.rs b/crates/perry-runtime/src/snap_census.rs index 1b8d2b5885b..ff1f65872fa 100644 --- a/crates/perry-runtime/src/snap_census.rs +++ b/crates/perry-runtime/src/snap_census.rs @@ -348,6 +348,7 @@ impl Drop for RuntimeScope { /// Codegen calls this where the entry module's own body starts (after its /// eager imports), in instrumented compiles only (`PERRY_SNAP_CENSUS` set /// while compiling). The census boundary of an ESM entry. +#[cfg(perry_gc_instruments)] #[no_mangle] pub extern "C" fn js_snap_entry_body() { crate::effect!(pure); @@ -432,6 +433,7 @@ pub(crate) fn env_has(name: &str, present: bool) { /// when the read's value is stored straight into a module global, a /// property, an element or a literal; 0 otherwise. Kept for the frame's /// next `js_getenv`. +#[cfg(perry_gc_instruments)] #[no_mangle] pub extern "C" fn js_snap_env_site(kind: i32) { crate::effect!(pure); @@ -448,6 +450,7 @@ pub extern "C" fn js_snap_env_site(kind: i32) { /// after. The evaluation the guard starts was reached by `require` (a static /// edge), not by `import()`: it is no member of a dynamic subgraph (design /// §2.10.7). A missing mark costs precision, never soundness. +#[cfg(perry_gc_instruments)] #[no_mangle] pub extern "C" fn js_snap_require_edge(on: i32) { crate::effect!(pure); From d7b18c63ffc2c5a7b4134fb7eabd8e85ffe10ce9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 2 Nov 2026 11:16:07 +0000 Subject: [PATCH 47/84] SNAP3g: a reused slot reports its birth by the user address (the header address is computed only in instrumented builds); the declared-prototype link is read once, with no census guard, in production --- crates/perry-runtime/src/arena/allocators.rs | 2 +- crates/perry-runtime/src/gc/old_free.rs | 4 ++-- .../src/object/class_registry/state.rs | 9 +++++++-- crates/perry-runtime/src/snap_census.rs | 13 +++++++++++++ 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/perry-runtime/src/arena/allocators.rs b/crates/perry-runtime/src/arena/allocators.rs index 1501ffcab1d..81eb44a446b 100644 --- a/crates/perry-runtime/src/arena/allocators.rs +++ b/crates/perry-runtime/src/arena/allocators.rs @@ -701,7 +701,7 @@ fn arena_alloc_gc_slow(size: usize, align: usize, obj_type: u8, total: usize) -> }; if let Some(user_ptr) = reused { - crate::snap_census::birth(unsafe { user_ptr.sub(GC_HEADER_SIZE) }, total); + crate::snap_census::birth_user(user_ptr as usize, total); ARENA.with(|a| unsafe { (&mut *a.get()).note_allocation(total, total >= 16 * 1024) }); // Reusing a free-list slot: the GcHeader is already in place (before user_ptr) // Just update it diff --git a/crates/perry-runtime/src/gc/old_free.rs b/crates/perry-runtime/src/gc/old_free.rs index 2212d345b42..171083380be 100644 --- a/crates/perry-runtime/src/gc/old_free.rs +++ b/crates/perry-runtime/src/gc/old_free.rs @@ -332,7 +332,7 @@ pub(crate) fn old_free_take_exact( ) -> Option { let user = old_free_take_exact_class(total_size, excluded_pages, false); if let Some(user) = user { - crate::snap_census::birth((user - crate::gc::GC_HEADER_SIZE) as *mut u8, total_size); + crate::snap_census::birth_user(user, total_size); } user } @@ -340,7 +340,7 @@ pub(crate) fn old_free_take_exact( pub(crate) fn old_free_take_exact_collecting_holder(total_size: usize) -> Option { let user = old_free_take_exact_class(total_size, None, true); if let Some(user) = user { - crate::snap_census::birth((user - crate::gc::GC_HEADER_SIZE) as *mut u8, total_size); + crate::snap_census::birth_user(user, total_size); } user } diff --git a/crates/perry-runtime/src/object/class_registry/state.rs b/crates/perry-runtime/src/object/class_registry/state.rs index 9aaa7c2482c..ffd50156f01 100644 --- a/crates/perry-runtime/src/object/class_registry/state.rs +++ b/crates/perry-runtime/src/object/class_registry/state.rs @@ -1136,20 +1136,25 @@ fn decl_prototype_class_id(ptr: usize) -> Option { /// builds the SNAP census counts the read as the init's only on a match (it /// reads the link again outside the bookkeeping scope); a production build /// reads it once. +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] #[inline(always)] fn decl_prototype_link_observed(class_id: u32, ptr: usize) -> usize { let link = { let _census = crate::snap_census::bookkeeping(); crate::object::class_value::class_decl_prototype_link(class_id) as usize }; - #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] if link == ptr { return crate::object::class_value::class_decl_prototype_link(class_id) as usize; } - let _ = ptr; link } +#[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] +#[inline(always)] +fn decl_prototype_link_observed(class_id: u32, _ptr: usize) -> usize { + crate::object::class_value::class_decl_prototype_link(class_id) as usize +} + /// #7757: a monomorphized specialization (`Gen$num`) must present the GENERIC's /// reflective surface. TypeScript erases type arguments, so at runtime there is /// exactly one `Gen`, one `Gen.prototype` and one `Gen.prototype.constructor` — diff --git a/crates/perry-runtime/src/snap_census.rs b/crates/perry-runtime/src/snap_census.rs index ff1f65872fa..d28648044a9 100644 --- a/crates/perry-runtime/src/snap_census.rs +++ b/crates/perry-runtime/src/snap_census.rs @@ -255,6 +255,19 @@ pub(crate) fn birth(ptr: *mut u8, size: usize) { let _ = (ptr, size); } +/// A reused slot handed out at `user` (its GcHeader sits just below): +/// [`birth`] of the allocation. The header address is computed only in +/// instrumented builds, so a production caller computes nothing new. +#[inline(always)] +pub(crate) fn birth_user(user: usize, size: usize) { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + runner::birth(user - crate::gc::GC_HEADER_SIZE, size); + } + #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] + let _ = (user, size); +} + /// While the census runs, compiled code must allocate through the runtime /// (every birth is logged), so the inline bump bound is closed. #[inline(always)] From aff6899e45fdb86e8567330ced61d16a915ca28a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 2 Nov 2026 18:03:40 +0000 Subject: [PATCH 48/84] SNAP3i: meta installs and prototype marks are representation; the entry module is the entry body; the weights run takes no fault Census only (perry_gc_instruments / PERRY_SNAP_CENSUS compiles); production code paths unchanged. - object_meta_ensure(_for_cell): snap_census::meta_install/meta_installed around the meta pointer store. The store is no rule 2-4 access; a record of an owner that pre-exists the first import() joins rule 4's pre-existing set (its words are the owner's). - proto_validity::ensure_meta_for_mark runs in snap_census::representation() (the prototype / exotic-read-receiver mark: empty record, flag, private shape lineage). Knob PERRY_SNAP_META=0. - codegen entry_in_init: js_snap_entry_module() before the entry module's __init; the runner takes that evaluation as the entry body (in D, EntryEager, not dynamic); the check run never skips it and does not poison its slots. - PERRY_SNAP_NOPROTECT=1 (weights run): no page protection, no collection hold. Built; fixtures meta and argv were running at commit time (untested); Supabase chains not run; irdiff/rtest2 not run. --- crates/perry-codegen/src/codegen/entry.rs | 7 + .../src/object/meta_accessors.rs | 8 ++ .../src/object/proto_validity.rs | 5 + crates/perry-runtime/src/snap_census.rs | 55 +++++++- .../perry-runtime/src/snap_census/runner.rs | 132 +++++++++++++++++- 5 files changed, 199 insertions(+), 8 deletions(-) diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 0be0f0281f6..fed1f73ea17 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -192,6 +192,9 @@ pub(super) fn compile_module_entry( } if std::env::var_os("PERRY_SNAP_CENSUS").is_some_and(|v| !v.is_empty()) { llmod.declare_function("js_snap_entry_body", VOID, &[]); + if entry_in_init { + llmod.declare_function("js_snap_entry_module", VOID, &[]); + } } // Issue #753: emit a no-op `__init` stub so the // dispatch site in some other module that does `await @@ -1118,6 +1121,10 @@ pub(super) fn compile_module_entry( buffer_alias_base, }; if entry_in_init { + // SNAP census: the entry module's evaluation is the entry body. + if std::env::var_os("PERRY_SNAP_CENSUS").is_some_and(|v| !v.is_empty()) { + ctx.block().call_void("js_snap_entry_module", &[]); + } ctx.block() .call_void(&format!("{}__init", module_prefix), &[]); } else { diff --git a/crates/perry-runtime/src/object/meta_accessors.rs b/crates/perry-runtime/src/object/meta_accessors.rs index c932fcbca1b..68ee5c81550 100644 --- a/crates/perry-runtime/src/object/meta_accessors.rs +++ b/crates/perry-runtime/src/object/meta_accessors.rs @@ -161,8 +161,12 @@ pub(crate) unsafe fn object_meta_ensure_for_cell( // barrier, exactly as `object_meta_ensure` does for an `ObjectHeader`. // The reloaded cell is live; consume its current generation proof. // Young arena holders need only the active incremental-mark barrier. + #[cfg(perry_gc_instruments)] + crate::snap_census::meta_install(slot as usize); *slot = meta; crate::gc::runtime_write_barrier_resolved_slot(user_ptr, slot as usize, meta as u64); + #[cfg(perry_gc_instruments)] + crate::snap_census::meta_installed(slot as usize, meta as usize); Some(meta) } @@ -216,12 +220,16 @@ pub(crate) unsafe fn object_meta_ensure(obj: MetaObj, reason: MetaReason) -> *mu // followed by an object-slot barrier, mirroring `set_object_keys_array`. // Reloaded after the allocation: promotion, if any, is reflected in the // live header. No safepoint intervenes before the resolved-slot barrier. + #[cfg(perry_gc_instruments)] + crate::snap_census::meta_install(&(*obj).meta as *const _ as usize); (*obj).meta = meta; crate::gc::runtime_write_barrier_resolved_slot( obj as usize, &(*obj).meta as *const _ as usize, meta as u64, ); + #[cfg(perry_gc_instruments)] + crate::snap_census::meta_installed(&(*obj).meta as *const _ as usize, meta as usize); meta } diff --git a/crates/perry-runtime/src/object/proto_validity.rs b/crates/perry-runtime/src/object/proto_validity.rs index 5e62cbc4ce0..30ffc92edc2 100644 --- a/crates/perry-runtime/src/object/proto_validity.rs +++ b/crates/perry-runtime/src/object/proto_validity.rs @@ -298,6 +298,11 @@ unsafe fn ensure_meta_for_mark(obj: usize, flag: u64) -> Option<*mut crate::obje if !meta.is_null() && (*meta).flags & flag != 0 { return Some(meta); } + // SNAP census: the mark (an empty record, its flag, the private shape + // lineage) classifies an object the program already holds; no program + // operation observes it (design §2.10.11). + #[cfg(perry_gc_instruments)] + let _census = crate::snap_census::representation(); let scope = crate::gc::RuntimeHandleScope::new(); let handle = scope.root_raw_mut_ptr(object); let (meta, object) = handle diff --git a/crates/perry-runtime/src/snap_census.rs b/crates/perry-runtime/src/snap_census.rs index d28648044a9..18d199b1c2f 100644 --- a/crates/perry-runtime/src/snap_census.rs +++ b/crates/perry-runtime/src/snap_census.rs @@ -104,11 +104,51 @@ pub(crate) fn dyn_code(kind: u8) { #[inline(always)] pub(crate) fn holds_collection() -> bool { #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] - return runner::on(); + return runner::holds_collection(); #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] false } +/// `object_meta_ensure` is about to store a fresh, all-zero record into the +/// owner's meta word `slot` (design §2.10.11: an empty install is +/// representation). Instrumented builds only. +#[cfg(perry_gc_instruments)] +#[inline(always)] +pub(crate) fn meta_install(slot: usize) { + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + runner::meta_install(slot); + } + #[cfg(not(all(target_os = "linux", target_arch = "x86_64")))] + let _ = slot; +} + +/// The record `meta` now hangs off the owner's meta word `slot`: its words +/// are the owner's. Instrumented builds only. +#[cfg(perry_gc_instruments)] +#[inline(always)] +pub(crate) fn meta_installed(slot: usize, meta: usize) { + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + runner::meta_installed(slot, meta); + } + #[cfg(not(all(target_os = "linux", target_arch = "x86_64")))] + let _ = (slot, meta); +} + +/// A classification mark (`ensure_meta_for_mark`) runs while this lives: +/// representation, not program state (design §2.10.11). Instrumented +/// builds only. +#[cfg(perry_gc_instruments)] +#[inline(always)] +pub(crate) fn representation() -> Bookkeeping { + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + if runner::on() && runner::representation(1) { + return Bookkeeping { active: true }; + } + Bookkeeping { active: false } +} + /// `cells` (an object's metadata record, its overflow table) now belong to /// `obj`: the cells of a transparent object are transparent too. #[inline(always)] @@ -361,6 +401,19 @@ impl Drop for RuntimeScope { /// Codegen calls this where the entry module's own body starts (after its /// eager imports), in instrumented compiles only (`PERRY_SNAP_CENSUS` set /// while compiling). The census boundary of an ESM entry. +/// The program evaluates its entry as a module (it has a worker): the +/// evaluation that starts next is the entry body (design §2.3). Emitted by +/// census compiles only, right before the entry module's `__init`. +#[cfg(perry_gc_instruments)] +#[no_mangle] +pub extern "C" fn js_snap_entry_module() { + crate::effect!(pure); + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() || runner::start_if_requested() { + runner::entry_module(); + } +} + #[cfg(perry_gc_instruments)] #[no_mangle] pub extern "C" fn js_snap_entry_body() { diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index f8362ea9ba4..b4a67e49ea1 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -63,6 +63,11 @@ pub(super) fn on() -> bool { ANCHOR.on.load(Relaxed) } +/// Collection is held until the boundary (not in the weights run). +pub(super) fn holds_collection() -> bool { + on() && !state().noprotect +} + 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 @@ -373,6 +378,9 @@ struct Init { evals: u32, /// The check run skipped this member's body parts. skipped: bool, + /// 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, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -478,6 +486,20 @@ struct State { reborn: u64, silent_stores: u64, noprotect: bool, + /// The meta rule (design §2.10.11; `PERRY_SNAP_META=0` turns it off): + /// installing an empty meta record and marking an object a prototype + /// are representation, and a meta record's words are its owner's. + meta_rule: bool, + /// The owner's meta word whose (empty) record is being stored now. + meta_slot: usize, + /// Meta installs seen; records of pre-existing owners added to rule 4's + /// pre-existing set; classification marks (representation scopes). + meta_installs: u64, + meta_owned: u64, + marks: u64, + /// `js_snap_entry_module`: the next evaluation is the entry module's own + /// (its body is the entry body, design §2.3). + entry_module_next: bool, /// Global root slots on no tracked page (the thread pointer's page, /// or not a binding: a root slot elsewhere): not watched. untracked_slots: u64, @@ -662,6 +684,12 @@ unsafe fn start(out: String) { reborn: 0, silent_stores: 0, noprotect: std::env::var("PERRY_SNAP_NOPROTECT").is_ok_and(|v| v == "1"), + meta_rule: !std::env::var("PERRY_SNAP_META").is_ok_and(|v| v == "0"), + meta_slot: 0, + meta_installs: 0, + meta_owned: 0, + marks: 0, + entry_module_next: false, untracked_slots: 0, top_mode: std::env::var("PERRY_SNAP_MODE").is_ok_and(|v| v == "top"), pre_bits: map_zeroed::(((res_hi - res_lo) / 8).div_ceil(64).max(1)), @@ -742,8 +770,11 @@ unsafe fn start(out: String) { ANCHOR.state.store(&mut *st as *mut State, Relaxed); std::mem::forget(st); let st = state(); - // Nothing moves until the boundary. - crate::gc::gc_suppress(); + // Nothing moves until the boundary. The weights run (no protection) + // collects as production does: its Ir per init includes the collections. + if !st.noprotect { + crate::gc::gc_suppress(); + } // The objects that exist now are the runtime's (init 0). crate::arena::arena_walk_objects_with_block_index(|header, _| { let size = (*(header as *const crate::gc::GcHeader)).size as usize; @@ -851,6 +882,7 @@ fn blank_init(addr: usize, parent: u32, reach: Reach) -> Init { in_d: false, faults: 0, skipped: false, + entry_module: false, } } @@ -986,7 +1018,12 @@ pub(super) fn init_enter(addr: usize) { } }; let edge = st.require_edge.take() == Some(parent_id); - let reach = if st.require_depth > 0 || edge { + let entry_module = std::mem::take(&mut st.entry_module_next) + && st.entry_init != 0 + && parent_id == st.entry_init; + let reach = if entry_module { + Reach::EntryEager + } else if st.require_depth > 0 || edge { Reach::Require } else if !parent_in_body { if parent.is_none() { @@ -1004,7 +1041,18 @@ pub(super) fn init_enter(addr: usize) { init.enter_seq = st.seq; init.open = true; let parent_dynamic = parent_id != 0 && st.inits[parent_id as usize].dynamic; - init.dynamic = reach == Reach::DynImport || parent_dynamic; + init.dynamic = !entry_module && (reach == Reach::DynImport || parent_dynamic); + if entry_module { + // The entry module's evaluation is the entry body (§2.10.7): in D, + // reached at startup in every run, never part of the import() subgraph. + init.entry_module = true; + init.in_d = true; + init.host = Some(Hit { + class: Effect::HostRead as u8, + what: "entry_body", + pcs: [0; 6], + }); + } if init.dynamic && !st.q7 { // Without write containment an init of the `import()` subgraph has // no proof of its own: it runs at the import, after the entry body. @@ -1126,6 +1174,61 @@ 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; +} + +/// `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 st = state(); + if st.meta_rule { + st.meta_slot = slot & !7; + } +} + +/// The record `meta` now hangs off the owner's meta word `slot`. What is +/// 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 st = state(); + st.meta_slot = 0; + st.meta_installs += 1; + if !st.meta_rule || !st.rule4_armed || !pre_existing(st, slot) { + return; + } + let Some(h) = object_start(st, meta) else { return }; + let Some(size) = st.sizes.get(h as u64) else { return }; + let e = h + size as usize; + let i = st.pre_sorted.partition_point(|&(a, _)| a <= h); + st.pre_sorted.insert(i, (h, e)); + st.meta_owned += 1; + let mut p = h & !(PAGE - 1); + while p < e { + flag_page(st, p, PF_PRE); + unsafe { libc::mprotect(p as *mut _, PAGE, policy(st, p)) }; + p += PAGE; + } +} + +/// A classification mark (`ensure_meta_for_mark`: the prototype or exotic +/// read receiver flag, its empty record, the private shape lineage) starts +/// (`delta` 1) or ends (-1): representation, not program state. Returns +/// whether the scope is active (the meta rule is on). +pub(super) fn representation(delta: i32) -> bool { + let st = state(); + if !st.meta_rule { + return false; + } + if delta > 0 { + st.marks += 1; + } + st.bookkeeping += delta; + true +} + pub(super) fn attached(obj: usize, cells: &[usize]) { let st = state(); if st.transparent.is_empty() { @@ -1329,6 +1432,11 @@ fn page_flags(st: &State, addr: usize) -> Option<*mut u8> { // --------------------------------------------------------------------------- fn policy(st: &State, page: usize) -> i32 { + // 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 { + return libc::PROT_READ | libc::PROT_WRITE; + } let Some(f) = page_flags(st, page) else { return libc::PROT_READ | libc::PROT_WRITE; }; @@ -1786,6 +1894,10 @@ fn note_access(st: &mut State, word: usize, write: bool, pc: usize) { st.faults_ignored += 1; return; } + if write && word == st.meta_slot { + st.faults_ignored += 1; + return; + } if st.env_words.len > 0 { if let Some(v) = st.env_words.get(word as u64) { env_word_access(st, v, word, write, pc); @@ -2207,6 +2319,11 @@ fn write_report(st: &mut State) { st.t0.elapsed().as_millis(), st.perf_fd >= 0, ); + let _ = writeln!( + f, + "#meta\trule={}\tinstalls={}\towned={}\tmarks={}", + st.meta_rule as u8, st.meta_installs, st.meta_owned, st.marks + ); let _ = writeln!( f, "#id\taddr\tparent\treach\tdynamic\tfrom_entry_body\tenter\texit\topen\tthrew\tseeded\tin_d\tir\tbirths\tbirth_bytes\tfaults\thost_count\thost_class\thost_what\thost_pcs\tconflict_rule\tconflict_word\tconflict_write\tconflict_pc\tconflict_owner\tconflict_cause\tconflict_cause_pc\tdyn_fns\tevals" @@ -2732,7 +2849,7 @@ pub(super) fn skip_body() -> bool { match st.frames.last() { Some(f) if f.kind == FrameKind::Init => { let i = &mut st.inits[f.init as usize]; - if st.skip.binary_search(&i.addr).is_ok() { + if !i.entry_module && st.skip.binary_search(&i.addr).is_ok() { i.skipped = true; return true; } @@ -2939,7 +3056,7 @@ fn write_env_report(st: &State, f: &mut std::fs::File, rank: &[u64]) { } if !st.skip.is_empty() { for (id, i) in st.inits.iter().enumerate().skip(1) { - if i.in_d && !i.skipped && i.addr != ENTRY_BODY_ADDR { + if i.in_d && !i.skipped && i.addr != ENTRY_BODY_ADDR && !i.entry_module { let _ = writeln!(f, "#checkfail\tjoined_d\t{id}\t{:x}", i.addr); } } @@ -2949,7 +3066,8 @@ fn write_env_report(st: &State, f: &mut std::fs::File, rank: &[u64]) { if let Ok(mut d) = std::fs::File::create(format!("{}.dslots", st.out)) { for (k, &slot) in st.globals_log.iter().enumerate() { let owner = st.slot_owner.get(k).copied().unwrap_or(0); - if owner != 0 && in_d(owner) { + // The entry module runs in the check run: its slots are not poisoned. + if owner != 0 && in_d(owner) && !st.inits[owner as usize].entry_module { let _ = writeln!(d, "{slot:x}\t{owner}"); } } From 05ff2cf6580a0d84a2e0fb8d8db0ad0efef2599a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 2 Nov 2026 18:13:36 +0000 Subject: [PATCH 49/84] SNAP3j: the census runs the whole prototype mark (flag, serial) as representation --- crates/perry-runtime/src/object/proto_validity.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/perry-runtime/src/object/proto_validity.rs b/crates/perry-runtime/src/object/proto_validity.rs index 30ffc92edc2..89e3aa23581 100644 --- a/crates/perry-runtime/src/object/proto_validity.rs +++ b/crates/perry-runtime/src/object/proto_validity.rs @@ -52,6 +52,11 @@ pub(crate) fn any_prototype_marked() -> bool { /// re-reading through a pointer the allocation may have moved. #[inline] pub(crate) unsafe fn mark_object_as_prototype(obj: usize) -> Option { + // SNAP census: the whole prototype mark (the record, its flag, the + // serial, the private lineage, never-move) is representation (design + // §2.10.11); the image re-applies it lazily on first use. + #[cfg(perry_gc_instruments)] + let _census = crate::snap_census::representation(); if let Some(meta) = ensure_meta_for_mark(obj, crate::object::OBJECT_META_FLAG_IS_PROTOTYPE) { ANY_PROTOTYPE_MARKED.store(true, Ordering::Relaxed); // GC_STORE_AUDIT(POINTER_FREE): scalar classification bit in the meta @@ -172,6 +177,11 @@ pub(crate) fn next_prototype_serial() -> u64 { /// As [`mark_object_as_prototype`]: allocates, and may move the owner. pub(crate) unsafe fn mark_object_as_prototype_with_serial(obj: usize, serial: u64) -> Option { debug_assert!(serial >= FIRST_DYNAMIC_PROTOTYPE_SERIAL); + // SNAP census: the whole prototype mark (the record, its flag, the + // serial, the private lineage, never-move) is representation (design + // §2.10.11); the image re-applies it lazily on first use. + #[cfg(perry_gc_instruments)] + let _census = crate::snap_census::representation(); let meta = ensure_meta_for_mark(obj, crate::object::OBJECT_META_FLAG_IS_PROTOTYPE)?; ANY_PROTOTYPE_MARKED.store(true, Ordering::Relaxed); // GC_STORE_AUDIT(POINTER_FREE): scalar classification bit. From b3bfa0e24bb7aa52fe81551db4b2a624f27b6217 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 2 Nov 2026 23:30:43 +0000 Subject: [PATCH 50/84] SNAP3k: a poisoned read in the check run reports instead of faulting The first poisoned read pushed onto an empty Vec inside the SIGSEGV handler, so it allocated in signal context and faulted again (the rc=139 foreign fault 0xdead in mi_malloc_aligned on Supabase). The capacity is now reserved; every poisoned read also goes signal-safely to .checkfail; a check run that ends before the boundary writes its report at exit; a fault after a poisoned read exits 3 with one line. --- .../perry-runtime/src/snap_census/runner.rs | 91 ++++++++++++++++++- 1 file changed, 89 insertions(+), 2 deletions(-) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index b4a67e49ea1..225e2d68d33 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -579,7 +579,14 @@ struct State { /// The check run (`PERRY_SNAP_SKIP=`): D's evaluation addresses /// (sorted), whose bodies are skipped. skip: Vec, + /// The check run's poisoned-binding reads. Pushed in signal context: + /// the capacity is reserved up front (a push that grows would allocate + /// inside the fault handler). check_fail: Vec<(u32, usize, usize, bool)>, + /// The check run writes each poisoned read to `.checkfail` as it + /// happens (signal-safe), so a run that never reaches the boundary + /// still leaves its report. + checkfail_fd: i32, /// The check run's digest leaves out the words the census pass's D wrote /// (`PERRY_SNAP_WD`, by logical name). wd_names: std::collections::HashSet<(u64, usize)>, @@ -741,7 +748,8 @@ unsafe fn start(out: String) { v.sort_unstable(); v }, - check_fail: Vec::new(), + check_fail: Vec::with_capacity(CHECK_FAIL_MAX), + checkfail_fd: -1, wd_names: std::env::var("PERRY_SNAP_WD") .ok() .and_then(|p| std::fs::read_to_string(p).ok()) @@ -803,6 +811,17 @@ unsafe fn start(out: String) { mark_tracked(slot, slot + 8); } walk_realm_cells(st); + // The check run's report file, opened before any page closes: this + // allocates, and the handlers are not installed yet. + if !st.skip.is_empty() { + let path = format!("{}.checkfail\0", st.out); + st.checkfail_fd = libc::open( + path.as_ptr().cast(), + libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC | libc::O_CLOEXEC, + 0o644, + ); + libc::atexit(check_exit); + } // The check run: the global slots the census pass's D registered are // poisoned (any access is a check failure). if let Ok(p) = std::env::var("PERRY_SNAP_POISON") { @@ -1682,6 +1701,15 @@ unsafe fn diag(words: &[u64]) { } unsafe fn chain(prev: *const libc::sigaction, sig: i32) { + // The check run after a poisoned read: the program ran on with a skipped + // member's binding, so a later fault is that read's consequence. The + // `#checkfail` lines are in `.checkfail` already; end with them. + let st_ptr = ANCHOR.state.load(Relaxed); + if sig == libc::SIGSEGV && !st_ptr.is_null() && !(*st_ptr).check_fail.is_empty() { + let m = b"snap census: check run fault after a poisoned read (see .checkfail)\n"; + libc::write(2, m.as_ptr().cast(), m.len()); + libc::_exit(3); + } // Not ours: restore the previous disposition; the instruction faults // again and takes it. libc::sigaction(sig, prev, std::ptr::null_mut()); @@ -2658,6 +2686,64 @@ const ENV_BLOCK: u32 = u32::MAX; /// `env_words` value of a module-global slot a member of D registered in the /// census pass (the check run poisons it). const POISON: u32 = u32::MAX - 1; +/// At most this many poisoned reads are recorded (`check_fail`). +const CHECK_FAIL_MAX: usize = 4096; + +/// `#checkfail\tpoison\t\t\t\t\t` to +/// `.checkfail` (signal context: no allocation). The report at the +/// boundary repeats the line with the slot's logical name. +fn checkfail_line(st: &State, init: u32, word: usize, pc: usize, write: bool) { + if st.checkfail_fd < 0 { + return; + } + let mut buf = [0u8; 160]; + let mut n = 0; + let mut put = |b: &[u8], n: &mut usize| { + for &c in b { + buf[*n] = c; + *n += 1; + } + }; + put(b"#checkfail\tpoison\t", &mut n); + let mut dec = [0u8; 10]; + let mut k = dec.len(); + let mut x = init; + loop { + k -= 1; + dec[k] = b'0' + (x % 10) as u8; + x /= 10; + if x == 0 { + break; + } + } + put(&dec[k..], &mut n); + let addr = st.inits.get(init as usize).map_or(0, |i| i.addr); + for w in [addr, word, pc] { + put(b"\t", &mut n); + let mut hex = [0u8; 16]; + let mut k = hex.len(); + let mut x = w; + loop { + k -= 1; + hex[k] = b"0123456789abcdef"[x & 15]; + x >>= 4; + if x == 0 { + break; + } + } + put(&hex[k..], &mut n); + } + put(if write { b"\t1\n" } else { b"\t0\n" }, &mut n); + unsafe { libc::write(st.checkfail_fd, buf.as_ptr().cast(), n) }; +} + +/// The check run ends before the boundary (an exception, `process.exit`): +/// write the report there, so the run always ends with one. +extern "C" fn check_exit() { + if state().boundary_seq == 0 { + boundary("exit"); + } +} struct EnvObs { init: u32, @@ -2797,8 +2883,9 @@ pub(super) fn env_has(name: &str, present: bool) { fn env_word_access(st: &mut State, v: u32, word: usize, write: bool, pc: usize) { let top = top_init(st); if v == POISON { - if st.check_fail.len() < 4096 { + if st.check_fail.len() < CHECK_FAIL_MAX { st.check_fail.push((top, word, pc, write)); + checkfail_line(st, top, word, pc, write); } return; } From 73bea418ef30d16e309b11093757f9a600d1db86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 2 Nov 2026 23:52:13 +0000 Subject: [PATCH 51/84] SNAP3l: key-granular locations for globalThis in the census The realm global's shape word is no census location by itself: a keyed operation on the global (try_data_lookup_key, perry_probe_get_binding, js_object_set_field_by_name) names its key through the census-only snap_census::global_key hook. A keyed read of X reads key X; a keyed write of K writes key K and the key set (two adds conflict: key order); an unkeyed access stands for every key. PERRY_SNAP_GKEY=0 turns it off; the report gains #gkey and #gkeyconflict lines. Design SNAP_DESIGN section 2.10.13. Compiles in both configs (cargo check, instr and production cfg); untested: no build, fixture or census run yet. --- .../src/object/field_set_by_name.rs | 6 + .../perry-runtime/src/object/global_this.rs | 2 + .../src/object/global_this/fetch_globals.rs | 7 + .../perry-runtime/src/object/layout_probe.rs | 8 ++ crates/perry-runtime/src/object/native_get.rs | 6 + crates/perry-runtime/src/snap_census.rs | 38 +++++ .../perry-runtime/src/snap_census/runner.rs | 135 +++++++++++++++++- 7 files changed, 201 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/object/field_set_by_name.rs b/crates/perry-runtime/src/object/field_set_by_name.rs index ef130d6ea06..0df15ce347e 100644 --- a/crates/perry-runtime/src/object/field_set_by_name.rs +++ b/crates/perry-runtime/src/object/field_set_by_name.rs @@ -48,6 +48,12 @@ pub extern "C" fn js_object_set_field_by_name( key: *const crate::StringHeader, value: f64, ) { + // Census builds: a store on the realm global names its key (§2.10.13). + #[cfg(perry_gc_instruments)] + let _gkey = crate::snap_census::global_key( + (obj as u64 & crate::value::POINTER_MASK) as usize, + || (!key.is_null()).then(|| crate::regex::string_as_bytes(key)), + ); #[cfg(perry_contract_checks)] crate::contracts::generic_store(obj, key, value); // Case 2c: a family object's late key and a key outside its layout diff --git a/crates/perry-runtime/src/object/global_this.rs b/crates/perry-runtime/src/object/global_this.rs index 76d280ccdd6..c338878491b 100644 --- a/crates/perry-runtime/src/object/global_this.rs +++ b/crates/perry-runtime/src/object/global_this.rs @@ -111,6 +111,8 @@ pub(crate) use ctor_thunks::{ #[cfg(test)] pub(crate) use fetch_globals::collect_before_global_this_alloc_for_test; pub(crate) use fetch_globals::init_fetch_backed_receiver; +#[cfg(perry_gc_instruments)] +pub(crate) use fetch_globals::thread_realm_global_addr; #[cfg(feature = "temporal")] pub(crate) use fetch_globals::temporal_subclass_super; pub(crate) use fetch_globals::{ diff --git a/crates/perry-runtime/src/object/global_this/fetch_globals.rs b/crates/perry-runtime/src/object/global_this/fetch_globals.rs index 293cacbf5dc..2c0aaa9fc72 100644 --- a/crates/perry-runtime/src/object/global_this/fetch_globals.rs +++ b/crates/perry-runtime/src/object/global_this/fetch_globals.rs @@ -86,6 +86,13 @@ pub(crate) fn global_this_is_materialized() -> bool { THREAD_GLOBAL_THIS.with(|c| c.get()) != 0 } +/// This thread's realm global's address, 0 before it exists: the census's +/// key-granular container (design §2.10.13). Census builds only. +#[cfg(perry_gc_instruments)] +pub(crate) fn thread_realm_global_addr() -> usize { + (THREAD_GLOBAL_THIS.with(|c| c.get()) & crate::value::POINTER_MASK) as usize +} + /// Whether `obj` is THIS thread's realm global, as opposed to the global of a /// `vm` context or an eval realm that `populate_global_this_builtins` also /// fills (each of those gets its own intrinsics). diff --git a/crates/perry-runtime/src/object/layout_probe.rs b/crates/perry-runtime/src/object/layout_probe.rs index e7be3afac06..69c12e70bf2 100644 --- a/crates/perry-runtime/src/object/layout_probe.rs +++ b/crates/perry-runtime/src/object/layout_probe.rs @@ -698,6 +698,14 @@ pub unsafe extern "C" fn perry_probe_get_binding( key_bits: u64, hash: u64, ) -> f64 { + // Census builds: a global binding's read names its key (§2.10.13). + #[cfg(perry_gc_instruments)] + let mut scratch = [0; crate::value::SHORT_STRING_MAX_LEN]; + #[cfg(perry_gc_instruments)] + let _gkey = crate::snap_census::global_key( + (obj_biased as usize).wrapping_add(perry_abi::RECEIVER_HANDLE_FLOOR), + || crate::string::js_string_key_bytes(crate::JSValue::from_bits(key_bits), &mut scratch), + ); probe_get::(dir, obj_biased, key_bits, hash) } diff --git a/crates/perry-runtime/src/object/native_get.rs b/crates/perry-runtime/src/object/native_get.rs index 4de2d2afe24..97589f48306 100644 --- a/crates/perry-runtime/src/object/native_get.rs +++ b/crates/perry-runtime/src/object/native_get.rs @@ -138,6 +138,12 @@ unsafe fn try_data_lookup_key( if FORCE_SLOW.with(|value| value.get()) { return None; } + // Census builds: a lookup on the realm global names its key (§2.10.13). + #[cfg(perry_gc_instruments)] + let _gkey = crate::snap_census::global_key( + if receiver.is_pointer() { receiver.as_pointer::() as usize } else { 0 }, + || Some(key), + ); if !receiver.is_pointer() || key.first() == Some(&b'#') || key == b"constructor" { return None; } diff --git a/crates/perry-runtime/src/snap_census.rs b/crates/perry-runtime/src/snap_census.rs index 18d199b1c2f..e58fc70146b 100644 --- a/crates/perry-runtime/src/snap_census.rs +++ b/crates/perry-runtime/src/snap_census.rs @@ -149,6 +149,44 @@ pub(crate) fn representation() -> Bookkeeping { Bookkeeping { active: false } } +/// A keyed operation on `obj` (a global lookup of key X, the add or store +/// of key K) names `key` for the scope's extent: on the realm global, the +/// census takes the global's shape word as the location of that key +/// (design §2.10.13); any other object is untouched. Census builds only. +#[cfg(perry_gc_instruments)] +#[inline(always)] +pub(crate) fn global_key<'a>(obj: usize, key: impl FnOnce() -> Option<&'a [u8]>) -> GlobalKey { + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + return GlobalKey { + prev: runner::global_key(obj, key), + }; + } + let _ = (obj, key); + GlobalKey { prev: None } +} + +#[cfg(perry_gc_instruments)] +pub(crate) struct GlobalKey { + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + prev: Option<(u64, [u8; runner::GKEY_NAME], u8)>, + #[cfg(not(all(target_os = "linux", target_arch = "x86_64")))] + prev: Option<()>, +} + +#[cfg(perry_gc_instruments)] +impl Drop for GlobalKey { + #[inline(always)] + fn drop(&mut self) { + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + if let Some(prev) = self.prev.take() { + runner::global_key_end(prev); + } + #[cfg(not(all(target_os = "linux", target_arch = "x86_64")))] + let _ = self.prev; + } +} + /// `cells` (an object's metadata record, its overflow table) now belong to /// `obj`: the cells of a transparent object are transparent too. #[inline(always)] diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 225e2d68d33..f4096979c81 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -97,6 +97,30 @@ unsafe fn map_zeroed(count: usize) -> *mut T { /// A removed [`WordSet`] slot (no word is all ones). const TOMBSTONE: u64 = u64::MAX; +/// Location ids of the realm global's keys (§2.10.13). Real words are user +/// addresses below 2^47; these ids have the top bit set. `GKEY_ANY`: the +/// global's key set (every add writes it, an unkeyed read reads it: key +/// order and enumeration); `GKEY_UNKEYED`: an unkeyed write (it may change +/// any key: every keyed read reads it); `gkey_loc`: key K. +const GKEY_ANY: u64 = 0x8000_0000_0000_0001; +const GKEY_UNKEYED: u64 = 0x8000_0000_0000_0002; +pub(super) const GKEY_NAME: usize = 40; +const GKEY_CONFLICTS_MAX: usize = 1 << 16; + +fn gkey_loc(key: &[u8]) -> u64 { + // FNV-1a over the key's bytes; the top two bits tag a key id. + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for &b in key { + h = (h ^ b as u64).wrapping_mul(0x0100_0000_01b3); + } + let id = (h >> 2) | 0xC000_0000_0000_0000; + if id == TOMBSTONE { + id - 1 + } else { + id + } +} + /// Open-addressing set of nonzero words. struct WordSet { slots: *mut u64, @@ -495,6 +519,23 @@ struct State { /// Meta installs seen; records of pre-existing owners added to rule 4's /// pre-existing set; classification marks (representation scopes). meta_installs: u64, + /// Key-granular globalThis (design §2.10.13; `PERRY_SNAP_GKEY=0` turns + /// it off): the realm global's shape word is no location by itself; a + /// keyed operation on the global (a lookup of key X, the add or store of + /// key K) names the key it accesses, and an unkeyed access stands for + /// every key. `gshape`: the global's shape word (0 until it exists). + gkey_rule: bool, + gshape: usize, + /// The key the running keyed operation on the global names (a location + /// id, 0: none) and its name for the report. + gkey: u64, + gkey_name: [u8; GKEY_NAME], + gkey_len: u8, + /// Shape-word accesses: keyed reads, keyed writes, unkeyed reads, + /// unkeyed writes. + gkey_counts: [u64; 4], + /// The key names of keyed conflicts, per init (filled at the conflict). + gkey_conflicts: Vec<(u32, u64, [u8; GKEY_NAME], u8)>, meta_owned: u64, marks: u64, /// `js_snap_entry_module`: the next evaluation is the entry module's own @@ -694,6 +735,13 @@ unsafe fn start(out: String) { meta_rule: !std::env::var("PERRY_SNAP_META").is_ok_and(|v| v == "0"), meta_slot: 0, meta_installs: 0, + gkey_rule: !std::env::var("PERRY_SNAP_GKEY").is_ok_and(|v| v == "0"), + gshape: 0, + gkey: 0, + gkey_name: [0; GKEY_NAME], + gkey_len: 0, + gkey_counts: [0; 4], + gkey_conflicts: Vec::with_capacity(GKEY_CONFLICTS_MAX), meta_owned: 0, marks: 0, entry_module_next: false, @@ -1236,6 +1284,44 @@ pub(super) fn meta_installed(slot: usize, meta: usize) { /// read receiver flag, its empty record, the private shape lineage) starts /// (`delta` 1) or ends (-1): representation, not program state. Returns /// whether the scope is active (the meta rule is on). +/// A keyed operation on `obj` names `key` (§2.10.13): when `obj` is the +/// realm global, the census takes accesses of its shape word as accesses of +/// `key` until [`global_key_end`]. Returns the previous key to restore. +pub(super) fn global_key<'a>( + obj: usize, + key: impl FnOnce() -> Option<&'a [u8]>, +) -> Option<(u64, [u8; GKEY_NAME], u8)> { + let st = state(); + if !st.gkey_rule { + return None; + } + if st.gshape == 0 { + st.gshape = crate::object::global_this::thread_realm_global_addr(); + } + if obj == 0 || obj != st.gshape { + return None; + } + let prev = (st.gkey, st.gkey_name, st.gkey_len); + // The key's bytes may sit on a tracked page: reading them is census + // bookkeeping, not an access of the running init. + st.bookkeeping += 1; + let named = key().map(|key| { + st.gkey = gkey_loc(key); + let n = key.len().min(GKEY_NAME); + st.gkey_name[..n].copy_from_slice(&key[..n]); + st.gkey_len = n as u8; + }); + st.bookkeeping -= 1; + // A key the operation cannot name leaves the access unkeyed. + named.map(|()| prev) +} + +/// The keyed operation [`global_key`] opened is over. +pub(super) fn global_key_end(prev: (u64, [u8; GKEY_NAME], u8)) { + let st = state(); + (st.gkey, st.gkey_name, st.gkey_len) = prev; +} + pub(super) fn representation(delta: i32) -> bool { let st = state(); if !st.meta_rule { @@ -1989,7 +2075,36 @@ fn note_access(st: &mut State, word: usize, write: bool, pc: usize) { } let top = st.frames.last().map_or(0, |f| f.init) as usize; st.inits[top].faults += 1; - let w = word as u64; + if st.gkey_rule && st.gshape == 0 { + st.gshape = crate::object::global_this::thread_realm_global_addr(); + } + if st.gkey_rule && word == st.gshape && st.gshape != 0 { + // §2.10.13: the realm global's shape word is the global's key set. + let key = st.gkey; + let (kind, locs): (usize, [(u64, bool); 2]) = match (key != 0, write) { + (true, false) => (0, [(key, false), (GKEY_UNKEYED, false)]), + (true, true) => (1, [(key, true), (GKEY_ANY, true)]), + (false, false) => (2, [(GKEY_ANY, false), (GKEY_ANY, false)]), + (false, true) => (3, [(GKEY_ANY, true), (GKEY_UNKEYED, true)]), + }; + st.gkey_counts[kind] += 1; + let had = st.inits[top].conflict.is_some(); + for (loc, w) in locs { + note_loc(st, top, word, loc, w, pc); + } + if !had && key != 0 && st.inits[top].conflict.is_some() && st.gkey_conflicts.len() < GKEY_CONFLICTS_MAX { + // Capacity is reserved: no allocation in the fault handler. + let e = (top as u32, key, st.gkey_name, st.gkey_len); + st.gkey_conflicts.push(e); + } + return; + } + note_loc(st, top, word, word as u64, write, pc); +} + +/// One access of location `w` (a word, or a key id of the realm global whose +/// shape word is `word`) by init `top`: rules 2-4. +fn note_loc(st: &mut State, top: usize, word: usize, w: u64, write: bool, pc: usize) { if st.top_mode { if st.d_mode || top == 0 || !top_pre_existing(st, word) { return; @@ -2352,6 +2467,24 @@ fn write_report(st: &mut State) { "#meta\trule={}\tinstalls={}\towned={}\tmarks={}", st.meta_rule as u8, st.meta_installs, st.meta_owned, st.marks ); + let _ = writeln!( + f, + "#gkey\trule={}\tglobal={:x}\tkeyed_reads={}\tkeyed_writes={}\tunkeyed_reads={}\tunkeyed_writes={}\tkeyed_conflicts={}", + st.gkey_rule as u8, + st.gshape, + st.gkey_counts[0], + st.gkey_counts[1], + st.gkey_counts[2], + st.gkey_counts[3], + st.gkey_conflicts.len() + ); + for (init, key, name, len) in st.gkey_conflicts.iter() { + let _ = writeln!( + f, + "#gkeyconflict\t{init}\t{key:x}\t{}", + tsv_text(&String::from_utf8_lossy(&name[..*len as usize])) + ); + } let _ = writeln!( f, "#id\taddr\tparent\treach\tdynamic\tfrom_entry_body\tenter\texit\topen\tthrew\tseeded\tin_d\tir\tbirths\tbirth_bytes\tfaults\thost_count\thost_class\thost_what\thost_pcs\tconflict_rule\tconflict_word\tconflict_write\tconflict_pc\tconflict_owner\tconflict_cause\tconflict_cause_pc\tdyn_fns\tevals" From f67022266fbe21c2f167c2445c6510e8226da455 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 3 Nov 2026 00:30:40 +0000 Subject: [PATCH 52/84] SNAP3m: census TLS block is whole pages (no untracked module slots) One page-aligned TLS byte (census builds only) raises the executable PT_TLS alignment to a page. The first and last pages of the static TLS block were shared with libc TLS and the TCB, never protected, so the module slots on them were untracked (fastify 276 of 3,520, Supabase 260 of 44,914): rules 2-4 and the check run poison never saw them. The report adds #tls whole=. --- .../perry-runtime/src/snap_census/runner.rs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index f4096979c81..8136d653424 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -544,6 +544,9 @@ struct State { /// Global root slots on no tracked page (the thread pointer's page, /// or not a binding: a root slot elsewhere): not watched. untracked_slots: u64, + /// The executable's static TLS block is whole pages (`TLS_PAGE`): every + /// module slot in it can be protected. + tls_whole: bool, /// `PERRY_SNAP_MODE=top`: every member of D may read and write every /// location that exists when it starts (a ⊤ summary). Members run /// untracked; any other init that touches such a location joins D. @@ -689,12 +692,16 @@ unsafe fn start(out: String) { // The page holding the thread pointer is never protected: the kernel // writes the thread's rseq area (in the TCB, right above it) on every // return to user space, and a protected page there kills the thread. + // `TLS_PAGE` makes the block whole pages (fs page-aligned, size a page + // multiple), so no module slot shares a page with libc's TLS or the TCB. + std::hint::black_box(std::ptr::addr_of!(TLS_PAGE)); let (tls_lo, tls_hi) = if fs != 0 && tls_size != 0 { // Whole pages only: the page below the block holds libc static TLS. ((fs - tls_size + PAGE - 1) & !(PAGE - 1), fs & !(PAGE - 1)) } else { (fs, fs) }; + let tls_whole = tls_size == 0 || (tls_lo == fs - tls_size && tls_hi == fs); let mut st = Box::new(State { out, inits: Vec::with_capacity(4096), @@ -746,6 +753,7 @@ unsafe fn start(out: String) { marks: 0, entry_module_next: false, untracked_slots: 0, + tls_whole, top_mode: std::env::var("PERRY_SNAP_MODE").is_ok_and(|v| v == "top"), pre_bits: map_zeroed::(((res_hi - res_lo) / 8).div_ceil(64).max(1)), pre_upto: 0, @@ -989,6 +997,17 @@ fn writable_image_range(inside: usize) -> (usize, usize) { } /// `PT_TLS` memsz of the executable, rounded up to its alignment. +/// One page-aligned TLS byte (census builds only): it raises the executable's +/// `PT_TLS` alignment to a page, so the static TLS block `[fs - size, fs)` +/// is whole pages. Without it the block's first page is shared with libc's +/// static TLS and its last with the TCB (rseq), both never protected, and the +/// module slots on them were untracked: fastify 276 of 3,520, Supabase 260 of +/// 44,914, the poison fixture's one binding (TLS offset 0). +#[repr(C, align(4096))] +struct TlsPage(u8); +#[thread_local] +static TLS_PAGE: TlsPage = TlsPage(0); + fn executable_tls_size() -> usize { unsafe extern "C" fn each( info: *mut libc::dl_phdr_info, @@ -2781,6 +2800,7 @@ fn write_report(st: &mut State) { let _ = writeln!(f, "#transparent\t{}\t{}\t{}", st.transparent.len(), st.transp.len, st.transparent_hits); let _ = writeln!(f, "#foreign_thread_faults\t{}", st.foreign_faults); let _ = writeln!(f, "#untracked_slots\t{}\tof\t{}", st.untracked_slots, st.globals.len); + let _ = writeln!(f, "#tls\twhole={}", st.tls_whole as u8); for (what, pcs) in &st.realm_hosts { let pcs: Vec = pcs.iter().map(|p| format!("{p:x}")).collect(); let _ = writeln!(f, "#realm_host\t{what}\t{}", pcs.join(",")); From 76185f407b7181f0b9162bfff530a61d1fd0b50c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 3 Nov 2026 01:07:25 +0000 Subject: [PATCH 53/84] SNAP3n: program TLS slots on pages of their own in the census Census compiles link two anchor objects around the program objects: a page-aligned byte in .tdata and one in .tbss before the first program object and after the last one. The linker lays TLS input sections out in command-line order, so the program's module-global TLS slots fill whole pages between the anchors and the runtime's thread-locals lie outside. main passes the four anchor addresses to the runner (js_snap_tls_program), which protects only those pages: the handler path may touch runtime TLS freely (the SIGTRAP of SNAP3m: a tls_hot cache on a protected page faulted inside the handler path). Replaces the page-aligned TLS byte of the previous commit. The report line #tls gives whole= and the two ranges. --- crates/perry-codegen/src/codegen/entry.rs | 14 ++ crates/perry-codegen/src/stubs.rs | 32 +++++ crates/perry-runtime/src/snap_census.rs | 19 +++ .../perry-runtime/src/snap_census/runner.rs | 121 ++++++++---------- .../src/commands/compile/run_pipeline.rs | 24 ++++ 5 files changed, 145 insertions(+), 65 deletions(-) diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index fed1f73ea17..6dc9de30d4e 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -192,6 +192,10 @@ pub(super) fn compile_module_entry( } if std::env::var_os("PERRY_SNAP_CENSUS").is_some_and(|v| !v.is_empty()) { llmod.declare_function("js_snap_entry_body", VOID, &[]); + llmod.declare_function("js_snap_tls_program", VOID, &[PTR, PTR, PTR, PTR]); + for anchor in crate::stubs::SNAP_TLS_ANCHORS { + llmod.add_external_tls_global(anchor, "i8", "localexec"); + } if entry_in_init { llmod.declare_function("js_snap_entry_module", VOID, &[]); } @@ -463,6 +467,16 @@ pub(super) fn compile_module_entry( let _ = main.create_block("entry"); { let blk = main.block_mut(0).unwrap(); + // SNAP census: the program's TLS slots lie between the anchors the + // driver links before and after the program objects; the runner + // protects those pages only (never runtime thread-locals). + if std::env::var_os("PERRY_SNAP_CENSUS").is_some_and(|v| !v.is_empty()) { + let a = crate::stubs::SNAP_TLS_ANCHORS.map(|n| format!("@{n}")); + blk.call_void( + "js_snap_tls_program", + &[(PTR, &a[0]), (PTR, &a[1]), (PTR, &a[2]), (PTR, &a[3])], + ); + } if let Some((const_name, byte_len)) = process_entry_path.as_ref() { let path_ptr = format!("@{}", const_name); let len_str = byte_len.to_string(); diff --git a/crates/perry-codegen/src/stubs.rs b/crates/perry-codegen/src/stubs.rs index 4907715e9e1..2ccb8709b09 100644 --- a/crates/perry-codegen/src/stubs.rs +++ b/crates/perry-codegen/src/stubs.rs @@ -256,6 +256,38 @@ pub fn generate_feature_installer_object( compile_ll_to_object(&ll, triple.as_deref()) } +/// SNAP census TLS anchors (census compiles only, `PERRY_SNAP_CENSUS`): data +/// begin, data end, bss begin, bss end. The driver links the begin object +/// first and the end object after every other compiled object, so the +/// linker, which lays `.tdata` and `.tbss` input sections out in command-line +/// order, puts the program's TLS slots between them and the runtime archive's +/// thread-locals after them. Each anchor is page-aligned, so the program's +/// slots fill whole pages that hold nothing else. +pub const SNAP_TLS_ANCHORS: [&str; 4] = [ + "__perry_snap_tls_data_begin", + "__perry_snap_tls_data_end", + "__perry_snap_tls_bss_begin", + "__perry_snap_tls_bss_end", +]; + +/// The object holding the begin (`end == false`) or end anchors of +/// [`SNAP_TLS_ANCHORS`]: one page-aligned byte in `.tdata` (initialized) and +/// one in `.tbss` (zero). +pub fn generate_snap_tls_anchor_object(end: bool, target: Option<&str>) -> Result> { + let (data, bss) = if end { + (SNAP_TLS_ANCHORS[1], SNAP_TLS_ANCHORS[3]) + } else { + (SNAP_TLS_ANCHORS[0], SNAP_TLS_ANCHORS[2]) + }; + let ll = format!( + "; Perry SNAP census TLS anchors, generated by perry-codegen::stubs\n\n\ + @{data} = thread_local global i8 1, align 4096\n\ + @{bss} = thread_local global i8 0, align 4096\n" + ); + let triple = target.and_then(crate::resolve_target_triple); + compile_ll_to_object(&ll, triple.as_deref()) +} + /// The LLVM IR of the program's static shape seed unit (design step 4, WRT /// step 2): the program's literal seeds as data. For each seed: its layout /// request, its packed key names, and, on targets whose atoms are image diff --git a/crates/perry-runtime/src/snap_census.rs b/crates/perry-runtime/src/snap_census.rs index e58fc70146b..32be29f27bd 100644 --- a/crates/perry-runtime/src/snap_census.rs +++ b/crates/perry-runtime/src/snap_census.rs @@ -452,6 +452,25 @@ pub extern "C" fn js_snap_entry_module() { } } +/// The program's TLS slot ranges: census compiles call this first in `main` +/// with the addresses of the four page-aligned anchors the driver links +/// before and after the program objects (`.tdata` and `.tbss` each). Only +/// these pages are protected; runtime thread-locals stay outside them. +#[cfg(perry_gc_instruments)] +#[no_mangle] +pub extern "C" fn js_snap_tls_program( + data_begin: *const u8, + data_end: *const u8, + bss_begin: *const u8, + bss_end: *const u8, +) { + crate::effect!(pure); + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + runner::tls_program(data_begin as usize, data_end as usize, bss_begin as usize, bss_end as usize); + #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] + let _ = (data_begin, data_end, bss_begin, bss_end); +} + #[cfg(perry_gc_instruments)] #[no_mangle] pub extern "C" fn js_snap_entry_body() { diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 8136d653424..05cc262b290 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -36,7 +36,7 @@ //! Linux x86_64 only (the reservation, `REG_ERR`, the trap flag). use super::Effect; -use std::sync::atomic::{AtomicBool, AtomicPtr, AtomicU8, Ordering::Relaxed}; +use std::sync::atomic::{AtomicBool, AtomicPtr, AtomicU8, AtomicUsize, Ordering::Relaxed}; const PAGE: usize = 4096; const MAX_INITS: usize = 1 << 16; @@ -48,16 +48,31 @@ struct Anchor { on: AtomicBool, started: AtomicU8, state: AtomicPtr, - _pad: [u8; PAGE - 16], + /// The program's TLS slot ranges (`js_snap_tls_program`): data begin, + /// data end, bss begin, bss end. + tls: [AtomicUsize; 4], + _pad: [u8; PAGE - 48], } static ANCHOR: Anchor = Anchor { on: AtomicBool::new(false), started: AtomicU8::new(0), state: AtomicPtr::new(std::ptr::null_mut()), - _pad: [0; PAGE - 16], + tls: [AtomicUsize::new(0), AtomicUsize::new(0), AtomicUsize::new(0), AtomicUsize::new(0)], + _pad: [0; PAGE - 48], }; +/// Census compiles call this first in `main` with the addresses of the four +/// page-aligned anchors the driver links around the program objects +/// (`__perry_snap_tls_{data,bss}_{begin,end}`): the program's module-global +/// TLS slots lie in `[data_begin, data_end)` and `[bss_begin, bss_end)`, on +/// pages of their own. Runtime and libc thread-locals are outside both. +pub(super) fn tls_program(data_lo: usize, data_hi: usize, bss_lo: usize, bss_hi: usize) { + for (i, v) in [data_lo, data_hi, bss_lo, bss_hi].into_iter().enumerate() { + ANCHOR.tls[i].store(v, Relaxed); + } +} + #[inline(always)] pub(super) fn on() -> bool { ANCHOR.on.load(Relaxed) @@ -541,11 +556,10 @@ struct State { /// `js_snap_entry_module`: the next evaluation is the entry module's own /// (its body is the entry body, design §2.3). entry_module_next: bool, - /// Global root slots on no tracked page (the thread pointer's page, + /// Global root slots on no tracked page (a TLS slot outside the anchors, /// or not a binding: a root slot elsewhere): not watched. untracked_slots: u64, - /// The executable's static TLS block is whole pages (`TLS_PAGE`): every - /// module slot in it can be protected. + /// The program's TLS slots are anchored on whole pages of their own. tls_whole: bool, /// `PERRY_SNAP_MODE=top`: every member of D may read and write every /// location that exists when it starts (a ⊤ summary). Members run @@ -564,9 +578,13 @@ struct State { regions: Vec<(usize, usize)>, heap: PageMap, bin: PageMap, - /// The main thread's static TLS block: module globals live there in a - /// program with a Worker (per-thread init flags and bindings, #10399). + /// The main thread's program TLS slots (initialized data): module + /// globals live there in a program with a Worker (per-thread init flags + /// and bindings, #10399). Only the anchored range (`tls_program`). tls: PageMap, + /// The same for the program's zero-initialized TLS slots. + tlsb: PageMap, + tls_ranges: [usize; 4], /// Pages opened for the current instruction (the handler's own state is /// never in TLS: a TLS page may be protected). pending: [usize; 8], @@ -684,24 +702,18 @@ unsafe fn start(out: String) { let (res_lo, res_hi) = crate::arena::snap_reservation_bounds(); let realm = crate::value::addr_class::realm::bounds(); let (bin_lo, bin_hi) = writable_image_range(realm.0); - let mut fs: usize = 0; - libc::syscall(libc::SYS_arch_prctl, 0x1003 /* ARCH_GET_FS */, &mut fs as *mut usize); - // The executable's static TLS block sits right below the thread pointer - // (x86_64 variant II): [fs - memsz rounded to its alignment, fs). - let tls_size = executable_tls_size(); - // The page holding the thread pointer is never protected: the kernel - // writes the thread's rseq area (in the TCB, right above it) on every - // return to user space, and a protected page there kills the thread. - // `TLS_PAGE` makes the block whole pages (fs page-aligned, size a page - // multiple), so no module slot shares a page with libc's TLS or the TCB. - std::hint::black_box(std::ptr::addr_of!(TLS_PAGE)); - let (tls_lo, tls_hi) = if fs != 0 && tls_size != 0 { - // Whole pages only: the page below the block holds libc static TLS. - ((fs - tls_size + PAGE - 1) & !(PAGE - 1), fs & !(PAGE - 1)) - } else { - (fs, fs) - }; - let tls_whole = tls_size == 0 || (tls_lo == fs - tls_size && tls_hi == fs); + // The program's TLS slots: only the anchored ranges, whole pages that hold + // program slots and nothing else (runtime thread-locals, libc's TLS, the + // TCB and its rseq area are outside them, so the handler path may touch + // them freely). Without the anchors (not a census compile) no TLS page is + // tracked and every TLS slot counts as untracked. + let t: [usize; 4] = std::array::from_fn(|i| ANCHOR.tls[i].load(Relaxed)); + let tls_whole = t[0] != 0 + && t.iter().all(|&a| a % PAGE == 0) + && t[0] <= t[1] + && t[2] <= t[3]; + let (tls_lo, tls_hi, tlsb_lo, tlsb_hi) = + if tls_whole { (t[0], t[1], t[2], t[3]) } else { (0, 0, 0, 0) }; let mut st = Box::new(State { out, inits: Vec::with_capacity(4096), @@ -767,6 +779,8 @@ unsafe fn start(out: String) { heap: PageMap::new(res_lo, res_hi), bin: PageMap::new(bin_lo, bin_hi), tls: PageMap::new(tls_lo, tls_hi), + tlsb: PageMap::new(tlsb_lo, tlsb_hi), + tls_ranges: t, pending: [0; 8], stores: [(0, 0, 0); 4], rset_word: 0, @@ -996,39 +1010,6 @@ fn writable_image_range(inside: usize) -> (usize, usize) { (lo, hi) } -/// `PT_TLS` memsz of the executable, rounded up to its alignment. -/// One page-aligned TLS byte (census builds only): it raises the executable's -/// `PT_TLS` alignment to a page, so the static TLS block `[fs - size, fs)` -/// is whole pages. Without it the block's first page is shared with libc's -/// static TLS and its last with the TCB (rseq), both never protected, and the -/// module slots on them were untracked: fastify 276 of 3,520, Supabase 260 of -/// 44,914, the poison fixture's one binding (TLS offset 0). -#[repr(C, align(4096))] -struct TlsPage(u8); -#[thread_local] -static TLS_PAGE: TlsPage = TlsPage(0); - -fn executable_tls_size() -> usize { - unsafe extern "C" fn each( - info: *mut libc::dl_phdr_info, - _size: libc::size_t, - data: *mut libc::c_void, - ) -> libc::c_int { - 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_TLS { - let align = (ph.p_align as usize).max(1); - *(data as *mut usize) = (ph.p_memsz as usize).div_ceil(align) * align; - } - } - 1 // the executable is the first object; stop - } - let mut size = 0usize; - unsafe { libc::dl_iterate_phdr(Some(each), (&mut size as *mut usize).cast()) }; - size -} - fn open_instruction_counter() -> i32 { // perf_event_attr, version 0 layout (64 bytes). let mut attr = [0u64; 8]; @@ -1548,7 +1529,12 @@ fn mark_tracked(lo: usize, hi: usize) { #[inline] fn page_flags(st: &State, addr: usize) -> Option<*mut u8> { - st.heap.slot(addr).or_else(|| st.bin.slot(addr)).or_else(|| st.tls.slot(addr)) + st.heap.slot(addr).or_else(|| st.bin.slot(addr)).or_else(|| tls_slot(st, addr)) +} + +#[inline] +fn tls_slot(st: &State, addr: usize) -> Option<*mut u8> { + st.tls.slot(addr).or_else(|| st.tlsb.slot(addr)) } // --------------------------------------------------------------------------- @@ -1596,7 +1582,7 @@ unsafe fn for_each_tracked_range(st: &State, mut f: impl FnMut(usize, usize)) { for &(b, e) in &st.regions { f(b, e); } - for m in [&st.bin, &st.tls] { + for m in [&st.bin, &st.tls, &st.tlsb] { let mut i = 0; while i < m.pages { if *m.flags.add(i) & PF_TRACKED != 0 { @@ -1894,7 +1880,7 @@ unsafe fn on_segv_inner( 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); - let range = if st.heap.slot(addr).is_some() { 1 } else if st.bin.slot(addr).is_some() { 2 } else if st.tls.slot(addr).is_some() { 3 } else { 0 }; + let range = if st.heap.slot(addr).is_some() { 1 } else if st.bin.slot(addr).is_some() { 2 } else if tls_slot(st, addr).is_some() { 3 } else { 0 }; diag(&[addr as u64, pc, flags, range, st.d_mode as u64, st.top_mode as u64]); return chain(std::ptr::addr_of!(PREV_SEGV.0).cast(), sig); } @@ -2038,7 +2024,7 @@ fn note_access(st: &mut State, word: usize, write: bool, pc: usize) { } } // Words of the binary range count only as global slots or realm cells. - if (st.bin.slot(word).is_some() || st.tls.slot(word).is_some()) + if (st.bin.slot(word).is_some() || tls_slot(st, word).is_some()) && !(word >= st.realm.0 && word < st.realm.1) && !st.globals.contains(word as u64) { @@ -2656,7 +2642,7 @@ fn write_report(st: &mut State) { (hi == 0x7FFD || hi == 0x7FFC || hi == 0x7FFF || hi == 0x7FFE) && st.heap.slot(a).is_none() && st.bin.slot(a).is_none() - && st.tls.slot(a).is_none() + && tls_slot(st, a).is_none() }; for (bk, b) in log.iter().enumerate() { if (b.init != 0 && st.inits[b.init as usize].in_d) || is_env(b.addr) { @@ -2800,7 +2786,12 @@ fn write_report(st: &mut State) { let _ = writeln!(f, "#transparent\t{}\t{}\t{}", st.transparent.len(), st.transp.len, st.transparent_hits); let _ = writeln!(f, "#foreign_thread_faults\t{}", st.foreign_faults); let _ = writeln!(f, "#untracked_slots\t{}\tof\t{}", st.untracked_slots, st.globals.len); - let _ = writeln!(f, "#tls\twhole={}", st.tls_whole as u8); + let r = st.tls_ranges; + let _ = writeln!( + f, + "#tls\twhole={}\tdata={:x}-{:x}\tbss={:x}-{:x}", + st.tls_whole as u8, r[0], r[1], r[2], r[3] + ); for (what, pcs) in &st.realm_hosts { let pcs: Vec = pcs.iter().map(|p| format!("{p:x}")).collect(); let _ = writeln!(f, "#realm_host\t{what}\t{}", pcs.join(",")); diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 941c5e20fac..53e66a684f5 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -8066,6 +8066,30 @@ pub fn run_with_parse_cache( } } + // SNAP census compiles: the TLS anchors around the program objects (begin + // first, end after every other compiled object, before the pads and the + // runtime archive), so the program's TLS slots get pages of their own. + if std::env::var_os("PERRY_SNAP_CENSUS").is_some_and(|v| !v.is_empty()) { + for end in [false, true] { + let bytes = + perry_codegen::stubs::generate_snap_tls_anchor_object(end, target.as_deref())?; + let path = object_output_dir.join(if end { + "_perry_snap_tls_end.o" + } else { + "_perry_snap_tls_begin.o" + }); + fs::write(&path, &bytes)?; + obj_cleanup_paths.push(path.clone()); + if end { + obj_paths.push(path); + obj_fingerprints.push(None); + } else { + obj_paths.insert(0, path); + obj_fingerprints.insert(0, None); + } + } + } + match format { OutputFormat::Text => { if ctx.needs_stdlib { From 6269431a81dfaa29a6109069910feb03a95ddd5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 3 Nov 2026 01:42:44 +0000 Subject: [PATCH 54/84] SNAP3o: runtime TLS roots counted on their own line; keyed global property reads name their key A root the runtime registers in its own thread-locals (inside the static TLS block, outside the program's anchored pages; on every fixture the realm global's cache word, registered by js_get_global_this) is representation: the report line #runtime_tls_roots counts it (first slot, block bounds), it is not protected, and #untracked_slots counts only roots outside the TLS block that no tracked page covers (first slot printed). perry_probe_get (an inline-probed keyed read such as globalThis.atob) reports its key to the census like perry_probe_get_binding, so a lookup of another global no longer conflicts with a key add (fx/gkey: other.ts read atob and __fxAdded through it, unkeyed). --- .../perry-runtime/src/object/layout_probe.rs | 9 ++ .../perry-runtime/src/snap_census/runner.rs | 84 +++++++++++++++++-- 2 files changed, 84 insertions(+), 9 deletions(-) diff --git a/crates/perry-runtime/src/object/layout_probe.rs b/crates/perry-runtime/src/object/layout_probe.rs index 69c12e70bf2..212ce5b172b 100644 --- a/crates/perry-runtime/src/object/layout_probe.rs +++ b/crates/perry-runtime/src/object/layout_probe.rs @@ -682,6 +682,15 @@ pub unsafe extern "C" fn perry_probe_get( key_bits: u64, hash: u64, ) -> f64 { + // Census builds: a keyed read of a global property names its key + // (§2.10.13), like the binding read below. + #[cfg(perry_gc_instruments)] + let mut scratch = [0; crate::value::SHORT_STRING_MAX_LEN]; + #[cfg(perry_gc_instruments)] + let _gkey = crate::snap_census::global_key( + (obj_biased as usize).wrapping_add(perry_abi::RECEIVER_HANDLE_FLOOR), + || crate::string::js_string_key_bytes(crate::JSValue::from_bits(key_bits), &mut scratch), + ); probe_get::(dir, obj_biased, key_bits, hash) } diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 05cc262b290..92c5b42b42d 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -556,9 +556,19 @@ struct State { /// `js_snap_entry_module`: the next evaluation is the entry module's own /// (its body is the entry body, design §2.3). entry_module_next: bool, - /// Global root slots on no tracked page (a TLS slot outside the anchors, - /// or not a binding: a root slot elsewhere): not watched. + /// Global root slots on no tracked page that are not the runtime's own + /// thread-locals (a program slot the census does not watch): must be 0. untracked_slots: u64, + /// The first of them (0: none), for the report. + untracked_first: usize, + /// Roots the runtime registers in its own thread-locals (inside the + /// static TLS block, outside the program's anchored pages, e.g. the realm + /// global's cache word): representation, counted on their own report + /// line, never protected. + runtime_tls_roots: u64, + runtime_tls_first: usize, + /// The executable's static TLS block `[fs - PT_TLS size, fs)`. + tls_block: (usize, usize), /// The program's TLS slots are anchored on whole pages of their own. tls_whole: bool, /// `PERRY_SNAP_MODE=top`: every member of D may read and write every @@ -714,6 +724,10 @@ unsafe fn start(out: String) { && t[2] <= t[3]; let (tls_lo, tls_hi, tlsb_lo, tlsb_hi) = if tls_whole { (t[0], t[1], t[2], t[3]) } else { (0, 0, 0, 0) }; + // x86-64 variant II: the static TLS block ends at the thread pointer. + let mut fs = 0usize; + libc::syscall(libc::SYS_arch_prctl, 0x1003 /* ARCH_GET_FS */, &mut fs as *mut usize); + let tls_block = (fs.saturating_sub(executable_tls_size()), fs); let mut st = Box::new(State { out, inits: Vec::with_capacity(4096), @@ -765,6 +779,10 @@ unsafe fn start(out: String) { marks: 0, entry_module_next: false, untracked_slots: 0, + untracked_first: 0, + runtime_tls_roots: 0, + runtime_tls_first: 0, + tls_block, tls_whole, top_mode: std::env::var("PERRY_SNAP_MODE").is_ok_and(|v| v == "top"), pre_bits: map_zeroed::(((res_hi - res_lo) / 8).div_ceil(64).max(1)), @@ -872,9 +890,7 @@ unsafe fn start(out: String) { let mut slots = Vec::new(); crate::gc::for_each_global_root_slot(|slot| slots.push(slot)); for slot in slots { - if page_flags(st, slot).is_none() { - st.untracked_slots += 1; - } + count_unwatched_root(&mut *st, slot); st.globals.insert(slot as u64); st.globals_log.push(slot); st.slot_owner.push(0); @@ -1493,9 +1509,7 @@ pub(super) fn region_mapped(base: usize, len: usize) { pub(super) fn global_root(slot: usize) { let st = state(); - if page_flags(st, slot).is_none() { - st.untracked_slots += 1; - } + count_unwatched_root(st, slot); st.globals.insert(slot as u64); st.globals_log.push(slot); st.slot_owner.push(st.frames.last().map_or(0, |f| f.init)); @@ -1527,6 +1541,49 @@ fn mark_tracked(lo: usize, hi: usize) { } } +/// A registered root slot on no tracked page: inside the static TLS block it +/// is one of the runtime's own thread-locals (the program's TLS slots all lie +/// on the anchored pages), representation with its own count; anywhere else +/// it is a program slot the census misses. +fn count_unwatched_root(st: &mut State, slot: usize) { + if page_flags(st, slot).is_some() { + return; + } + if slot >= st.tls_block.0 && slot < st.tls_block.1 { + st.runtime_tls_roots += 1; + if st.runtime_tls_first == 0 { + st.runtime_tls_first = slot; + } + } else { + st.untracked_slots += 1; + if st.untracked_first == 0 { + st.untracked_first = slot; + } + } +} + +/// `PT_TLS` memsz of the executable, rounded up to its alignment. +fn executable_tls_size() -> usize { + unsafe extern "C" fn each( + info: *mut libc::dl_phdr_info, + _size: libc::size_t, + data: *mut libc::c_void, + ) -> libc::c_int { + 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_TLS { + let align = (ph.p_align as usize).max(1); + *(data as *mut usize) = (ph.p_memsz as usize).div_ceil(align) * align; + } + } + 1 // the executable is the first object; stop + } + let mut size = 0usize; + unsafe { libc::dl_iterate_phdr(Some(each), (&mut size as *mut usize).cast()) }; + size +} + #[inline] fn page_flags(st: &State, addr: usize) -> Option<*mut u8> { st.heap.slot(addr).or_else(|| st.bin.slot(addr)).or_else(|| tls_slot(st, addr)) @@ -2785,7 +2842,16 @@ fn write_report(st: &mut State) { let _ = writeln!(f, "#silent_stores\t{}", st.silent_stores); let _ = writeln!(f, "#transparent\t{}\t{}\t{}", st.transparent.len(), st.transp.len, st.transparent_hits); let _ = writeln!(f, "#foreign_thread_faults\t{}", st.foreign_faults); - let _ = writeln!(f, "#untracked_slots\t{}\tof\t{}", st.untracked_slots, st.globals.len); + let _ = writeln!( + f, + "#untracked_slots\t{}\tof\t{}\tfirst={:x}", + st.untracked_slots, st.globals.len, st.untracked_first + ); + let _ = writeln!( + f, + "#runtime_tls_roots\t{}\tfirst={:x}\tblock={:x}-{:x}", + st.runtime_tls_roots, st.runtime_tls_first, st.tls_block.0, st.tls_block.1 + ); let r = st.tls_ranges; let _ = writeln!( f, From 2271779ade880f48e8905e46ad08e35e1ea20d19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 3 Nov 2026 02:06:13 +0000 Subject: [PATCH 55/84] changelog: startup-snapshot census (instrumented builds only) --- changelog.d/snap-startup-census.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/snap-startup-census.md diff --git a/changelog.d/snap-startup-census.md b/changelog.d/snap-startup-census.md new file mode 100644 index 00000000000..b49acc99d7b --- /dev/null +++ b/changelog.d/snap-startup-census.md @@ -0,0 +1 @@ +Instrumented builds gain the startup-snapshot census (`PERRY_SNAP_CENSUS=` on a binary compiled against the instr runtime): it reports which module inits a startup image could hold, with env cells, one environment guard, the entry body, key-granular globalThis locations and a check run. It is off by default and absent from production builds, whose code and startup cost are unchanged. From 8fa8ddf177004b177618982059e42c9831b74de5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 3 Nov 2026 05:28:25 +0000 Subject: [PATCH 56/84] SNAP3p: a slot registered again (one linked class-static address, registered by its module and by importers) keeps its first owner; the re-registration is no access --- crates/perry-runtime/src/snap_census/runner.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 92c5b42b42d..9f6e690bd8b 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -1509,6 +1509,14 @@ pub(super) fn region_mapped(base: usize, len: usize) { pub(super) fn global_root(slot: usize) { 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 + // every importer that names it). The registration is GC bookkeeping, not + // an access: the slot keeps its first owner, and the re-registering init + // neither initializes it (rule 2) nor owns it (the check run's poison). + if st.globals.contains(slot as u64) { + return; + } count_unwatched_root(st, slot); st.globals.insert(slot as u64); st.globals_log.push(slot); From a0952497f74e8d0f38b2978182d3861eb94e5403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 3 Nov 2026 11:33:37 +0000 Subject: [PATCH 57/84] SNAP3s: census R1-R3 (design 2.10.16): representation decides ordering not ownership (D writes in representation scopes, meta installs and to excluded cells of non-D objects join a rollback set with their old values; D-born records are D state); shared immutable cells (strings, regex programs, canonical key lists) named by content with no rank; WeakStorage digests its sorted entry set --- crates/perry-runtime/src/regex/perex_owner.rs | 13 + crates/perry-runtime/src/snap_census.rs | 26 +- .../perry-runtime/src/snap_census/runner.rs | 298 +++++++++++++++++- 3 files changed, 331 insertions(+), 6 deletions(-) diff --git a/crates/perry-runtime/src/regex/perex_owner.rs b/crates/perry-runtime/src/regex/perex_owner.rs index 2084f45fa77..fc6e146c54b 100644 --- a/crates/perry-runtime/src/regex/perex_owner.rs +++ b/crates/perry-runtime/src/regex/perex_owner.rs @@ -41,6 +41,19 @@ struct ProgramCell { /// needs more than this is searched by the evaluator alone. const DFA_MAX_WORDS: usize = 64 * 1024; +/// SNAP census (design §2.10.16 R2): the immutable program words of the cell +/// at `cell`, the content that names it. Instrumented builds only. +/// +/// # Safety +/// `cell` must be the payload address of a live `GC_TYPE_REGEX_PROGRAM`. +#[cfg(perry_gc_instruments)] +pub(crate) unsafe fn snap_program_words<'a>(cell: usize) -> &'a [u32] { + unsafe { + let c = cell as *const ProgramCell; + std::slice::from_raw_parts(c.add(1).cast(), (*c).word_count) + } +} + /// The address of the program cell's automaton-storage edge, for the /// collector's trace and rewrite of the cell at `cell`. /// diff --git a/crates/perry-runtime/src/snap_census.rs b/crates/perry-runtime/src/snap_census.rs index 32be29f27bd..e0aecc18c43 100644 --- a/crates/perry-runtime/src/snap_census.rs +++ b/crates/perry-runtime/src/snap_census.rs @@ -141,12 +141,32 @@ pub(crate) fn meta_installed(slot: usize, meta: usize) { /// builds only. #[cfg(perry_gc_instruments)] #[inline(always)] -pub(crate) fn representation() -> Bookkeeping { +pub(crate) fn representation() -> Representation { #[cfg(all(target_os = "linux", target_arch = "x86_64"))] if runner::on() && runner::representation(1) { - return Bookkeeping { active: true }; + return Representation { active: true }; + } + Representation { active: false } +} + +/// The scope [`representation`] opened: its end closes the representation +/// depth with the bookkeeping depth (R1 tells the two apart). +#[cfg(perry_gc_instruments)] +pub(crate) struct Representation { + active: bool, +} + +#[cfg(perry_gc_instruments)] +impl Drop for Representation { + #[inline(always)] + fn drop(&mut self) { + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + if self.active { + runner::representation(-1); + } + #[cfg(not(all(target_os = "linux", target_arch = "x86_64")))] + let _ = self.active; } - Bookkeeping { active: false } } /// A keyed operation on `obj` (a global lookup of key X, the add or store diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 9f6e690bd8b..64e49d6695f 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -517,6 +517,29 @@ struct State { /// populating itself on first use. (what, call chain), first per what. realm_hosts: Vec<(&'static str, [usize; 6])>, excluded: u64, + /// R1 (design §2.10.16; `PERRY_SNAP_R1=0` turns it off): representation + /// decides ordering, not ownership. Words a member of D wrote inside a + /// representation scope, as a meta install or into an excluded cell, of + /// an object no member of D bore: rolled back, left out of the digest, + /// no rule-2/3 edge. + r1: bool, + repw: WordSet, + repw_writes: u64, + /// (word, value before the first rolled-back write). + repw_old: Log<(u64, u64)>, + /// The value before the store being classified after its step. + store_before: Option, + repw_owned: u64, + repw_reads: u64, + /// Depth of the representation scopes inside `bookkeeping`. + repr_depth: i32, + /// The init whose frame was on top at each birth (by start). + born_by: SizeMap, + /// R2: a shared immutable cell (string, regex program, canonical key + /// list) is named by its content and takes no rank. `PERRY_SNAP_R2=0`. + r2: bool, + /// R3: a WeakStorage digests its entry set, not its buckets. `PERRY_SNAP_R3=0`. + r3: bool, /// Size of every birth and realm image cell, by start. sizes: SizeMap, /// One bit per 8-byte word of the realm section: an image cell starts. @@ -759,6 +782,17 @@ unsafe fn start(out: String) { starts: map_zeroed::(((res_hi - res_lo) / 8).div_ceil(64).max(1)), realm_hosts: Vec::new(), excluded: 0, + r1: !std::env::var("PERRY_SNAP_R1").is_ok_and(|v| v == "0"), + repw: WordSet::new(24), + repw_writes: 0, + repw_old: Log::new(1 << 24), + store_before: None, + repw_owned: 0, + repw_reads: 0, + repr_depth: 0, + born_by: SizeMap::new(26), + r2: !std::env::var("PERRY_SNAP_R2").is_ok_and(|v| v == "0"), + r3: !std::env::var("PERRY_SNAP_R3").is_ok_and(|v| v == "0"), sizes: SizeMap::new(26), realm_starts: map_zeroed::(((realm.1 - realm.0) / 8).div_ceil(64).max(1)), free_space: 0, @@ -1347,6 +1381,7 @@ pub(super) fn representation(delta: i32) -> bool { st.marks += 1; } st.bookkeeping += delta; + st.repr_depth += delta; true } @@ -1485,6 +1520,7 @@ pub(super) fn birth(addr: usize, size: usize) { st.transp.insert(addr as u64); st.transp_objs.push((addr, addr + size)); } + st.born_by.insert(addr as u64, top); st.births.push(Birth { addr, size: size as u32, @@ -2020,7 +2056,9 @@ extern "C" fn on_trap(sig: libc::c_int, _info: *mut libc::siginfo_t, ctx: *mut l if !changed { st.silent_stores += 1; } + st.store_before = Some(before); note_access(st, word, changed, pc); + st.store_before = None; } for &page in pending.iter().take_while(|p| **p != 0) { libc::mprotect(page as *mut _, PAGE, policy(st, page)); @@ -2074,6 +2112,19 @@ fn transparent_cell(st: &mut State, h: usize, ty: u8) -> bool { } fn note_access(st: &mut State, word: usize, write: bool, pc: usize) { + 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 + // representation (R1). + if st.bookkeeping > st.repr_depth { + st.faults_ignored += 1; + return; + } + if st.repr_depth > 0 || (write && word == st.meta_slot) { + repr_access(st, word, write, pc); + return; + } + } if st.bookkeeping > 0 { st.faults_ignored += 1; return; @@ -2135,6 +2186,11 @@ fn note_access(st: &mut State, word: usize, write: bool, pc: usize) { }; if ty == crate::gc::GC_TYPE_STRING || flags & crate::gc::GC_FLAG_SHAPE_SHARED != 0 { st.excluded += 1; + // R1: a member of D changing an excluded cell in place (an + // in-place append) another init bore: rolled back. + if st.r1 && write && st.d_mode && !born_in_d(st, h) { + note_repw(st, word); + } return; } if !st.transparent.is_empty() && transparent_cell(st, h, ty) { @@ -2172,6 +2228,88 @@ fn note_access(st: &mut State, word: usize, write: bool, pc: usize) { note_loc(st, top, word, word as u64, write, pc); } +/// A rolled-back word (R1): the image holds it as before D, so its value +/// before this (the first) write is kept for the digest's content names. +fn note_repw(st: &mut State, word: usize) { + st.repw_writes += 1; + if st.repw.insert(word as u64) { + // A store classified after its step names the value before it; at + // the fault the word still holds it (its page is open). + let old = st.store_before.unwrap_or_else(|| unsafe { *(word as *const u64) }); + st.repw_old.push((word as u64, old)); + } +} + +/// The object starting at `h` was born while a member of D ran. +fn born_in_d(st: &State, h: usize) -> bool { + st.born_by + .get(h as u64) + .is_some_and(|i| i != 0 && st.inits[i as usize].in_d) +} + +/// R1 (design §2.10.16): an access inside a representation scope or a meta +/// install. Representation decides ordering, never who owns a write. A +/// member of D writing an object it (or another member) bore writes D state +/// (`st.w`): imaged inits that later touch those words are rule 2/3 +/// readers. A member of D writing any other object's word: the word joins +/// the rollback set (`repw`, the digest and `.wd` leave it out), no edge. +/// An imaged init's representation access is an access only of D state. +fn repr_access(st: &mut State, word: usize, write: bool, pc: usize) { + let cell = if st.heap.slot(word).is_some() { + object_start(st, word) + } else { + realm_cell(st, word) + }; + let Some(h) = cell.filter(|&h| st.sizes.get(h as u64).is_some_and(|s| word < h + s as usize)) else { + st.faults_ignored += 1; + return; + }; + // A string or a shared canonical key list is a value (R2): a member of + // D extending one another init bore in place is rolled back; no other + // access of it is an access. + // The faulting page is open for the access; another header page is + // opened for the read only (as in `note_access`). + let (ty, flags) = unsafe { + let hp = h & !(PAGE - 1); + if hp != word & !(PAGE - 1) { + libc::mprotect(hp as *mut _, PAGE, libc::PROT_READ); + } + let g = &*(h as *const crate::gc::GcHeader); + let r = (g.obj_type, g.gc_flags); + if hp != word & !(PAGE - 1) { + libc::mprotect(hp as *mut _, PAGE, policy(st, hp)); + } + r + }; + if ty == crate::gc::GC_TYPE_STRING || flags & crate::gc::GC_FLAG_SHAPE_SHARED != 0 { + st.excluded += 1; + if st.d_mode && write && !born_in_d(st, h) { + note_repw(st, word); + } + return; + } + let top = st.frames.last().map_or(0, |f| f.init) as usize; + if st.d_mode { + if !write { + st.faults_ignored += 1; + } else if born_in_d(st, h) { + st.repw_owned += 1; + st.inits[top].faults += 1; + note_loc(st, top, word, word as u64, true, pc); + } else { + note_repw(st, word); + } + return; + } + if top != 0 && st.w.contains(word as u64) { + st.repw_reads += 1; + st.inits[top].faults += 1; + note_loc(st, top, word, word as u64, write, pc); + return; + } + st.faults_ignored += 1; +} + /// One access of location `w` (a word, or a key id of the realm global whose /// shape word is `word`) by init `top`: rules 2-4. fn note_loc(st: &mut State, top: usize, word: usize, w: u64, write: bool, pc: usize) { @@ -2648,14 +2786,66 @@ fn write_report(st: &mut State) { let mut env_store = st.env_storage.clone(); env_store.sort_unstable(); let is_env = |a: usize| env_store.binary_search_by_key(&a, |r| r.0).is_ok(); + // R1: a rolled-back word reads as it was before D (first write). + let rolled_map: std::collections::HashMap = { + let mut m = std::collections::HashMap::new(); + for &(w, v) in st.repw_old.as_slice() { + m.entry(w).or_insert(v); + } + m + }; + let rolled = |w: usize| -> u64 { + rolled_map + .get(&(w as u64)) + .copied() + .unwrap_or_else(|| unsafe { *(w as *const u64) }) + }; + let fnv = |h: u64, x: u64| (h ^ x).wrapping_mul(0x100000001B3); + // R2 (design §2.10.16): a shared immutable cell is a value. 1 a + // string (its bytes), 2 a regex program (its program words), 3 a + // canonical key list (its keys, named in a second pass), 4 a + // program's automaton storage (derived state of its program). + let vkind = |b: &Birth| -> u8 { + if !st.r2 { + return 0; + } + let g = unsafe { &*(b.addr as *const crate::gc::GcHeader) }; + match g.obj_type { + crate::gc::GC_TYPE_STRING => 1, + crate::gc::GC_TYPE_REGEX_PROGRAM => 2, + crate::gc::GC_TYPE_ARRAY if g.gc_flags & crate::gc::GC_FLAG_SHAPE_SHARED != 0 => 3, + crate::gc::GC_TYPE_REGEX_CACHE => 4, + _ => 0, + } + }; + let mut vk: Vec = Vec::with_capacity(log.len()); + let mut keylists: Vec = Vec::new(); for b in log { let k = rank.entry(b.init).or_insert(0); let a = st.inits[b.init as usize].addr as u64; + let kind = if is_env(b.addr) { 0 } else { vkind(b) }; + vk.push(kind); + let user = b.addr + crate::gc::GC_HEADER_SIZE; + let end = b.addr + b.size as usize; // The runtime's births (init 0) happen when an init first uses a // runtime object, so their order depends on D: they are named by // type and size only. let name = if is_env(b.addr) { ENV_NAME + } else if kind == 1 { + let byte_len = (rolled(user) >> 32) as usize; + let p = user + std::mem::size_of::(); + let n = byte_len.min(end.saturating_sub(p)); + let bytes = unsafe { std::slice::from_raw_parts(p as *const u8, n) }; + bytes.iter().fold(fnv(0x5354_5249_4e47_0000, n as u64), |h, &c| fnv(h, c as u64)) + } else if kind == 2 { + let words = unsafe { crate::regex::perex_owner::snap_program_words(user) }; + words.iter().fold(fnv(0x5052_4f47_0000_0000, words.len() as u64), |h, &x| fnv(h, x as u64)) + } else if kind == 3 { + keylists.push(b.addr); + 0 + } else if kind == 4 { + 0x5245_4341_4348_4500 } else if b.init == 0 { let ty = unsafe { (*(b.addr as *const crate::gc::GcHeader)).obj_type } as u64; 0x5254_0000_0000_0000 ^ (ty << 32) ^ b.size as u64 @@ -2664,7 +2854,9 @@ fn write_report(st: &mut State) { }; names.push((b.addr, b.addr + b.size as usize, name)); ranks.push(*k); - *k += 1; + if kind == 0 { + *k += 1; + } } let birth_names: Vec = names.iter().map(|n| n.2).collect(); let mut wd_out = if st.skip.is_empty() { @@ -2673,6 +2865,57 @@ fn write_report(st: &mut State) { None }; names.sort_unstable(); + // A pointer word as a name: the cell it points into plus offset and tag. + fn pname(names: &[(usize, usize, u64)], w: u64) -> u64 { + let v = (w & 0xFFFF_FFFF_FFFF) as usize; + let i = names.partition_point(|&(a, _, _)| a <= v); + if i > 0 && v < names[i - 1].1 { + let (n, o) = (names[i - 1].2, v - names[i - 1].0); + n ^ (o as u64).rotate_left(17) ^ (w >> 48).rotate_left(40) + } else { + w + } + } + // The words of a canonical key list's first `n` keys (after the + // key-attribute reserve), as the image holds them. + let key_words = |user: usize, end: usize, n: usize| -> Vec { + let attrs = unsafe { (*(user as *const crate::gc::GcHeader).sub(1))._reserved } + & crate::gc::GC_ARRAY_KEY_ATTRS + != 0; + let first = user + std::mem::size_of::() + if attrs { 8 } else { 0 }; + (0..n).map(|i| first + 8 * i).take_while(|&w| w + 8 <= end).map(|w| rolled(w)).collect() + }; + if !keylists.is_empty() { + keylists.sort_unstable(); + let upd: Vec<(usize, u64)> = names + .iter() + .enumerate() + .filter(|(_, n)| keylists.binary_search(&n.0).is_ok()) + .map(|(i, n)| { + let user = n.0 + crate::gc::GC_HEADER_SIZE; + let len = (rolled(user) & 0xFFFF_FFFF) as usize; + let h = key_words(user, n.1, len) + .into_iter() + .fold(fnv(0x4b45_5953_0000_0000, len as u64), |h, w| fnv(h, pname(&names, w))); + (i, h) + }) + .collect(); + for (i, h) in upd { + names[i].2 = h; + } + } + // A shape names its key list by its first `count` keys (R2): a list + // extended in place later is the same list to this shape. + let shape_keys = |keys: u64, count: usize| -> u64 { + let v = (keys & 0xFFFF_FFFF_FFFF) as usize; + let i = names.partition_point(|&(a, _, _)| a <= v); + if !st.r2 || i == 0 || v >= names[i - 1].1 || v != names[i - 1].0 + crate::gc::GC_HEADER_SIZE { + return pname(&names, keys); + } + key_words(v, names[i - 1].1, count) + .into_iter() + .fold(fnv(0x4b45_5953_0000_0000, count as u64), |h, w| fnv(h, pname(&names, w))) + }; let name_of = |v: usize| -> Option<(u64, usize)> { let i = names.partition_point(|&(a, _, _)| a <= v); (i > 0 && v < names[i - 1].1).then(|| (names[i - 1].2, v - names[i - 1].0)) @@ -2713,6 +2956,10 @@ fn write_report(st: &mut State) { if (b.init != 0 && st.inits[b.init as usize].in_d) || is_env(b.addr) { continue; } + // R2: a value is no init's state; pointers name it by content. + if vk[bk] != 0 { + continue; + } let bname = birth_names[bk]; let e = per .entry(st.inits[b.init as usize].addr) @@ -2720,6 +2967,46 @@ fn write_report(st: &mut State) { e.1 += 1; let ty = unsafe { (*(b.addr as *const crate::gc::GcHeader)).obj_type }; dump(st.inits[b.init as usize].addr, bname, 0, format!("t{ty}:{}", b.size)); + // R3 (design §2.10.16): a WeakStorage's bucket positions and + // entry order are derived state (they hash raw addresses): it + // digests its live entry set, sorted. + if st.r3 && ty == crate::gc::GC_TYPE_WEAK_STORAGE { + let ws = (b.addr + crate::gc::GC_HEADER_SIZE) as *const crate::weakref::storage::WeakStorage; + let mut ents: Vec<(u64, u64)> = Vec::new(); + unsafe { + let n = (*ws).len.min((*ws).capacity) as usize; + let base = (*ws).entries(); + for i in 0..n { + let ent = base.add(i); + if (*ent).key == crate::value::TAG_UNDEFINED { + continue; + } + let kw = std::ptr::addr_of!((*ent).key) as usize; + let mut skip = false; + for w in [kw, kw + 8] { + let o = w - b.addr; + if st.w.contains(w as u64) || st.repw.contains(w as u64) { + if let Some(f) = wd_out.as_mut() { + use std::io::Write; + let _ = writeln!(f, "{bname:x}\t{o}"); + } + skip = true; + } else if st.wd_names.contains(&(bname, o)) { + skip = true; + } + } + if !skip { + ents.push((pname(&names, (*ent).key), pname(&names, (*ent).value))); + } + } + } + ents.sort_unstable(); + let h = ents.iter().fold(0xcbf29ce484222325u64, |h, &(k, v)| fnv(fnv(h, k), v)); + e.2 += 1; + mix(&mut e.0, h); + dump(st.inits[b.init as usize].addr, bname, crate::gc::GC_HEADER_SIZE, format!("k{h:x}:{}", ents.len())); + continue; + } // The GC header (mark and move-request bits) is no JS state. let mut off = crate::gc::GC_HEADER_SIZE; // A Map store's native indexes are derived state (see @@ -2736,7 +3023,7 @@ fn write_report(st: &mut State) { if word - b.addr >= skip.0 && word - b.addr < skip.1 { continue; } - if st.w.contains(word as u64) { + if st.w.contains(word as u64) || st.repw.contains(word as u64) { if let Some(o) = wd_out.as_mut() { use std::io::Write; let _ = writeln!(o, "{bname:x}\t{}", off - 8); @@ -2768,7 +3055,7 @@ fn write_report(st: &mut State) { }) }; let sname = [ - pn(d.keys), + shape_keys(d.keys, d.logical_key_count as usize), d.logical_key_count as u64, d.live_inline_slot_count as u64, d.integrity_flags as u64, @@ -2845,6 +3132,11 @@ fn write_report(st: &mut State) { write_env_report(st, &mut f, &ranks); } let _ = writeln!(f, "#excluded\t{}", st.excluded); + let _ = writeln!( + f, + "#repw\trule={}\twords={}\twrites={}\towned={}\treads={}\tr2={}\tr3={}", + st.r1 as u8, st.repw.len, st.repw_writes, st.repw_owned, st.repw_reads, st.r2 as u8, st.r3 as u8 + ); let _ = writeln!(f, "#free_space\t{}", st.free_space); let _ = writeln!(f, "#reborn\t{}", st.reborn); let _ = writeln!(f, "#silent_stores\t{}", st.silent_stores); From 3c091e0f6f98ee51ee8498116e1f2dd32b05eed0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 3 Nov 2026 12:18:39 +0000 Subject: [PATCH 58/84] SNAP3t: census serial rule (design 2.10.17): a dynamic prototype serial is drawn in allocation order, so the digest names it by the birth name of the prototype that carries it (PERRY_SNAP_SERIAL=0 off); the dig token shows a shape word's inputs (keys, prototype, rest); the regex automaton storage is a derived leaf (comment) --- crates/perry-runtime/src/object/shapes.rs | 4 +- .../perry-runtime/src/snap_census/runner.rs | 78 +++++++++++++++++-- 2 files changed, 72 insertions(+), 10 deletions(-) diff --git a/crates/perry-runtime/src/object/shapes.rs b/crates/perry-runtime/src/object/shapes.rs index 1974ca69d80..e88d9f22d87 100644 --- a/crates/perry-runtime/src/object/shapes.rs +++ b/crates/perry-runtime/src/object/shapes.rs @@ -5056,7 +5056,7 @@ pub(crate) const PROTO_ID_PER_OBJECT: u64 = PROTO_ID_UNIQUE; /// hands it out. pub(crate) const PROTO_ID_RECORD_HELD: u64 = PROTO_ID_NULL - 1; /// Serial bits a MIXED identity can carry beside a 32-bit class id. -const PROTO_ID_MIXED_SERIAL_BITS: u32 = 30; +pub(crate) const PROTO_ID_MIXED_SERIAL_BITS: u32 = 30; static PROTO_ID_UNIQUE_NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -5113,7 +5113,7 @@ pub(crate) fn class_proto_id(class_id: u32) -> u64 { /// /// # Safety /// `bits` are a recorded `ObjectMeta.prototype` word. -unsafe fn prototype_serial(bits: u64) -> u64 { +pub(crate) unsafe fn prototype_serial(bits: u64) -> u64 { let value = crate::value::JSValue::from_bits(bits); if !value.is_pointer() { return 0; diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 64e49d6695f..fa6bd9f77f4 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -540,6 +540,11 @@ struct State { r2: bool, /// R3: a WeakStorage digests its entry set, not its buckets. `PERRY_SNAP_R3=0`. r3: bool, + /// Serial rule (design §2.10.17): a dynamic prototype serial is drawn in + /// allocation order (`proto_validity::next_prototype_serial`), so it is + /// representation: the digest names it by the birth name of the + /// prototype that carries it. `PERRY_SNAP_SERIAL=0`. + serial: bool, /// Size of every birth and realm image cell, by start. sizes: SizeMap, /// One bit per 8-byte word of the realm section: an image cell starts. @@ -793,6 +798,7 @@ unsafe fn start(out: String) { born_by: SizeMap::new(26), r2: !std::env::var("PERRY_SNAP_R2").is_ok_and(|v| v == "0"), r3: !std::env::var("PERRY_SNAP_R3").is_ok_and(|v| v == "0"), + serial: !std::env::var("PERRY_SNAP_SERIAL").is_ok_and(|v| v == "0"), sizes: SizeMap::new(26), realm_starts: map_zeroed::(((realm.1 - realm.0) / 8).div_ceil(64).max(1)), free_space: 0, @@ -2845,6 +2851,11 @@ fn write_report(st: &mut State) { keylists.push(b.addr); 0 } else if kind == 4 { + // A regex program's lazy automaton storage (`GC_TYPE_REGEX_CACHE`) + // is derived state of its program (owner ruling 2026-11-03): it + // holds no program value, only states the matcher built from the + // program, so it is one fixed name with no rank and no words. If + // it ever holds a program value the digest hashes it. 0x5245_4341_4348_4500 } else if b.init == 0 { let ty = unsafe { (*(b.addr as *const crate::gc::GcHeader)).obj_type } as u64; @@ -2916,6 +2927,46 @@ fn write_report(st: &mut State) { .into_iter() .fold(fnv(0x4b45_5953_0000_0000, count as u64), |h, w| fnv(h, pname(&names, w))) }; + // Serial rule (design §2.10.17): a dynamic prototype serial + // (`FIRST_DYNAMIC_PROTOTYPE_SERIAL` and up, drawn in allocation + // order, so an init's serial depends on how many D drew before it) + // is named by the birth name of the object whose meta record carries + // it, the way a ShapeId is named by its shape. Intrinsic serials + // (below it) and serials of no birth (image prototypes, families + // registered before user code) are fixed numbers and hash as such. + let serial_names: std::collections::HashMap = if st.serial { + names + .iter() + .filter_map(|&(a, _, n)| { + let bits = + crate::value::js_nanbox_pointer((a + crate::gc::GC_HEADER_SIZE) as i64).to_bits(); + let s = unsafe { crate::object::shapes::prototype_serial(bits) }; + (s >= crate::object::proto_validity::FIRST_DYNAMIC_PROTOTYPE_SERIAL + && s < crate::object::shapes::PROTO_ID_CLASS) + .then_some((s, n)) + }) + .collect() + } else { + Default::default() + }; + // A shape's prototype identity with its serial named: a plain serial + // identity, or a MIXED one (class id beside the serial). None: not a + // named dynamic serial (the identity hashes as before). + let pser = |id: u64| -> Option { + use crate::object::shapes::{PROTO_ID_CLASS, PROTO_ID_MIXED, PROTO_ID_MIXED_SERIAL_BITS, PROTO_ID_UNIQUE}; + let (hi, s) = if id < PROTO_ID_CLASS { + (0, id) + } else if (PROTO_ID_MIXED..PROTO_ID_UNIQUE).contains(&id) { + let m = (1u64 << PROTO_ID_MIXED_SERIAL_BITS) - 1; + (id & !m, id & m) + } else { + return None; + }; + if s < crate::object::proto_validity::FIRST_DYNAMIC_PROTOTYPE_SERIAL { + return None; + } + serial_names.get(&s).map(|&n| fnv(fnv(0x5345_5249_414c_0000, hi), n)) + }; let name_of = |v: usize| -> Option<(u64, usize)> { let i = names.partition_point(|&(a, _, _)| a <= v); (i > 0 && v < names[i - 1].1).then(|| (names[i - 1].2, v - names[i - 1].0)) @@ -3054,23 +3105,33 @@ fn write_report(st: &mut State) { n ^ (o as u64).rotate_left(17) ^ (w >> 48).rotate_left(40) }) }; - let sname = [ + let parts = [ shape_keys(d.keys, d.logical_key_count as usize), d.logical_key_count as u64, d.live_inline_slot_count as u64, d.integrity_flags as u64, - pn(d.proto_id), + pser(d.proto_id).unwrap_or_else(|| pn(d.proto_id)), d.object_kind as u8 as u64, d.hole_count as u64, d.summary as u64, d.rep, d.special_constfn_mask as u64, - ] - .iter() - .fold(0xcbf29ce484222325u64, |h, &x| (h ^ x).wrapping_mul(0x100000001B3)); + ]; + let sname = + parts.iter().fold(0xcbf29ce484222325u64, |h, &x| (h ^ x).wrapping_mul(0x100000001B3)); mix(&mut e.0, v & 0xFFFF_FFFF); mix(&mut e.0, sname); - let tok = format!("s{:x}:{sname:x}", v & 0xFFFF_FFFF); + // The dump shows the inputs too (keys, prototype, the rest), so a + // differing shape word names its differing field. + let rest = parts[5..].iter().fold(parts[1] ^ parts[2] << 20 ^ parts[3] << 40, |h, &x| { + (h ^ x).wrapping_mul(0x100000001B3) + }); + let tok = format!( + "s{:x}:{sname:x}|k{:x}|p{:x}|r{rest:x}", + v & 0xFFFF_FFFF, + parts[0], + parts[4] + ); dump(st.inits[b.init as usize].addr, bname, off - 8, tok); continue; } @@ -3134,8 +3195,9 @@ fn write_report(st: &mut State) { let _ = writeln!(f, "#excluded\t{}", st.excluded); let _ = writeln!( f, - "#repw\trule={}\twords={}\twrites={}\towned={}\treads={}\tr2={}\tr3={}", - st.r1 as u8, st.repw.len, st.repw_writes, st.repw_owned, st.repw_reads, st.r2 as u8, st.r3 as u8 + "#repw\trule={}\twords={}\twrites={}\towned={}\treads={}\tr2={}\tr3={}\tserial={}", + st.r1 as u8, st.repw.len, st.repw_writes, st.repw_owned, st.repw_reads, st.r2 as u8, st.r3 as u8, + st.serial as u8 ); let _ = writeln!(f, "#free_space\t{}", st.free_space); let _ = writeln!(f, "#reborn\t{}", st.reborn); From a4ea4f10c7008342e86bf9e2ade351d6bc2a5484 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 3 Nov 2026 14:35:22 +0000 Subject: [PATCH 59/84] SNAP3u: census key-name rule (design 2.10.18): a key is named by what it is, never by where it lives. A key in a named cell takes the cell name (a string by its bytes, R2); a symbol the runtime leaked into malloc memory (symbol::leak_symbol: no birth, mimalloc randomizes its address per run even with ASLR off) is named by its description and its Symbol.for bit, never by its address or creation id; any other foreign key word hashes as its tag. Used by the key-list naming loop and shape_keys; the dig token gains a K part per key; PERRY_SNAP_KEYNAME=0 off; #repw line gains keyname=. Fixes fastify check differ=8 (R2 named symbol keys by raw word). Fixtures (outside the tree): keyname new (off differs a.ts, on differ=0); intern and keytail rewritten with Object.assign({}, Object.fromEntries(..)) so they reach canonical_keys::extend_token; keytail off stays green: shape_keys names a shape by its first count keys, so nothing points at the extended list directly. --- .../perry-runtime/src/snap_census/runner.rs | 65 +++++++++++++++++-- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index fa6bd9f77f4..ff24cd5fe74 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -538,6 +538,10 @@ struct State { /// R2: a shared immutable cell (string, regex program, canonical key /// list) is named by its content and takes no rank. `PERRY_SNAP_R2=0`. r2: bool, + /// Key-name rule (design §2.10.18): a key word is named by what it is + /// (a leaked symbol by its description), never by its address. + /// `PERRY_SNAP_KEYNAME=0`. + keyname: bool, /// R3: a WeakStorage digests its entry set, not its buckets. `PERRY_SNAP_R3=0`. r3: bool, /// Serial rule (design §2.10.17): a dynamic prototype serial is drawn in @@ -797,6 +801,7 @@ unsafe fn start(out: String) { repr_depth: 0, born_by: SizeMap::new(26), r2: !std::env::var("PERRY_SNAP_R2").is_ok_and(|v| v == "0"), + keyname: !std::env::var("PERRY_SNAP_KEYNAME").is_ok_and(|v| v == "0"), r3: !std::env::var("PERRY_SNAP_R3").is_ok_and(|v| v == "0"), serial: !std::env::var("PERRY_SNAP_SERIAL").is_ok_and(|v| v == "0"), sizes: SizeMap::new(26), @@ -2896,6 +2901,37 @@ fn write_report(st: &mut State) { let first = user + std::mem::size_of::() + if attrs { 8 } else { 0 }; (0..n).map(|i| first + 8 * i).take_while(|&w| w + 8 <= end).map(|w| rolled(w)).collect() }; + // Key-name rule (design §2.10.18): a key is named by what it is, never + // by where it lives. A key in a named cell takes that cell's name (a + // string by its bytes, R2). A symbol the runtime leaked into malloc + // memory (`symbol::leak_symbol`: process-lifetime, no birth; mimalloc + // randomizes its address per run even with ASLR off, and its creation + // id is drawn in creation order) is named by its description and its + // `Symbol.for` bit (two symbols of one description share a name). Any other foreign key word hashes as its tag, like + // every foreign word of the digest. `PERRY_SNAP_KEYNAME=0`: raw words. + let kname = |names: &[(usize, usize, u64)], w: u64| -> u64 { + let a = (w & 0xFFFF_FFFF_FFFF) as usize; + let named = pname(names, w); + if !st.keyname || named != w || !in_malloc(a) || a < crate::gc::GC_HEADER_SIZE { + return named; + } + let g = unsafe { &*((a - crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader) }; + if g.obj_type != crate::gc::GC_TYPE_SYMBOL { + return fnv(0x464f_5245_4947_4e00, w >> 48); + } + let sym = unsafe { &*(a as *const crate::symbol::SymbolHeader) }; + let d = sym.description as u64; + // The description by its bytes (a string is a value, R2), wherever it lives. + let dn = if d == 0 { + 0 + } else { + let sh = d as *const crate::string::StringHeader; + let n = unsafe { (*sh).byte_len } as usize; + let bytes = unsafe { std::slice::from_raw_parts(crate::string::string_data(sh), n) }; + bytes.iter().fold(fnv(0x5354_5249_4e47_0000, n as u64), |h, &c| fnv(h, c as u64)) + }; + fnv(fnv(fnv(0x5359_4d42_0000_0000, w >> 48), (sym.registered & 1) as u64), dn) + }; if !keylists.is_empty() { keylists.sort_unstable(); let upd: Vec<(usize, u64)> = names @@ -2907,7 +2943,7 @@ fn write_report(st: &mut State) { let len = (rolled(user) & 0xFFFF_FFFF) as usize; let h = key_words(user, n.1, len) .into_iter() - .fold(fnv(0x4b45_5953_0000_0000, len as u64), |h, w| fnv(h, pname(&names, w))); + .fold(fnv(0x4b45_5953_0000_0000, len as u64), |h, w| fnv(h, kname(&names, w))); (i, h) }) .collect(); @@ -2925,7 +2961,23 @@ fn write_report(st: &mut State) { } key_words(v, names[i - 1].1, count) .into_iter() - .fold(fnv(0x4b45_5953_0000_0000, count as u64), |h, w| fnv(h, pname(&names, w))) + .fold(fnv(0x4b45_5953_0000_0000, count as u64), |h, w| fnv(h, kname(&names, w))) + }; + // Diagnostic (the dig dump): a shape key input key by key: L (a named heap + // list) or P (pointer named by pname), then each key word's raw value and name. + let shape_keys_tok = |keys: u64, count: usize| -> String { + let v = (keys & 0xFFFF_FFFF_FFFF) as usize; + let i = names.partition_point(|&(a, _, _)| a <= v); + if !st.r2 || i == 0 || v >= names[i - 1].1 || v != names[i - 1].0 + crate::gc::GC_HEADER_SIZE { + let (n, o) = if i > 0 && v < names[i - 1].1 { (names[i - 1].2, v - names[i - 1].0) } else { (0, 0) }; + return format!("P{keys:x}={n:x}+{o}"); + } + let ws = key_words(v, names[i - 1].1, count); + let mut t = format!("L{}", ws.len()); + for w in ws { + t.push_str(&format!(",{w:x}={:x}", kname(&names, w))); + } + t }; // Serial rule (design §2.10.17): a dynamic prototype serial // (`FIRST_DYNAMIC_PROTOTYPE_SERIAL` and up, drawn in allocation @@ -3127,10 +3179,11 @@ fn write_report(st: &mut State) { (h ^ x).wrapping_mul(0x100000001B3) }); let tok = format!( - "s{:x}:{sname:x}|k{:x}|p{:x}|r{rest:x}", + "s{:x}:{sname:x}|k{:x}|p{:x}|r{rest:x}|K{}", v & 0xFFFF_FFFF, parts[0], - parts[4] + parts[4], + shape_keys_tok(d.keys, d.logical_key_count as usize) ); dump(st.inits[b.init as usize].addr, bname, off - 8, tok); continue; @@ -3195,9 +3248,9 @@ fn write_report(st: &mut State) { let _ = writeln!(f, "#excluded\t{}", st.excluded); let _ = writeln!( f, - "#repw\trule={}\twords={}\twrites={}\towned={}\treads={}\tr2={}\tr3={}\tserial={}", + "#repw\trule={}\twords={}\twrites={}\towned={}\treads={}\tr2={}\tr3={}\tserial={}\tkeyname={}", st.r1 as u8, st.repw.len, st.repw_writes, st.repw_owned, st.repw_reads, st.r2 as u8, st.r3 as u8, - st.serial as u8 + st.serial as u8, st.keyname as u8 ); let _ = writeln!(f, "#free_space\t{}", st.free_space); let _ = writeln!(f, "#reborn\t{}", st.reborn); From 5cb9986c9af99fe3b78b9da59d62cf2f337042c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 3 Nov 2026 15:14:08 +0000 Subject: [PATCH 60/84] SNAP3v: census symbol identity and serial carrier (design 2.10.18, 2.10.17). A fresh symbol is an arena birth: its id word, drawn in creation order (symbol::next_id), is named by the symbol own birth name (PERRY_SNAP_SYMID=0 off; #repw symid=). A meta record proto_serial word is named like the shape prototype input (pser: the birth name of the object carrying the serial). The key-name rule reads a leaked symbol description through symbol_description_text (its header description is null; the text is native), so registry, well-known and runtime symbols are named by kind and description; a registry symbol is not named by its creator, since every later Symbol.for returns it. Fixtures (outside the tree): symid (e.ts joins D by rule 2, f.ts Symbol.for equal, g.ts off differs) and sercar (h.ts off differs). --- .../perry-runtime/src/snap_census/runner.rs | 72 +++++++++++++------ 1 file changed, 52 insertions(+), 20 deletions(-) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index ff24cd5fe74..883c835354c 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -549,6 +549,10 @@ struct State { /// representation: the digest names it by the birth name of the /// prototype that carries it. `PERRY_SNAP_SERIAL=0`. serial: bool, + /// Symbol identity (design §2.10.18): a fresh symbol's id is drawn in + /// creation order (`symbol::next_id`), so the digest names the id word + /// by the symbol's own birth name. `PERRY_SNAP_SYMID=0`. + symid: bool, /// Size of every birth and realm image cell, by start. sizes: SizeMap, /// One bit per 8-byte word of the realm section: an image cell starts. @@ -804,6 +808,7 @@ unsafe fn start(out: String) { keyname: !std::env::var("PERRY_SNAP_KEYNAME").is_ok_and(|v| v == "0"), r3: !std::env::var("PERRY_SNAP_R3").is_ok_and(|v| v == "0"), serial: !std::env::var("PERRY_SNAP_SERIAL").is_ok_and(|v| v == "0"), + symid: !std::env::var("PERRY_SNAP_SYMID").is_ok_and(|v| v == "0"), sizes: SizeMap::new(26), realm_starts: map_zeroed::(((realm.1 - realm.0) / 8).div_ceil(64).max(1)), free_space: 0, @@ -2902,13 +2907,19 @@ fn write_report(st: &mut State) { (0..n).map(|i| first + 8 * i).take_while(|&w| w + 8 <= end).map(|w| rolled(w)).collect() }; // Key-name rule (design §2.10.18): a key is named by what it is, never - // by where it lives. A key in a named cell takes that cell's name (a - // string by its bytes, R2). A symbol the runtime leaked into malloc - // memory (`symbol::leak_symbol`: process-lifetime, no birth; mimalloc - // randomizes its address per run even with ASLR off, and its creation - // id is drawn in creation order) is named by its description and its - // `Symbol.for` bit (two symbols of one description share a name). Any other foreign key word hashes as its tag, like - // every foreign word of the digest. `PERRY_SNAP_KEYNAME=0`: raw words. + // by where it lives. A key in a named cell takes that cell's name: a + // string by its bytes (R2), a fresh `Symbol()` by its birth (it is an + // arena cell, `symbol::alloc_symbol`, born by the init that made it; + // no later code obtains it except through a censused read, so a D + // symbol held by an imaged init is visible as rule 2). A symbol the + // runtime leaked into malloc memory (`symbol::leak_symbol`: no birth; + // mimalloc randomizes its address per run even with ASLR off, and its + // id is drawn in creation order) is a registry (`Symbol.for`), + // well-known or runtime-layout symbol: any later code obtains the same + // symbol by its name, whichever init created it, so it is named by + // its `Symbol.for` bit and its description bytes, not by its creator. + // Any other foreign key word hashes as its tag, like every foreign + // word of the digest. `PERRY_SNAP_KEYNAME=0`: raw words. let kname = |names: &[(usize, usize, u64)], w: u64| -> u64 { let a = (w & 0xFFFF_FFFF_FFFF) as usize; let named = pname(names, w); @@ -2919,18 +2930,15 @@ fn write_report(st: &mut State) { if g.obj_type != crate::gc::GC_TYPE_SYMBOL { return fnv(0x464f_5245_4947_4e00, w >> 48); } - let sym = unsafe { &*(a as *const crate::symbol::SymbolHeader) }; - let d = sym.description as u64; - // The description by its bytes (a string is a value, R2), wherever it lives. - let dn = if d == 0 { - 0 - } else { - let sh = d as *const crate::string::StringHeader; - let n = unsafe { (*sh).byte_len } as usize; - let bytes = unsafe { std::slice::from_raw_parts(crate::string::string_data(sh), n) }; - bytes.iter().fold(fnv(0x5354_5249_4e47_0000, n as u64), |h, &c| fnv(h, c as u64)) + let sym = a as *const crate::symbol::SymbolHeader; + // The description by its bytes (a string is a value, R2), wherever it lives: a + // leaked symbol keeps it as native text and its header's `description` is null, + // so it is read through `symbol_description_text` (the one reader of both homes). + let dn = match unsafe { crate::symbol::symbol_description_text(sym) } { + None => 0, + Some(b) => b.iter().fold(fnv(0x5354_5249_4e47_0000, b.len() as u64), |h, &c| fnv(h, c as u64)), }; - fnv(fnv(fnv(0x5359_4d42_0000_0000, w >> 48), (sym.registered & 1) as u64), dn) + fnv(fnv(fnv(0x5359_4d42_0000_0000, w >> 48), (unsafe { (*sym).registered } & 1) as u64), dn) }; if !keylists.is_empty() { keylists.sort_unstable(); @@ -3138,6 +3146,30 @@ fn write_report(st: &mut State) { } let v = unsafe { *(word as *const u64) }; e.2 += 1; + // The carriers of numbers drawn in creation order name them as + // their uses do: a meta record's dynamic prototype serial by the + // birth name of the object it belongs to (serial rule, §2.10.17; + // `pser`, as a shape's prototype input), and a fresh symbol's id + // by the symbol's own birth name (symbol identity, §2.10.18). A + // serial of no birth hashes as its number, as before. + let q = if st.serial + && ty == crate::gc::GC_TYPE_OBJECT_META + && off - 8 == crate::gc::GC_HEADER_SIZE + std::mem::offset_of!(crate::object::ObjectMeta, proto_serial) + { + pser(v) + } else if st.symid + && ty == crate::gc::GC_TYPE_SYMBOL + && off - 8 == crate::gc::GC_HEADER_SIZE + std::mem::offset_of!(crate::symbol::SymbolHeader, id) + { + Some(fnv(0x5359_4d49_4400_0000, bname)) + } else { + None + }; + if let Some(n) = q { + mix(&mut e.0, n); + dump(st.inits[b.init as usize].addr, bname, off - 8, format!("q{n:x}")); + continue; + } // An ordinary object's first header word holds its class id // and its ShapeId (a closure's: capture count and ShapeId). A ShapeId is the shape's NAME: ids are // handed out in creation order, and the env object's keys @@ -3248,9 +3280,9 @@ fn write_report(st: &mut State) { let _ = writeln!(f, "#excluded\t{}", st.excluded); let _ = writeln!( f, - "#repw\trule={}\twords={}\twrites={}\towned={}\treads={}\tr2={}\tr3={}\tserial={}\tkeyname={}", + "#repw\trule={}\twords={}\twrites={}\towned={}\treads={}\tr2={}\tr3={}\tserial={}\tkeyname={}\tsymid={}", st.r1 as u8, st.repw.len, st.repw_writes, st.repw_owned, st.repw_reads, st.r2 as u8, st.r3 as u8, - st.serial as u8, st.keyname as u8 + st.serial as u8, st.keyname as u8, st.symid as u8 ); let _ = writeln!(f, "#free_space\t{}", st.free_space); let _ = writeln!(f, "#reborn\t{}", st.reborn); From 1c8132303450e1c3bfa5f0568a50d0d21ce3abb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 3 Nov 2026 17:59:48 +0000 Subject: [PATCH 61/84] SNAP3w: census lint and format: the census digest reads a string payload through string::string_data; the census header reads of its own births are allowlisted in the address-classification audit (instr builds only); cargo fmt of the branch hunks --- crates/perry-codegen/src/expr/mod.rs | 2 +- .../perry-codegen/src/expr/snap_env_site.rs | 4 +- crates/perry-runtime/src/lib.rs | 2 +- crates/perry-runtime/src/map/store.rs | 5 +- .../src/object/field_set_by_name.rs | 8 +- .../perry-runtime/src/object/global_this.rs | 4 +- crates/perry-runtime/src/object/native_get.rs | 6 +- crates/perry-runtime/src/snap_census.rs | 7 +- .../perry-runtime/src/snap_census/runner.rs | 383 ++++++++++++++---- scripts/addr_class_allowlist.txt | 2 + 10 files changed, 327 insertions(+), 96 deletions(-) diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 769f96faf41..f47820cec40 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -94,7 +94,6 @@ pub(crate) fn note_site(kind: &str) { } } pub(crate) mod array_length; -pub(crate) mod snap_env_site; mod array_literal; pub(crate) mod array_proto_guard; mod bitset_test; @@ -105,6 +104,7 @@ pub(crate) mod hot_tls; pub(crate) mod inline_birth; pub(crate) mod literal_chunks; mod literal_descriptor; +pub(crate) mod snap_env_site; /// A worker program's literal-descriptor IR, for the dialect reader's round trip. #[cfg(test)] pub(crate) fn literal_descriptor_worker_ir() -> String { diff --git a/crates/perry-codegen/src/expr/snap_env_site.rs b/crates/perry-codegen/src/expr/snap_env_site.rs index 916bc6a26b6..e00a3998d92 100644 --- a/crates/perry-codegen/src/expr/snap_env_site.rs +++ b/crates/perry-codegen/src/expr/snap_env_site.rs @@ -53,9 +53,7 @@ fn note<'e>(children: impl Iterator + Clone) { 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::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)) } diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index 0c52cd724b9..efc5fcbeb8c 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -198,12 +198,12 @@ pub mod native_payload_stream_abi; #[cfg(any(target_os = "linux", target_os = "android"))] mod native_stack; pub mod native_value_profile; -pub mod snap_census; pub mod navigator; pub mod net_validate; pub mod node_api_host; mod param_type_guard; pub(crate) mod runtime_state_key; +pub mod snap_census; mod startup_memory_profile; /// wpfacts slice 2: run-time witness of each elided field access. pub mod wpfacts_check; diff --git a/crates/perry-runtime/src/map/store.rs b/crates/perry-runtime/src/map/store.rs index 7dd8f804956..df64737fe69 100644 --- a/crates/perry-runtime/src/map/store.rs +++ b/crates/perry-runtime/src/map/store.rs @@ -88,7 +88,10 @@ impl MapStore { /// so the digest leaves `[start, end)` out (payload offsets). #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] pub(crate) const fn snap_native_index_range() -> (usize, usize) { - (std::mem::offset_of!(MapStore, numeric), std::mem::size_of::()) + ( + std::mem::offset_of!(MapStore, numeric), + std::mem::size_of::(), + ) } /// Bounded native publication: a creating caller allocates the store before diff --git a/crates/perry-runtime/src/object/field_set_by_name.rs b/crates/perry-runtime/src/object/field_set_by_name.rs index 0df15ce347e..1969680dc87 100644 --- a/crates/perry-runtime/src/object/field_set_by_name.rs +++ b/crates/perry-runtime/src/object/field_set_by_name.rs @@ -50,10 +50,10 @@ pub extern "C" fn js_object_set_field_by_name( ) { // Census builds: a store on the realm global names its key (§2.10.13). #[cfg(perry_gc_instruments)] - let _gkey = crate::snap_census::global_key( - (obj as u64 & crate::value::POINTER_MASK) as usize, - || (!key.is_null()).then(|| crate::regex::string_as_bytes(key)), - ); + let _gkey = + crate::snap_census::global_key((obj as u64 & crate::value::POINTER_MASK) as usize, || { + (!key.is_null()).then(|| crate::regex::string_as_bytes(key)) + }); #[cfg(perry_contract_checks)] crate::contracts::generic_store(obj, key, value); // Case 2c: a family object's late key and a key outside its layout diff --git a/crates/perry-runtime/src/object/global_this.rs b/crates/perry-runtime/src/object/global_this.rs index aa2612eec96..b7c5b467263 100644 --- a/crates/perry-runtime/src/object/global_this.rs +++ b/crates/perry-runtime/src/object/global_this.rs @@ -111,10 +111,10 @@ pub(crate) use ctor_thunks::{ #[cfg(test)] pub(crate) use fetch_globals::collect_before_global_this_alloc_for_test; pub(crate) use fetch_globals::init_fetch_backed_receiver; -#[cfg(perry_gc_instruments)] -pub(crate) use fetch_globals::thread_realm_global_addr; #[cfg(feature = "temporal")] pub(crate) use fetch_globals::temporal_subclass_super; +#[cfg(perry_gc_instruments)] +pub(crate) use fetch_globals::thread_realm_global_addr; pub(crate) use fetch_globals::{ global_this_blob_thunk, global_this_builtin_noop_thunk, global_this_file_thunk, global_this_headers_thunk, global_this_is_materialized, global_this_request_thunk, diff --git a/crates/perry-runtime/src/object/native_get.rs b/crates/perry-runtime/src/object/native_get.rs index 97589f48306..10107a90290 100644 --- a/crates/perry-runtime/src/object/native_get.rs +++ b/crates/perry-runtime/src/object/native_get.rs @@ -141,7 +141,11 @@ unsafe fn try_data_lookup_key( // Census builds: a lookup on the realm global names its key (§2.10.13). #[cfg(perry_gc_instruments)] let _gkey = crate::snap_census::global_key( - if receiver.is_pointer() { receiver.as_pointer::() as usize } else { 0 }, + if receiver.is_pointer() { + receiver.as_pointer::() as usize + } else { + 0 + }, || Some(key), ); if !receiver.is_pointer() || key.first() == Some(&b'#') || key == b"constructor" { diff --git a/crates/perry-runtime/src/snap_census.rs b/crates/perry-runtime/src/snap_census.rs index e0aecc18c43..78ab0cd5824 100644 --- a/crates/perry-runtime/src/snap_census.rs +++ b/crates/perry-runtime/src/snap_census.rs @@ -486,7 +486,12 @@ pub extern "C" fn js_snap_tls_program( ) { crate::effect!(pure); #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] - runner::tls_program(data_begin as usize, data_end as usize, bss_begin as usize, bss_end as usize); + runner::tls_program( + data_begin as usize, + data_end as usize, + bss_begin as usize, + bss_end as usize, + ); #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] let _ = (data_begin, data_end, bss_begin, bss_end); } diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 883c835354c..ce7cf80ce89 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -58,7 +58,12 @@ static ANCHOR: Anchor = Anchor { on: AtomicBool::new(false), started: AtomicU8::new(0), state: AtomicPtr::new(std::ptr::null_mut()), - tls: [AtomicUsize::new(0), AtomicUsize::new(0), AtomicUsize::new(0), AtomicUsize::new(0)], + tls: [ + AtomicUsize::new(0), + AtomicUsize::new(0), + AtomicUsize::new(0), + AtomicUsize::new(0), + ], _pad: [0; PAGE - 48], }; @@ -105,7 +110,10 @@ unsafe fn map_zeroed(count: usize) -> *mut T { -1, 0, ); - assert!(p != libc::MAP_FAILED, "snap census: mmap {bytes} bytes failed"); + assert!( + p != libc::MAP_FAILED, + "snap census: mmap {bytes} bytes failed" + ); p.cast() } @@ -754,15 +762,19 @@ unsafe fn start(out: String) { // them freely). Without the anchors (not a census compile) no TLS page is // tracked and every TLS slot counts as untracked. let t: [usize; 4] = std::array::from_fn(|i| ANCHOR.tls[i].load(Relaxed)); - let tls_whole = t[0] != 0 - && t.iter().all(|&a| a % PAGE == 0) - && t[0] <= t[1] - && t[2] <= t[3]; - let (tls_lo, tls_hi, tlsb_lo, tlsb_hi) = - if tls_whole { (t[0], t[1], t[2], t[3]) } else { (0, 0, 0, 0) }; + let tls_whole = t[0] != 0 && t.iter().all(|&a| a % PAGE == 0) && t[0] <= t[1] && t[2] <= t[3]; + let (tls_lo, tls_hi, tlsb_lo, tlsb_hi) = if tls_whole { + (t[0], t[1], t[2], t[3]) + } else { + (0, 0, 0, 0) + }; // x86-64 variant II: the static TLS block ends at the thread pointer. let mut fs = 0usize; - libc::syscall(libc::SYS_arch_prctl, 0x1003 /* ARCH_GET_FS */, &mut fs as *mut usize); + libc::syscall( + libc::SYS_arch_prctl, + 0x1003, /* ARCH_GET_FS */ + &mut fs as *mut usize, + ); let tls_block = (fs.saturating_sub(executable_tls_size()), fs); let mut st = Box::new(State { out, @@ -882,7 +894,9 @@ unsafe fn start(out: String) { env_storage: Vec::new(), slot_owner: Vec::new(), skip: { - let mut v = std::env::var("PERRY_SNAP_SKIP").map(|p| read_hex_lines(&p)).unwrap_or_default(); + let mut v = std::env::var("PERRY_SNAP_SKIP") + .map(|p| read_hex_lines(&p)) + .unwrap_or_default(); v.sort_unstable(); v }, @@ -1124,7 +1138,14 @@ fn set_mode_for_top(st: &mut State) { let top = st.frames.last().map_or(0, |f| f.init) as usize; if st.mark { // A syscall trace (strace) attributes what follows to this init. - unsafe { libc::syscall(libc::SYS_lseek, -1 as libc::c_int, top as libc::c_long, 0x5a) }; + unsafe { + libc::syscall( + libc::SYS_lseek, + -1 as libc::c_int, + top as libc::c_long, + 0x5a, + ) + }; } let want = top != 0 && st.inits[top].in_d && !st.noprotect; if want != st.d_mode { @@ -1332,8 +1353,12 @@ pub(super) fn meta_installed(slot: usize, meta: usize) { if !st.meta_rule || !st.rule4_armed || !pre_existing(st, slot) { return; } - let Some(h) = object_start(st, meta) else { return }; - let Some(size) = st.sizes.get(h as u64) else { return }; + let Some(h) = object_start(st, meta) else { + return; + }; + let Some(size) = st.sizes.get(h as u64) else { + return; + }; let e = h + size as usize; let i = st.pre_sorted.partition_point(|&(a, _)| a <= h); st.pre_sorted.insert(i, (h, e)); @@ -1406,7 +1431,9 @@ pub(super) fn attached(obj: usize, cells: &[usize]) { if st.transparent.is_empty() { return; } - let Some(h) = object_start(st, obj) else { return }; + let Some(h) = object_start(st, obj) else { + return; + }; if !st.transp.contains(h as u64) { return; } @@ -1433,7 +1460,10 @@ pub(super) fn dyn_code(kind: u8) { st.inits[top].evals += 1; } if st.dyn_defer { - host(Effect::HostWrite, if kind == 0 { "new_function" } else { "eval" }); + host( + Effect::HostWrite, + if kind == 0 { "new_function" } else { "eval" }, + ); } } @@ -1466,8 +1496,7 @@ pub(super) fn host(class: Effect, what: &'static str) { set_mode_for_top(st); } let i = &mut st.inits[top as usize]; - if i.host.is_none() || (class != Effect::TargetFact && i.host.is_some_and(|h| h.class == 1)) - { + if i.host.is_none() || (class != Effect::TargetFact && i.host.is_some_and(|h| h.class == 1)) { let mut pcs = [0usize; crate::error::MAX_CAPTURED_FRAMES]; let n = crate::error::capture_ips(&mut pcs); let mut six = [0usize; 6]; @@ -1515,8 +1544,11 @@ pub(super) fn birth(addr: usize, size: usize) { // 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. - let earlier = object_start(st, addr + size - 1) - .is_some_and(|h| st.sizes.get(h as u64).is_some_and(|s| addr < h + s as usize)); + let earlier = object_start(st, addr + size - 1).is_some_and(|h| { + st.sizes + .get(h as u64) + .is_some_and(|s| addr < h + s as usize) + }); if earlier { st.reborn += 1; for w in (addr..addr + size).step_by(8) { @@ -1646,7 +1678,10 @@ fn executable_tls_size() -> usize { #[inline] fn page_flags(st: &State, addr: usize) -> Option<*mut u8> { - st.heap.slot(addr).or_else(|| st.bin.slot(addr)).or_else(|| tls_slot(st, addr)) + st.heap + .slot(addr) + .or_else(|| st.bin.slot(addr)) + .or_else(|| tls_slot(st, addr)) } #[inline] @@ -1865,7 +1900,6 @@ struct Prev(std::mem::MaybeUninit); static mut PREV_SEGV: Prev = Prev(std::mem::MaybeUninit::uninit()); static mut PREV_TRAP: Prev = Prev(std::mem::MaybeUninit::uninit()); - unsafe fn install_handlers() { // The handlers run on their own stack: a fault whose frame cannot be // pushed on the current stack (a protected page) still reaches them. @@ -1880,12 +1914,20 @@ unsafe fn install_handlers() { a.sa_sigaction = on_segv as *const () as usize; a.sa_flags = libc::SA_SIGINFO | libc::SA_NODEFER | libc::SA_ONSTACK; libc::sigemptyset(&mut a.sa_mask); - libc::sigaction(libc::SIGSEGV, &a, std::ptr::addr_of_mut!(PREV_SEGV.0).cast()); + libc::sigaction( + libc::SIGSEGV, + &a, + std::ptr::addr_of_mut!(PREV_SEGV.0).cast(), + ); let mut t: libc::sigaction = std::mem::zeroed(); t.sa_sigaction = on_trap as *const () as usize; t.sa_flags = libc::SA_SIGINFO | libc::SA_ONSTACK; libc::sigemptyset(&mut t.sa_mask); - libc::sigaction(libc::SIGTRAP, &t, std::ptr::addr_of_mut!(PREV_TRAP.0).cast()); + libc::sigaction( + libc::SIGTRAP, + &t, + std::ptr::addr_of_mut!(PREV_TRAP.0).cast(), + ); } /// One stderr line of hex words, without allocating (signal context). @@ -1997,8 +2039,23 @@ unsafe fn on_segv_inner( 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); - let range = if st.heap.slot(addr).is_some() { 1 } else if st.bin.slot(addr).is_some() { 2 } else if tls_slot(st, addr).is_some() { 3 } else { 0 }; - diag(&[addr as u64, pc, flags, range, st.d_mode as u64, st.top_mode as u64]); + let range = if st.heap.slot(addr).is_some() { + 1 + } else if st.bin.slot(addr).is_some() { + 2 + } else if tls_slot(st, addr).is_some() { + 3 + } else { + 0 + }; + diag(&[ + addr as u64, + pc, + flags, + range, + st.d_mode as u64, + st.top_mode as u64, + ]); return chain(std::ptr::addr_of!(PREV_SEGV.0).cast(), sig); } let uc = ctx as *mut libc::ucontext_t; @@ -2180,7 +2237,9 @@ fn note_access(st: &mut State, word: usize, write: bool, pc: usize) { // A word outside every born object is free space the allocator // touched (block setup), not a location. let inside = cell.is_some_and(|h| { - st.sizes.get(h as u64).is_some_and(|s| word < h + s as usize) + st.sizes + .get(h as u64) + .is_some_and(|s| word < h + s as usize) }); if !inside { st.free_space += 1; @@ -2234,7 +2293,11 @@ fn note_access(st: &mut State, word: usize, write: bool, pc: usize) { for (loc, w) in locs { note_loc(st, top, word, loc, w, pc); } - if !had && key != 0 && st.inits[top].conflict.is_some() && st.gkey_conflicts.len() < GKEY_CONFLICTS_MAX { + if !had + && key != 0 + && st.inits[top].conflict.is_some() + && st.gkey_conflicts.len() < GKEY_CONFLICTS_MAX + { // Capacity is reserved: no allocation in the fault handler. let e = (top as u32, key, st.gkey_name, st.gkey_len); st.gkey_conflicts.push(e); @@ -2251,7 +2314,9 @@ fn note_repw(st: &mut State, word: usize) { if st.repw.insert(word as u64) { // A store classified after its step names the value before it; at // the fault the word still holds it (its page is open). - let old = st.store_before.unwrap_or_else(|| unsafe { *(word as *const u64) }); + let old = st + .store_before + .unwrap_or_else(|| unsafe { *(word as *const u64) }); st.repw_old.push((word as u64, old)); } } @@ -2276,7 +2341,11 @@ fn repr_access(st: &mut State, word: usize, write: bool, pc: usize) { } else { realm_cell(st, word) }; - let Some(h) = cell.filter(|&h| st.sizes.get(h as u64).is_some_and(|s| word < h + s as usize)) else { + let Some(h) = cell.filter(|&h| { + st.sizes + .get(h as u64) + .is_some_and(|s| word < h + s as usize) + }) else { st.faults_ignored += 1; return; }; @@ -2392,11 +2461,7 @@ fn note_loc(st: &mut State, top: usize, word: usize, w: u64, write: bool, pc: us Some(if write { 3 } else { 2 }) } else if write && st.r.contains(w) { Some(3) - } else if write - && st.rule4_armed - && st.inits[top].dynamic - && pre_existing(st, word) - { + } else if write && st.rule4_armed && st.inits[top].dynamic && pre_existing(st, word) { Some(4) } else { None @@ -2470,9 +2535,13 @@ pub unsafe extern "C" fn getenv(name: *const libc::c_char) -> *mut libc::c_char 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 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)); } } found @@ -2598,7 +2667,9 @@ extern "C" fn rset_report() { let _ = writeln!(f, "#hist\t<{}\t{n}", 1u64 << b); } } - let mut top: Vec = (0..st.cand.len()).filter(|&i| st.cand_writes[i] > 0).collect(); + let mut top: Vec = (0..st.cand.len()) + .filter(|&i| st.cand_writes[i] > 0) + .collect(); top.sort_unstable_by_key(|&i| std::cmp::Reverse(st.cand_writes[i])); let births = st.births.as_slice(); for &i in top.iter().take(200) { @@ -2655,7 +2726,11 @@ pub(super) fn after_boundary_collection() { use std::io::Write; let path = format!("{}.live.tsv", st.out); if let Ok(mut f) = std::fs::File::create(&path) { - let _ = writeln!(f, "#unattributed\t{}\t{}", st.live_unattributed[0], st.live_unattributed[1]); + let _ = writeln!( + f, + "#unattributed\t{}\t{}", + st.live_unattributed[0], st.live_unattributed[1] + ); let mut rows: Vec<_> = st.live_by.iter().collect(); rows.sort(); for ((id, t), row) in rows { @@ -2850,13 +2925,22 @@ fn write_report(st: &mut State) { ENV_NAME } else if kind == 1 { let byte_len = (rolled(user) >> 32) as usize; - let p = user + std::mem::size_of::(); + let p = + crate::string::string_data(user as *const crate::string::StringHeader) as usize; let n = byte_len.min(end.saturating_sub(p)); let bytes = unsafe { std::slice::from_raw_parts(p as *const u8, n) }; - bytes.iter().fold(fnv(0x5354_5249_4e47_0000, n as u64), |h, &c| fnv(h, c as u64)) + bytes + .iter() + .fold(fnv(0x5354_5249_4e47_0000, n as u64), |h, &c| { + fnv(h, c as u64) + }) } else if kind == 2 { let words = unsafe { crate::regex::perex_owner::snap_program_words(user) }; - words.iter().fold(fnv(0x5052_4f47_0000_0000, words.len() as u64), |h, &x| fnv(h, x as u64)) + words + .iter() + .fold(fnv(0x5052_4f47_0000_0000, words.len() as u64), |h, &x| { + fnv(h, x as u64) + }) } else if kind == 3 { keylists.push(b.addr); 0 @@ -2903,8 +2987,13 @@ fn write_report(st: &mut State) { let attrs = unsafe { (*(user as *const crate::gc::GcHeader).sub(1))._reserved } & crate::gc::GC_ARRAY_KEY_ATTRS != 0; - let first = user + std::mem::size_of::() + if attrs { 8 } else { 0 }; - (0..n).map(|i| first + 8 * i).take_while(|&w| w + 8 <= end).map(|w| rolled(w)).collect() + let first = + user + std::mem::size_of::() + if attrs { 8 } else { 0 }; + (0..n) + .map(|i| first + 8 * i) + .take_while(|&w| w + 8 <= end) + .map(|w| rolled(w)) + .collect() }; // Key-name rule (design §2.10.18): a key is named by what it is, never // by where it lives. A key in a named cell takes that cell's name: a @@ -2936,9 +3025,19 @@ fn write_report(st: &mut State) { // so it is read through `symbol_description_text` (the one reader of both homes). let dn = match unsafe { crate::symbol::symbol_description_text(sym) } { None => 0, - Some(b) => b.iter().fold(fnv(0x5354_5249_4e47_0000, b.len() as u64), |h, &c| fnv(h, c as u64)), + Some(b) => b + .iter() + .fold(fnv(0x5354_5249_4e47_0000, b.len() as u64), |h, &c| { + fnv(h, c as u64) + }), }; - fnv(fnv(fnv(0x5359_4d42_0000_0000, w >> 48), (unsafe { (*sym).registered } & 1) as u64), dn) + fnv( + fnv( + fnv(0x5359_4d42_0000_0000, w >> 48), + (unsafe { (*sym).registered } & 1) as u64, + ), + dn, + ) }; if !keylists.is_empty() { keylists.sort_unstable(); @@ -2951,7 +3050,9 @@ fn write_report(st: &mut State) { let len = (rolled(user) & 0xFFFF_FFFF) as usize; let h = key_words(user, n.1, len) .into_iter() - .fold(fnv(0x4b45_5953_0000_0000, len as u64), |h, w| fnv(h, kname(&names, w))); + .fold(fnv(0x4b45_5953_0000_0000, len as u64), |h, w| { + fnv(h, kname(&names, w)) + }); (i, h) }) .collect(); @@ -2964,20 +3065,34 @@ fn write_report(st: &mut State) { let shape_keys = |keys: u64, count: usize| -> u64 { let v = (keys & 0xFFFF_FFFF_FFFF) as usize; let i = names.partition_point(|&(a, _, _)| a <= v); - if !st.r2 || i == 0 || v >= names[i - 1].1 || v != names[i - 1].0 + crate::gc::GC_HEADER_SIZE { + if !st.r2 + || i == 0 + || v >= names[i - 1].1 + || v != names[i - 1].0 + crate::gc::GC_HEADER_SIZE + { return pname(&names, keys); } key_words(v, names[i - 1].1, count) .into_iter() - .fold(fnv(0x4b45_5953_0000_0000, count as u64), |h, w| fnv(h, kname(&names, w))) + .fold(fnv(0x4b45_5953_0000_0000, count as u64), |h, w| { + fnv(h, kname(&names, w)) + }) }; // Diagnostic (the dig dump): a shape key input key by key: L (a named heap // list) or P (pointer named by pname), then each key word's raw value and name. let shape_keys_tok = |keys: u64, count: usize| -> String { let v = (keys & 0xFFFF_FFFF_FFFF) as usize; let i = names.partition_point(|&(a, _, _)| a <= v); - if !st.r2 || i == 0 || v >= names[i - 1].1 || v != names[i - 1].0 + crate::gc::GC_HEADER_SIZE { - let (n, o) = if i > 0 && v < names[i - 1].1 { (names[i - 1].2, v - names[i - 1].0) } else { (0, 0) }; + if !st.r2 + || i == 0 + || v >= names[i - 1].1 + || v != names[i - 1].0 + crate::gc::GC_HEADER_SIZE + { + let (n, o) = if i > 0 && v < names[i - 1].1 { + (names[i - 1].2, v - names[i - 1].0) + } else { + (0, 0) + }; return format!("P{keys:x}={n:x}+{o}"); } let ws = key_words(v, names[i - 1].1, count); @@ -2999,7 +3114,8 @@ fn write_report(st: &mut State) { .iter() .filter_map(|&(a, _, n)| { let bits = - crate::value::js_nanbox_pointer((a + crate::gc::GC_HEADER_SIZE) as i64).to_bits(); + crate::value::js_nanbox_pointer((a + crate::gc::GC_HEADER_SIZE) as i64) + .to_bits(); let s = unsafe { crate::object::shapes::prototype_serial(bits) }; (s >= crate::object::proto_validity::FIRST_DYNAMIC_PROTOTYPE_SERIAL && s < crate::object::shapes::PROTO_ID_CLASS) @@ -3013,7 +3129,9 @@ fn write_report(st: &mut State) { // identity, or a MIXED one (class id beside the serial). None: not a // named dynamic serial (the identity hashes as before). let pser = |id: u64| -> Option { - use crate::object::shapes::{PROTO_ID_CLASS, PROTO_ID_MIXED, PROTO_ID_MIXED_SERIAL_BITS, PROTO_ID_UNIQUE}; + use crate::object::shapes::{ + PROTO_ID_CLASS, PROTO_ID_MIXED, PROTO_ID_MIXED_SERIAL_BITS, PROTO_ID_UNIQUE, + }; let (hi, s) = if id < PROTO_ID_CLASS { (0, id) } else if (PROTO_ID_MIXED..PROTO_ID_UNIQUE).contains(&id) { @@ -3025,7 +3143,9 @@ fn write_report(st: &mut State) { if s < crate::object::proto_validity::FIRST_DYNAMIC_PROTOTYPE_SERIAL { return None; } - serial_names.get(&s).map(|&n| fnv(fnv(0x5345_5249_414c_0000, hi), n)) + serial_names + .get(&s) + .map(|&n| fnv(fnv(0x5345_5249_414c_0000, hi), n)) }; let name_of = |v: usize| -> Option<(u64, usize)> { let i = names.partition_point(|&(a, _, _)| a <= v); @@ -3077,12 +3197,18 @@ fn write_report(st: &mut State) { .or_insert((0xcbf29ce484222325, 0, 0)); e.1 += 1; let ty = unsafe { (*(b.addr as *const crate::gc::GcHeader)).obj_type }; - dump(st.inits[b.init as usize].addr, bname, 0, format!("t{ty}:{}", b.size)); + dump( + st.inits[b.init as usize].addr, + bname, + 0, + format!("t{ty}:{}", b.size), + ); // R3 (design §2.10.16): a WeakStorage's bucket positions and // entry order are derived state (they hash raw addresses): it // digests its live entry set, sorted. if st.r3 && ty == crate::gc::GC_TYPE_WEAK_STORAGE { - let ws = (b.addr + crate::gc::GC_HEADER_SIZE) as *const crate::weakref::storage::WeakStorage; + let ws = (b.addr + crate::gc::GC_HEADER_SIZE) + as *const crate::weakref::storage::WeakStorage; let mut ents: Vec<(u64, u64)> = Vec::new(); unsafe { let n = (*ws).len.min((*ws).capacity) as usize; @@ -3112,10 +3238,17 @@ fn write_report(st: &mut State) { } } ents.sort_unstable(); - let h = ents.iter().fold(0xcbf29ce484222325u64, |h, &(k, v)| fnv(fnv(h, k), v)); + let h = ents + .iter() + .fold(0xcbf29ce484222325u64, |h, &(k, v)| fnv(fnv(h, k), v)); e.2 += 1; mix(&mut e.0, h); - dump(st.inits[b.init as usize].addr, bname, crate::gc::GC_HEADER_SIZE, format!("k{h:x}:{}", ents.len())); + dump( + st.inits[b.init as usize].addr, + bname, + crate::gc::GC_HEADER_SIZE, + format!("k{h:x}:{}", ents.len()), + ); continue; } // The GC header (mark and move-request bits) is no JS state. @@ -3154,12 +3287,16 @@ fn write_report(st: &mut State) { // serial of no birth hashes as its number, as before. let q = if st.serial && ty == crate::gc::GC_TYPE_OBJECT_META - && off - 8 == crate::gc::GC_HEADER_SIZE + std::mem::offset_of!(crate::object::ObjectMeta, proto_serial) + && off - 8 + == crate::gc::GC_HEADER_SIZE + + std::mem::offset_of!(crate::object::ObjectMeta, proto_serial) { pser(v) } else if st.symid && ty == crate::gc::GC_TYPE_SYMBOL - && off - 8 == crate::gc::GC_HEADER_SIZE + std::mem::offset_of!(crate::symbol::SymbolHeader, id) + && off - 8 + == crate::gc::GC_HEADER_SIZE + + std::mem::offset_of!(crate::symbol::SymbolHeader, id) { Some(fnv(0x5359_4d49_4400_0000, bname)) } else { @@ -3167,7 +3304,12 @@ fn write_report(st: &mut State) { }; if let Some(n) = q { mix(&mut e.0, n); - dump(st.inits[b.init as usize].addr, bname, off - 8, format!("q{n:x}")); + dump( + st.inits[b.init as usize].addr, + bname, + off - 8, + format!("q{n:x}"), + ); continue; } // An ordinary object's first header word holds its class id @@ -3201,15 +3343,18 @@ fn write_report(st: &mut State) { d.rep, d.special_constfn_mask as u64, ]; - let sname = - parts.iter().fold(0xcbf29ce484222325u64, |h, &x| (h ^ x).wrapping_mul(0x100000001B3)); + let sname = parts.iter().fold(0xcbf29ce484222325u64, |h, &x| { + (h ^ x).wrapping_mul(0x100000001B3) + }); mix(&mut e.0, v & 0xFFFF_FFFF); mix(&mut e.0, sname); // The dump shows the inputs too (keys, prototype, the rest), so a // differing shape word names its differing field. - let rest = parts[5..].iter().fold(parts[1] ^ parts[2] << 20 ^ parts[3] << 40, |h, &x| { - (h ^ x).wrapping_mul(0x100000001B3) - }); + let rest = parts[5..] + .iter() + .fold(parts[1] ^ parts[2] << 20 ^ parts[3] << 40, |h, &x| { + (h ^ x).wrapping_mul(0x100000001B3) + }); let tok = format!( "s{:x}:{sname:x}|k{:x}|p{:x}|r{rest:x}|K{}", v & 0xFFFF_FFFF, @@ -3227,17 +3372,32 @@ fn write_report(st: &mut State) { mix(&mut e.0, hi ^ 0xA5A5); mix(&mut e.0, n); mix(&mut e.0, o as u64); - dump(st.inits[b.init as usize].addr, bname, off - 8, format!("p{hi:x}:{n:x}+{o}")); + dump( + st.inits[b.init as usize].addr, + bname, + off - 8, + format!("p{hi:x}:{n:x}+{o}"), + ); continue; } } if foreign(v) { mix(&mut e.0, v >> 48); - dump(st.inits[b.init as usize].addr, bname, off - 8, format!("f{:x}", v >> 48)); + dump( + st.inits[b.init as usize].addr, + bname, + off - 8, + format!("f{:x}", v >> 48), + ); continue; } mix(&mut e.0, v); - dump(st.inits[b.init as usize].addr, bname, off - 8, format!("v{v:x}")); + dump( + st.inits[b.init as usize].addr, + bname, + off - 8, + format!("v{v:x}"), + ); } } // The module-global slots each imaged init registered. @@ -3257,13 +3417,23 @@ fn write_report(st: &mut State) { mix(&mut e.0, hi ^ 0xA5A5); mix(&mut e.0, n); mix(&mut e.0, o as u64); - dump(st.inits[owner as usize].addr, 0, slot, format!("p{hi:x}:{n:x}+{o}")); + dump( + st.inits[owner as usize].addr, + 0, + slot, + format!("p{hi:x}:{n:x}+{o}"), + ); continue; } } if foreign(v) { mix(&mut e.0, v >> 48); - dump(st.inits[owner as usize].addr, 0, slot, format!("f{:x}", v >> 48)); + dump( + st.inits[owner as usize].addr, + 0, + slot, + format!("f{:x}", v >> 48), + ); continue; } mix(&mut e.0, v); @@ -3287,7 +3457,13 @@ fn write_report(st: &mut State) { let _ = writeln!(f, "#free_space\t{}", st.free_space); let _ = writeln!(f, "#reborn\t{}", st.reborn); let _ = writeln!(f, "#silent_stores\t{}", st.silent_stores); - let _ = writeln!(f, "#transparent\t{}\t{}\t{}", st.transparent.len(), st.transp.len, st.transparent_hits); + let _ = writeln!( + f, + "#transparent\t{}\t{}\t{}", + st.transparent.len(), + st.transp.len, + st.transparent_hits + ); let _ = writeln!(f, "#foreign_thread_faults\t{}", st.foreign_faults); let _ = writeln!( f, @@ -3635,7 +3811,13 @@ fn slot_name(slot: usize) -> String { fn tsv_text(s: &str) -> String { s.chars() - .map(|c| if c == '\t' || c == '\n' || c == '\r' { ' ' } else { c }) + .map(|c| { + if c == '\t' || c == '\n' || c == '\r' { + ' ' + } else { + c + } + }) .collect() } @@ -3667,7 +3849,10 @@ fn write_env_report(st: &State, f: &mut std::fs::File, rank: &[u64]) { // wrote are not image words. let mut targets: std::collections::HashMap> = Default::default(); if st.env_obj != 0 { - targets.entry(st.env_obj & 0xFFFF_FFFF_FFFF).or_default().push(-1); + targets + .entry(st.env_obj & 0xFFFF_FFFF_FFFF) + .or_default() + .push(-1); } // A var some imaged init observes is a guard member: the guard makes // every imaged string of it right, so its store-site reads need no cell. @@ -3675,14 +3860,19 @@ fn write_env_report(st: &State, f: &mut std::fs::File, rank: &[u64]) { .env_obs .iter() .filter(|o| { - matches!(o.kind, OBS_OBSERVE | OBS_HAS | OBS_STORE_UNSET | OBS_STORE_READ) - && (o.init == 0 || !in_d(o.init)) + matches!( + o.kind, + OBS_OBSERVE | OBS_HAS | OBS_STORE_UNSET | OBS_STORE_READ + ) && (o.init == 0 || !in_d(o.init)) }) .map(|o| o.name.as_str()) .collect(); for (k, o) in st.env_obs.iter().enumerate() { if o.kind == OBS_STORE && !in_d(o.init) && !guarded.contains(o.name.as_str()) { - targets.entry(o.ptr as u64 & 0xFFFF_FFFF_FFFF).or_default().push(k as i64); + targets + .entry(o.ptr as u64 & 0xFFFF_FFFF_FFFF) + .or_default() + .push(k as i64); } } let mut found = vec![0u32; st.env_obs.len()]; @@ -3697,7 +3887,11 @@ fn write_env_report(st: &State, f: &mut std::fs::File, rank: &[u64]) { found[t as usize] += 1; // The alias hint at the boundary (0 shared, 1 unique). let alias = unsafe { (*(o.ptr as *const crate::StringHeader)).refcount }; - let _ = writeln!(f, "#cell\tenv_var\t{name}\t{}\t{}\talias={alias}", o.name, o.init); + let _ = writeln!( + f, + "#cell\tenv_var\t{name}\t{}\t{}\talias={alias}", + o.name, o.init + ); } } }; @@ -3752,7 +3946,12 @@ fn write_env_report(st: &State, f: &mut std::fs::File, rank: &[u64]) { // The guard: every other observation by an imaged init (the runtime's // own, init 0, included: the IPC probe decides the process object). let mut seen: std::collections::HashSet<(String, u32, &'static str)> = Default::default(); - let mut guard = |name: &str, value: Option<&str>, init: u32, consumer: &'static str, by: (u32, usize), f: &mut std::fs::File| { + let mut guard = |name: &str, + value: Option<&str>, + init: u32, + consumer: &'static str, + by: (u32, usize), + f: &mut std::fs::File| { if init != 0 && in_d(init) { return; } @@ -3773,7 +3972,14 @@ fn write_env_report(st: &State, f: &mut std::fs::File, rank: &[u64]) { OBS_OBSERVE => guard(&o.name, o.value.as_deref(), o.init, "observe", (0, 0), f), OBS_HAS => guard(&o.name, o.value.as_deref(), o.init, "has", (0, 0), f), OBS_STORE_UNSET => guard(&o.name, None, o.init, "store_unset", (0, 0), f), - OBS_STORE_READ => guard(&o.name, o.value.as_deref(), o.init, "store_read", (o.by, o.by_pc), f), + OBS_STORE_READ => guard( + &o.name, + o.value.as_deref(), + o.init, + "store_read", + (o.by, o.by_pc), + f, + ), OBS_STORE if found[k] == 0 && !in_d(o.init) && !guarded.contains(o.name.as_str()) => { let _ = writeln!(f, "#cell_unstored\t{}\t{}", o.name, o.init); } @@ -3794,9 +4000,22 @@ fn write_env_report(st: &State, f: &mut std::fs::File, rank: &[u64]) { } } let takers = st.env_obs.iter().filter(|o| o.kind == OBS_OBJECT).count(); - let _ = writeln!(f, "#env\trule={}\tq7={}\tobs={}\tobject_takers={takers}\tcheck={}", st.env_rule as u8, st.q7 as u8, st.env_obs.len(), !st.skip.is_empty() as u8); + let _ = writeln!( + f, + "#env\trule={}\tq7={}\tobs={}\tobject_takers={takers}\tcheck={}", + st.env_rule as u8, + st.q7 as u8, + st.env_obs.len(), + !st.skip.is_empty() as u8 + ); for &(init, word, pc, write) in &st.check_fail { - let _ = writeln!(f, "#checkfail\tpoison\t{init}\t{:x}\t{}\t{pc:x}\t{}", addr_of(init), slot_name(word), write as u8); + let _ = writeln!( + f, + "#checkfail\tpoison\t{init}\t{:x}\t{}\t{pc:x}\t{}", + addr_of(init), + slot_name(word), + write as u8 + ); } if !st.skip.is_empty() { for (id, i) in st.inits.iter().enumerate().skip(1) { diff --git a/scripts/addr_class_allowlist.txt b/scripts/addr_class_allowlist.txt index d6114486895..14044148a3e 100644 --- a/scripts/addr_class_allowlist.txt +++ b/scripts/addr_class_allowlist.txt @@ -198,3 +198,5 @@ crates/perry-runtime/src/array/indexing_read_cold.rs | as *const crate::gc::GcHe crates/perry-runtime/src/closure/alloc.rs | (*(raw.sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader))._reserved | existing fixed-capture birth debug assertion: raw is the fresh user pointer returned by closure_alloc_storage_no_collect in the same scope, with no collection before the assertion; the compact bound birth no longer has this assertion crates/perry-runtime/src/json_tape.rs | let header = (hdr as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; | is_lazy_array: hdr is a live GC user pointer by contract (callers hold the array being classified); reads its own header kind, the same discipline as the allocator walks. crates/perry-runtime/src/json_tape.rs | let header = (hdr as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; | lazy-to-ordinary flip: hdr is the live lazy array being materialized in place; the header kind is rewritten to GC_TYPE_ARRAY. +# SNAP census (census-only, cfg(perry_gc_instruments)): headers of births the census recorded at allocation, read while it holds collection. +crates/perry-runtime/src/snap_census/runner.rs | as *const crate::gc::GcHeader | Census digest (instr builds only): every address is a GC birth the census recorded at allocation (or the header before such a birth user pointer, or a runtime-leaked symbol header in the malloc range), read before the boundary while the census holds collection, so no object moved or died. From f34c2ccd4de6f7a84428126251d431c8d5e5c9fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 3 Nov 2026 18:13:05 +0000 Subject: [PATCH 62/84] SNAP3x: census key-name rule on the symbol space (design 2.10.19). Since #13817 every persistent symbol is a cell of the process immortal symbol space, bump allocated in creation order, so its address is a creation ordinal: an init in D that creates a persistent symbol first shifts every later one. The key-name rule admits a key word by the space own range and cell stride (symbol::in_symbol_space, instruments builds only) instead of mimalloc membership, and names it by its kind bits (Symbol.for, well-known) and description bytes. Fixture keyname2 (outside the tree): d.ts (D) creates registry symbols before a.ts keys objects by Symbol.for; off differs a.ts. --- .../perry-runtime/src/snap_census/runner.rs | 26 ++++++++++++------- crates/perry-runtime/src/symbol.rs | 11 ++++++++ 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index ce7cf80ce89..6a35d6a8be1 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -3000,19 +3000,23 @@ fn write_report(st: &mut State) { // string by its bytes (R2), a fresh `Symbol()` by its birth (it is an // arena cell, `symbol::alloc_symbol`, born by the init that made it; // no later code obtains it except through a censused read, so a D - // symbol held by an imaged init is visible as rule 2). A symbol the - // runtime leaked into malloc memory (`symbol::leak_symbol`: no birth; - // mimalloc randomizes its address per run even with ASLR off, and its - // id is drawn in creation order) is a registry (`Symbol.for`), - // well-known or runtime-layout symbol: any later code obtains the same + // symbol held by an imaged init is visible as rule 2). A persistent + // symbol (`symbol::leak_symbol`: no birth) is a cell of the process + // immortal symbol space, admitted by the space's own range and cell + // stride (`symbol::in_symbol_space`, design 2.10.19). Its cells are + // bump allocated in creation order, so its address (and its id) is a + // creation ordinal: an init in D that creates a persistent symbol + // first shifts every later one. It is a registry (`Symbol.for`), + // well-known, Intl or runtime symbol: any later code obtains the same // symbol by its name, whichever init created it, so it is named by - // its `Symbol.for` bit and its description bytes, not by its creator. - // Any other foreign key word hashes as its tag, like every foreign - // word of the digest. `PERRY_SNAP_KEYNAME=0`: raw words. + // its kind bits (`Symbol.for`, well-known) and its description + // bytes, not by its creator nor by its address. Any other foreign + // key word hashes as its tag, like every foreign word of the digest. + // `PERRY_SNAP_KEYNAME=0`: raw words. let kname = |names: &[(usize, usize, u64)], w: u64| -> u64 { let a = (w & 0xFFFF_FFFF_FFFF) as usize; let named = pname(names, w); - if !st.keyname || named != w || !in_malloc(a) || a < crate::gc::GC_HEADER_SIZE { + if !st.keyname || named != w || !crate::symbol::in_symbol_space(a) { return named; } let g = unsafe { &*((a - crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader) }; @@ -3034,7 +3038,9 @@ fn write_report(st: &mut State) { fnv( fnv( fnv(0x5359_4d42_0000_0000, w >> 48), - (unsafe { (*sym).registered } & 1) as u64, + (unsafe { (*sym).registered } + & (perry_abi::SYMBOL_GLOBAL_REGISTRY | perry_abi::SYMBOL_WELL_KNOWN)) + as u64, ), dn, ) diff --git a/crates/perry-runtime/src/symbol.rs b/crates/perry-runtime/src/symbol.rs index 37f75f7bed9..6d1b69cb481 100644 --- a/crates/perry-runtime/src/symbol.rs +++ b/crates/perry-runtime/src/symbol.rs @@ -754,6 +754,17 @@ pub(crate) fn store_class_static_symbol_root(class_id: u32, sym_key: usize, valu store_object_symbol_property_root(owner, sym_key, value_bits); } +/// Is the raw word `addr` a cell of the process immortal symbol space +/// ([`space::holds`]: range and cell stride, no read)? For the snapshot +/// census key-name rule, which names a persistent symbol by what it is +/// (kind and description), never by its cell address: cells are bump +/// allocated in creation order, so an address is a creation ordinal. +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +#[inline(always)] +pub(crate) fn in_symbol_space(addr: usize) -> bool { + space::holds(addr) +} + #[cfg(test)] mod wellknown_desc_tests { use super::*; From ab9e6d8003408fb4e11be6e00ba25798bac14d7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 3 Nov 2026 18:13:06 +0000 Subject: [PATCH 63/84] SNAP3x: root-holder gate pins for the census hooks. PASS1_MARKED window re-audited for the three instruments-only hooks (gc/census.rs census_live counts a header against the census birth list; arena/inline.rs closes the inline bump while the census runs, a mutator-side routing outside the window; gc/mod.rs re-exports for_each_global_root_slot) and its census.rs, arena/inline.rs and gc/mod.rs pins refreshed. The census start fn is renamed start_census: named start, the gate name graph reached it from a scanner callee and read GLOBAL_ROOTS as covered, which made its frontier pin stale. --- crates/perry-runtime/src/snap_census/runner.rs | 6 ++++-- scripts/gc_runtime_root_holders.json | 8 ++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 6a35d6a8be1..882320fb8ac 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -739,11 +739,13 @@ pub(super) fn start_if_requested() -> bool { return false; } ANCHOR.started.store(2, Relaxed); - unsafe { start(out) }; + unsafe { start_census(out) }; true } -unsafe fn start(out: String) { +// Not `start`: scripts/gc_runtime_root_holders.py walks function names, and a +// census fn named like a scanner callee would read GLOBAL_ROOTS as covered. +unsafe fn start_census(out: String) { let seeded: Vec = std::env::var("PERRY_SNAP_D") .ok() .and_then(|p| std::fs::read_to_string(p).ok()) diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index b4dadccb8ef..faea16e4d8c 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -250,7 +250,7 @@ "file": "crates/perry-runtime/src/gc/census.rs", "name": "PASS1_MARKED", "verdict": "non_moving_snapshot", - "why": "Re-audited 2026-10-23 against the pinned-source diffs and current collection phases. Raw header-address snapshot exists only in a synchronous full run_to_completion: step_mark_propagation records it after tracing, then block persistence and weak/ephemeron processing only mark or clear existing edges. Full finalization skips the relocating minor prelude. step_sweep consumes the snapshot before census reporting and before reclamation; budgeted cycles and minors skip its creation and consumption. No JS callback, object relocation, or mutator window occurs between the boundaries. The added full-sweep honest-pointer verifier only reads heap slots and allocator metadata; it neither calls JS nor relocates. Scanner registration and optional feature installation occur in gc_init before this window. Trigger-watermark admission, promotion-policy estimates and test-only throw sabotage do not change the synchronous mark-to-sweep sequence; the production throw check aborts rather than calling JS or unwinding. Native Rust allocations do not collect. Removing the retired PIC census row changes reporting only. Re-audited b7f3d56fed: policy.rs adds old_gen_growth_since_last_full_bytes, a read-only query of existing pressure and post-full baseline counters for idle-reducer admission. It does not call JS, collect or relocate, and neither snapshot boundary nor the synchronous mark-to-sweep sequence changes. Re-audited DPA-R: js_gc_init_with_layouts binds immutable image metadata before runtime initialization; the compatibility wrapper and linker keepalive anchor do not touch collection, either snapshot boundary, or the synchronous mark-to-sweep sequence. Re-audited WRT2b: the added image-root scan precedes mark propagation and snapshot creation; image cells are immortal MARKED|PINNED and do not enter the moving arena set. The honest-pointer verifier enumerates those immutable image addresses read-only. The new module declarations change no snapshot boundary, JS callback, relocation, or synchronous mark-to-sweep window. WRT3a re-audit: these source files equal e6e7cc419 and the parent; the synchronous census_pass1_if_armed to census_take_if_armed_at_full_sweep_start window remains inside run_to_completion, before reclamation, with no mutator resumption or relocation. Full runtime tests exercise this current source. Release-readiness re-audit 2026-10-28: census.rs only narrows the proc_pid_rusage reporter to macOS. Current gc_init installs roots and intrinsic layouts before collection; js_gc_init_with_layouts binds image metadata before gc_init. The unchanged run_to_completion, mark-completion capture, and pre-reclamation step_sweep consumption still provide the synchronous non-moving window. Re-audited release CI head: burst_boundary now arms the existing precise-poll flag and records the arena baseline. It does not collect or call JavaScript; collection remains deferred until a later precise poll outside the synchronous mark-to-sweep snapshot window. Re-audited #13405: cycle.rs routes birth-color changes through set_birth_extra_flags; it stores the same flags and refreshes the existing inline allocation bound. The refresh only reads existing TLS/arena state and changes the bound, without allocation, JS callbacks, collection or relocation. The synchronous mark-to-sweep custody window is unchanged. Re-audited 2026-10-29 against #13443 (trace.rs -> mark.rs, AtomicFinalize split out to cycle/atomic_finalize.rs, GC_STATS consolidated into gc/progress.rs), the #13457/#13463/#13479 GC slices, and the #13405/#13471/#13472 pacing changes. Both boundaries and both predicates are unchanged and still name the same condition: pass 1 needs minor.is_none() && !is_budgeted(), pass 2 needs full_trace (= minor.is_none()) && !is_budgeted(). The window now runs BlockPersistence -> AtomicFinalize -> the sweep entry before note_sweep_started, and each phase was read rather than assumed: BlockPersistence only marks; the only relocating subphase of AtomicFinalize, MinorPrelude, is entered solely from the minor branch of the ephemeron clear (cycle/weak_ephemerons.rs), while a full goes WeakProcessing -> DisableBarrier -> Sweep, so a full cycle never evacuates between the boundaries; no phase inside the window invokes JavaScript. The const-generic DIAGNOSTICS split of #13443 gates only trace_phase_* recording and the read-only honest-pointer and minor-unmarked verifiers. The new leaf-escape and in-place-promotion machinery (#13463/#13479, #13439) is reached from allocation, store and copying-minor promotion sites, never from this window. census.rs changed only its diagnostic gate predicate. The rest of the interval was read too: HeapChange::begin(Sweep) only clears the weak intern cache and advances the scope counter, the sweep-entry gap drain only traces seeds the barrier shaded, and finish_full_trace invokes perry-stdlib provider hooks that release native payloads owned by the stdlib rather than GC objects. The snapshot's own Vec is a native Rust allocation, which does not collect. Re-audited #13582 (x86-64 Linux GC walk): arena/inline.rs only puts a naked offering shim in front of js_inline_arena_slow_alloc, which passes its caller's pc/sp/fp to a Rust frame that stores a pointer to that origin in the thread-local walk-origin offer cell for the duration of the unchanged slow-path body and clears it on return. It is an allocation entry, not reached from inside the census window; it calls no JS, relocates nothing and changes neither snapshot boundary nor the synchronous mark-to-sweep sequence. Re-audited 2026-11-02 (RELPREP) against d70032de4..ab8fc9070. census.rs: the reporter's visit_object now accepts both object kinds (gc_object_type_pat!) and reads the meta word and slots through the A1 header accessors (meta_read!, object_slot_at, meta_form_alloc_bytes); it only reads heap words and counts, allocates nothing on the GC heap and changes neither census_pass1_if_armed nor census_take_if_armed_at_full_sweep_start. cycle.rs, mod.rs and allocation_pacing.rs changed formatting and a cfg-gated re-export of poll_armed only. policy.rs: on x86-64 Linux js_gc_loop_safepoint and js_gc_collect became naked entry shims that pass their caller's frame to the armed body as a WalkOrigin (EntryWalkOrigin::enter), and the leaf trigger arm (leaf_window_due) subtracts survivor bytes before comparing Eden with the Eden window. Both are collection ENTRIES and trigger arithmetic, reached before run_to_completion starts, never inside the synchronous mark-to-sweep window; no JS call, relocation or mutator resumption was added between the boundaries. Re-audited 2026-11-03 (RPX) against the diffs since ab8fc9070: census.rs swaps two sort_unstable calls for cold_sort::sort_by (same ordering of the same address Vec, no callback); barrier/mod.rs switches the rewrite-slot visitor to its shared (non-generic) form, same read-only walk; policy.rs, allocation_pacing.rs and verify_honest.rs gain #[optimize(size)] attributes and one rustfmt wrap. No JS callback, relocation or mutator window enters the synchronous mark-to-sweep sequence. Re-audited 2026-11-03 (handle-id reuse): gc/mod.rs gains thread_gc_initialized, a read-only query of the GC_INIT_DONE thread-local used by perry-ffi to tell a collecting thread from a worker; it is called from handle retirement, not from the census window, and calls no JS, allocates nothing on the GC heap and relocates nothing. full_trace.rs adds the pool liveness hook and release step consulted by an armed full trace and weakref.rs asks the pool liveness predicate for handle-valued weak keys, both outside the snapshot boundaries; the synchronous mark-to-sweep sequence is unchanged.", + "why": "Re-audited 2026-10-23 against the pinned-source diffs and current collection phases. Raw header-address snapshot exists only in a synchronous full run_to_completion: step_mark_propagation records it after tracing, then block persistence and weak/ephemeron processing only mark or clear existing edges. Full finalization skips the relocating minor prelude. step_sweep consumes the snapshot before census reporting and before reclamation; budgeted cycles and minors skip its creation and consumption. No JS callback, object relocation, or mutator window occurs between the boundaries. The added full-sweep honest-pointer verifier only reads heap slots and allocator metadata; it neither calls JS nor relocates. Scanner registration and optional feature installation occur in gc_init before this window. Trigger-watermark admission, promotion-policy estimates and test-only throw sabotage do not change the synchronous mark-to-sweep sequence; the production throw check aborts rather than calling JS or unwinding. Native Rust allocations do not collect. Removing the retired PIC census row changes reporting only. Re-audited b7f3d56fed: policy.rs adds old_gen_growth_since_last_full_bytes, a read-only query of existing pressure and post-full baseline counters for idle-reducer admission. It does not call JS, collect or relocate, and neither snapshot boundary nor the synchronous mark-to-sweep sequence changes. Re-audited DPA-R: js_gc_init_with_layouts binds immutable image metadata before runtime initialization; the compatibility wrapper and linker keepalive anchor do not touch collection, either snapshot boundary, or the synchronous mark-to-sweep sequence. Re-audited WRT2b: the added image-root scan precedes mark propagation and snapshot creation; image cells are immortal MARKED|PINNED and do not enter the moving arena set. The honest-pointer verifier enumerates those immutable image addresses read-only. The new module declarations change no snapshot boundary, JS callback, relocation, or synchronous mark-to-sweep window. WRT3a re-audit: these source files equal e6e7cc419 and the parent; the synchronous census_pass1_if_armed to census_take_if_armed_at_full_sweep_start window remains inside run_to_completion, before reclamation, with no mutator resumption or relocation. Full runtime tests exercise this current source. Release-readiness re-audit 2026-10-28: census.rs only narrows the proc_pid_rusage reporter to macOS. Current gc_init installs roots and intrinsic layouts before collection; js_gc_init_with_layouts binds image metadata before gc_init. The unchanged run_to_completion, mark-completion capture, and pre-reclamation step_sweep consumption still provide the synchronous non-moving window. Re-audited release CI head: burst_boundary now arms the existing precise-poll flag and records the arena baseline. It does not collect or call JavaScript; collection remains deferred until a later precise poll outside the synchronous mark-to-sweep snapshot window. Re-audited #13405: cycle.rs routes birth-color changes through set_birth_extra_flags; it stores the same flags and refreshes the existing inline allocation bound. The refresh only reads existing TLS/arena state and changes the bound, without allocation, JS callbacks, collection or relocation. The synchronous mark-to-sweep custody window is unchanged. Re-audited 2026-10-29 against #13443 (trace.rs -> mark.rs, AtomicFinalize split out to cycle/atomic_finalize.rs, GC_STATS consolidated into gc/progress.rs), the #13457/#13463/#13479 GC slices, and the #13405/#13471/#13472 pacing changes. Both boundaries and both predicates are unchanged and still name the same condition: pass 1 needs minor.is_none() && !is_budgeted(), pass 2 needs full_trace (= minor.is_none()) && !is_budgeted(). The window now runs BlockPersistence -> AtomicFinalize -> the sweep entry before note_sweep_started, and each phase was read rather than assumed: BlockPersistence only marks; the only relocating subphase of AtomicFinalize, MinorPrelude, is entered solely from the minor branch of the ephemeron clear (cycle/weak_ephemerons.rs), while a full goes WeakProcessing -> DisableBarrier -> Sweep, so a full cycle never evacuates between the boundaries; no phase inside the window invokes JavaScript. The const-generic DIAGNOSTICS split of #13443 gates only trace_phase_* recording and the read-only honest-pointer and minor-unmarked verifiers. The new leaf-escape and in-place-promotion machinery (#13463/#13479, #13439) is reached from allocation, store and copying-minor promotion sites, never from this window. census.rs changed only its diagnostic gate predicate. The rest of the interval was read too: HeapChange::begin(Sweep) only clears the weak intern cache and advances the scope counter, the sweep-entry gap drain only traces seeds the barrier shaded, and finish_full_trace invokes perry-stdlib provider hooks that release native payloads owned by the stdlib rather than GC objects. The snapshot's own Vec is a native Rust allocation, which does not collect. Re-audited #13582 (x86-64 Linux GC walk): arena/inline.rs only puts a naked offering shim in front of js_inline_arena_slow_alloc, which passes its caller's pc/sp/fp to a Rust frame that stores a pointer to that origin in the thread-local walk-origin offer cell for the duration of the unchanged slow-path body and clears it on return. It is an allocation entry, not reached from inside the census window; it calls no JS, relocates nothing and changes neither snapshot boundary nor the synchronous mark-to-sweep sequence. Re-audited 2026-11-02 (RELPREP) against d70032de4..ab8fc9070. census.rs: the reporter's visit_object now accepts both object kinds (gc_object_type_pat!) and reads the meta word and slots through the A1 header accessors (meta_read!, object_slot_at, meta_form_alloc_bytes); it only reads heap words and counts, allocates nothing on the GC heap and changes neither census_pass1_if_armed nor census_take_if_armed_at_full_sweep_start. cycle.rs, mod.rs and allocation_pacing.rs changed formatting and a cfg-gated re-export of poll_armed only. policy.rs: on x86-64 Linux js_gc_loop_safepoint and js_gc_collect became naked entry shims that pass their caller's frame to the armed body as a WalkOrigin (EntryWalkOrigin::enter), and the leaf trigger arm (leaf_window_due) subtracts survivor bytes before comparing Eden with the Eden window. Both are collection ENTRIES and trigger arithmetic, reached before run_to_completion starts, never inside the synchronous mark-to-sweep window; no JS call, relocation or mutator resumption was added between the boundaries. Re-audited 2026-11-03 (RPX) against the diffs since ab8fc9070: census.rs swaps two sort_unstable calls for cold_sort::sort_by (same ordering of the same address Vec, no callback); barrier/mod.rs switches the rewrite-slot visitor to its shared (non-generic) form, same read-only walk; policy.rs, allocation_pacing.rs and verify_honest.rs gain #[optimize(size)] attributes and one rustfmt wrap. No JS callback, relocation or mutator window enters the synchronous mark-to-sweep sequence. Re-audited 2026-11-03 (handle-id reuse): gc/mod.rs gains thread_gc_initialized, a read-only query of the GC_INIT_DONE thread-local used by perry-ffi to tell a collecting thread from a worker; it is called from handle retirement, not from the census window, and calls no JS, allocates nothing on the GC heap and relocates nothing. full_trace.rs adds the pool liveness hook and release step consulted by an armed full trace and weakref.rs asks the pool liveness predicate for handle-valued weak keys, both outside the snapshot boundaries; the synchronous mark-to-sweep sequence is unchanged. Re-audited 2026-11-03 for the SNAP census hooks (instruments builds only; production calls are empty inline stubs): gc/census.rs Census accounting calls snap_census::census_live(header, type, size), which only counts the header against the census's own birth list in a malloc map (no mark, no move, no GC allocation, no JS); arena/inline.rs allocation_limit returns 0 while snap_census closes the inline bump, which routes mutator allocations to the slow path, outside this window; gc/mod.rs re-exports roots::for_each_global_root_slot under the instruments cfg, no code in the window.", "window": { "start": { "file": "crates/perry-runtime/src/gc/census.rs", @@ -265,11 +265,11 @@ "function": "run_to_completion" }, "sources": { - "crates/perry-runtime/src/gc/census.rs": "6458bd6c728fec0981ec93fdd8a0669a410df17af9d618ea981342008e95d97a", + "crates/perry-runtime/src/gc/census.rs": "865d2ce07cccb76e77bb3c55fc4a562cc0623888c2d57ebbe456a829e287e09b", "crates/perry-runtime/src/gc/cycle.rs": "4a4b57e793afbb28821f3bce1444cfb06a41728c57a680825a4223b3e25f35a6", "crates/perry-runtime/src/gc/barrier/mod.rs": "47ca32e42119473fcb42b1f5b9ba7d1b51c602fe6682d1ef8cf520ed11b57d40", - "crates/perry-runtime/src/arena/inline.rs": "c4fa63b0fbd3aa00d47a3e856c34c4050ef96edc8c50444158d2cfc817d6f863", - "crates/perry-runtime/src/gc/mod.rs": "03fe4120680ac9a2e91f4e22f35a6838f3a012aa4160a114c236720e6f185a2b", + "crates/perry-runtime/src/arena/inline.rs": "437911ec0c0386b6d01b1226e073b58bb5df63659d71cdacc70825649caf03cc", + "crates/perry-runtime/src/gc/mod.rs": "af972a03e965e8709fc82e44bf177033c644e898fa58e28213ea11e0e4ea7db8", "crates/perry-runtime/src/gc/policy.rs": "7a11a2eb1bfd8437eed98bf46c641c4575f538aaea200cc797d606445b09efab", "crates/perry-runtime/src/gc/progress.rs": "d2cf96a624dff35e405ac15fa37fe6ba31e7e158668581428fb3765f2c7160c3", "crates/perry-runtime/src/gc/allocation_pacing.rs": "31567ecfd0560763dfe306503c90454c979851c5875465f50b0ed0eae4730a31", From 776e99bf585e43b8a418ab28de0ccdd6a4d7b21c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 3 Nov 2026 20:16:08 +0000 Subject: [PATCH 64/84] SNAP3y: cargo fmt order of the census re-export in map.rs (the instruments-only use of store::snap_native_index_range sorts before use store::*) --- crates/perry-runtime/src/map.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/map.rs b/crates/perry-runtime/src/map.rs index 8003ba3ec3b..859de2fbdc1 100644 --- a/crates/perry-runtime/src/map.rs +++ b/crates/perry-runtime/src/map.rs @@ -319,9 +319,9 @@ mod entry; mod store; mod string_key; -use store::*; #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] pub(crate) use store::snap_native_index_range; +use store::*; #[cfg(test)] pub(crate) use store::{ finalize_map_side_allocation_for_gc, test_init_owned_map_store, test_map_index_bytes, From a3916263c40f3b1a6b381861e46bbdae9f108c4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 4 Nov 2026 03:11:50 +0000 Subject: [PATCH 65/84] SNAP4a: slack rule: an object is named up to its key count; its shape live bound is clipped to it (learned inline width is allocation state D moves; PERRY_SNAP_SLACK=0) --- .../perry-runtime/src/snap_census/runner.rs | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 882320fb8ac..2afac3dd382 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -561,6 +561,13 @@ struct State { /// creation order (`symbol::next_id`), so the digest names the id word /// by the symbol's own birth name. `PERRY_SNAP_SYMID=0`. symid: bool, + /// Slack rule (design §2.10.20): an object's inline width is learned + /// allocation state (`shapes_birth_width`, `LEARNED_INLINE_FIELDS`) that + /// D's births and spills move: capacity past the key count is + /// representation. The digest names an object's shape with its live + /// bound clipped to its key count and leaves the slack slots out. + /// `PERRY_SNAP_SLACK=0`. + slack: bool, /// Size of every birth and realm image cell, by start. sizes: SizeMap, /// One bit per 8-byte word of the realm section: an image cell starts. @@ -823,6 +830,7 @@ unsafe fn start_census(out: String) { r3: !std::env::var("PERRY_SNAP_R3").is_ok_and(|v| v == "0"), serial: !std::env::var("PERRY_SNAP_SERIAL").is_ok_and(|v| v == "0"), symid: !std::env::var("PERRY_SNAP_SYMID").is_ok_and(|v| v == "0"), + slack: !std::env::var("PERRY_SNAP_SLACK").is_ok_and(|v| v == "0"), sizes: SizeMap::new(26), realm_starts: map_zeroed::(((realm.1 - realm.0) / 8).div_ceil(64).max(1)), free_space: 0, @@ -3205,11 +3213,30 @@ fn write_report(st: &mut State) { .or_insert((0xcbf29ce484222325, 0, 0)); e.1 += 1; let ty = unsafe { (*(b.addr as *const crate::gc::GcHeader)).obj_type }; + // Slack rule (§2.10.20): an ordinary object allocated exactly at + // its shape's live bound is named up to its key count; the slots + // past it are capacity (the image keeps its own run's width: the + // shape and the allocation agree, a later key spills as today). + let slack_end = if st.slack && ty == crate::gc::GC_TYPE_OBJECT { + let base = + crate::gc::GC_HEADER_SIZE + std::mem::size_of::(); + let w = unsafe { *((b.addr + crate::gc::GC_HEADER_SIZE) as *const u64) }; + crate::object::shapes::shape_descriptor_for_word(crate::codegen_abi::LayoutWord( + (w >> 32) as u32, + )) + .and_then(|d| { + let live = d.live_inline_slot_count as usize; + ((b.size as usize).checked_sub(base) == Some(8 * live)) + .then(|| base + 8 * live.min(d.logical_key_count as usize)) + }) + } else { + None + }; dump( st.inits[b.init as usize].addr, bname, 0, - format!("t{ty}:{}", b.size), + format!("t{ty}:{}", slack_end.unwrap_or(b.size as usize)), ); // R3 (design §2.10.16): a WeakStorage's bucket positions and // entry order are derived state (they hash raw addresses): it @@ -3275,6 +3302,9 @@ fn write_report(st: &mut State) { if word - b.addr >= skip.0 && word - b.addr < skip.1 { continue; } + if slack_end.is_some_and(|e| word - b.addr >= e) { + continue; + } if st.w.contains(word as u64) || st.repw.contains(word as u64) { if let Some(o) = wd_out.as_mut() { use std::io::Write; @@ -3342,7 +3372,11 @@ fn write_report(st: &mut State) { let parts = [ shape_keys(d.keys, d.logical_key_count as usize), d.logical_key_count as u64, - d.live_inline_slot_count as u64, + if st.slack && ty == perry_abi::GC_TYPE_OBJECT { + d.live_inline_slot_count.min(d.logical_key_count) as u64 + } else { + d.live_inline_slot_count as u64 + }, d.integrity_flags as u64, pser(d.proto_id).unwrap_or_else(|| pn(d.proto_id)), d.object_kind as u8 as u64, From a27c19fe5f6d7997990906ae81b7d2f8dee23c58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 4 Nov 2026 03:11:50 +0000 Subject: [PATCH 66/84] SNAP4a: first-use rule: a class keys cache fill and a regex literal site miss are the runtime's in the census (whichever init asks first; PERRY_SNAP_FIRSTUSE=0) --- crates/perry-runtime/src/object/alloc.rs | 3 +++ crates/perry-runtime/src/regex/literal.rs | 3 +++ crates/perry-runtime/src/snap_census.rs | 14 ++++++++++++++ crates/perry-runtime/src/snap_census/runner.rs | 12 ++++++++++++ 4 files changed, 32 insertions(+) diff --git a/crates/perry-runtime/src/object/alloc.rs b/crates/perry-runtime/src/object/alloc.rs index 26d0d1d2d87..52166df3e6a 100644 --- a/crates/perry-runtime/src/object/alloc.rs +++ b/crates/perry-runtime/src/object/alloc.rs @@ -354,6 +354,9 @@ pub(crate) unsafe fn build_keys_source_array( keys: &[&[u8]], ) -> *mut ArrayHeader { let total = prefix_len as usize + keys.len(); + // A class keys cache fill on first use (the temporary list and its key + // atoms): the SNAP census counts it as the runtime's. + let _census = crate::snap_census::first_use(); let scope = crate::gc::RuntimeHandleScope::new(); let prefix_handle = scope.root_raw_mut_ptr(prefix); let (arr, _) = prefix_handle.across_mut::(|| { diff --git a/crates/perry-runtime/src/regex/literal.rs b/crates/perry-runtime/src/regex/literal.rs index 5382d892cdf..384b93f5ed8 100644 --- a/crates/perry-runtime/src/regex/literal.rs +++ b/crates/perry-runtime/src/regex/literal.rs @@ -71,6 +71,9 @@ pub(crate) fn from_parts( #[cold] #[inline(never)] fn literal_miss(pattern: *const StringHeader, flags: *const StringHeader, word: *mut u64) { + // The site's immutable matcher data, built on first use whichever init + // asks first: the SNAP census counts it as the runtime's. + let _census = crate::snap_census::first_use(); let scope = RuntimeHandleScope::new(); let data = super::perex_api::finish(super::perex_construct::new_data(&scope, pattern, flags)); data.with_mut_ptr::(|data| unsafe { diff --git a/crates/perry-runtime/src/snap_census.rs b/crates/perry-runtime/src/snap_census.rs index 78ab0cd5824..769ce904ef2 100644 --- a/crates/perry-runtime/src/snap_census.rs +++ b/crates/perry-runtime/src/snap_census.rs @@ -440,6 +440,20 @@ pub(crate) fn runtime_scope() -> RuntimeScope { RuntimeScope { active: false } } +/// The fill of a cache of static data on its first use (a literal class's +/// keys, a regex literal site's matcher data): the same whichever init asks +/// first, so the census counts it as the runtime's, like [`runtime_scope`] +/// (design §2.10.20; `PERRY_SNAP_FIRSTUSE=0` leaves it the asking init's). +#[inline(always)] +pub(crate) fn first_use() -> RuntimeScope { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() && runner::first_use_on() { + runner::job_enter(0); + return RuntimeScope { active: true }; + } + RuntimeScope { active: false } +} + pub(crate) struct RuntimeScope { active: bool, } diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 2afac3dd382..bf2b8b6b46b 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -561,6 +561,12 @@ struct State { /// creation order (`symbol::next_id`), so the digest names the id word /// by the symbol's own birth name. `PERRY_SNAP_SYMID=0`. symid: bool, + /// First-use rule (design §2.10.20): a cache of static data filled on + /// first use (a literal class's keys, a regex literal site's matcher + /// data) is filled by whichever init asks first, D in the census and an + /// imaged init in the check: the fill is the runtime's (init 0). + /// `PERRY_SNAP_FIRSTUSE=0`. + first_use: bool, /// Slack rule (design §2.10.20): an object's inline width is learned /// allocation state (`shapes_birth_width`, `LEARNED_INLINE_FIELDS`) that /// D's births and spills move: capacity past the key count is @@ -831,6 +837,7 @@ unsafe fn start_census(out: String) { serial: !std::env::var("PERRY_SNAP_SERIAL").is_ok_and(|v| v == "0"), symid: !std::env::var("PERRY_SNAP_SYMID").is_ok_and(|v| v == "0"), slack: !std::env::var("PERRY_SNAP_SLACK").is_ok_and(|v| v == "0"), + first_use: !std::env::var("PERRY_SNAP_FIRSTUSE").is_ok_and(|v| v == "0"), sizes: SizeMap::new(26), realm_starts: map_zeroed::(((realm.1 - realm.0) / 8).div_ceil(64).max(1)), free_space: 0, @@ -1285,6 +1292,11 @@ pub(super) fn require_edge(on: bool) { st.require_edge = on.then(|| top_init(st)); } +/// The first-use rule is on (`PERRY_SNAP_FIRSTUSE` is not `0`). +pub(super) fn first_use_on() -> bool { + state().first_use +} + pub(super) fn bookkeeping_depth(delta: i32) { state().bookkeeping += delta; } From 3db25cf84d53822a9464bb2b1beb66fb5b5a6f86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 4 Nov 2026 14:44:57 +0000 Subject: [PATCH 67/84] census: the weights run writes no heap digest (its collections free logged births) --- crates/perry-runtime/src/snap_census/runner.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index bf2b8b6b46b..8f3235a0f2a 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -2889,7 +2889,15 @@ fn write_report(st: &mut State) { // the object it points into (plus the offset), a word a member of D // wrote is left out (the image holds it as it was before), any other // word hashes as itself. - { + // + // The digest reads each logged birth as the object it was born as and + // follows its words (a program's word count, a meta record, a weak + // storage's entries): the log is the image only while the census holds + // collection, so nothing moved or died. The weights run + // (`PERRY_SNAP_NOPROTECT=1`) collects as production does: its log keeps + // births whose blocks were freed or handed out again, so it writes no + // digest (its report is the Ir per init above). + if !st.noprotect { let log = st.births.as_slice(); let mut rank: std::collections::HashMap = std::collections::HashMap::new(); let mut names: Vec<(usize, usize, u64)> = Vec::with_capacity(log.len()); From edb7f5f0a9c359e3a1697368a53700bd40e0d7f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 4 Nov 2026 14:44:57 +0000 Subject: [PATCH 68/84] census: digest the deprecated F64 rep lane by its special identity --- crates/perry-runtime/src/snap_census/runner.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 8f3235a0f2a..25065173623 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -3402,7 +3402,14 @@ fn write_report(st: &mut State) { d.object_kind as u8 as u64, d.hole_count as u64, d.summary as u64, - d.rep, + // A deprecated F64 lane (`10`) is learned record state, like the + // width above: field_rep::identity reads it as `F64`, and stores + // by D deprecate it in the census only (design §2.10.20). + if st.slack { + crate::object::field_rep::identity_with_special(d.rep) + } else { + d.rep + }, d.special_constfn_mask as u64, ]; let sname = parts.iter().fold(0xcbf29ce484222325u64, |h, &x| { From becba6d9b1156b1aa1c382551194253fa0e50be3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 4 Nov 2026 15:50:14 +0000 Subject: [PATCH 69/84] census: native module export first use is the runtime's (G2) --- .../src/object/native_module/callable_exports.rs | 3 +++ .../perry-runtime/src/object/native_module/static_exports.rs | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/crates/perry-runtime/src/object/native_module/callable_exports.rs b/crates/perry-runtime/src/object/native_module/callable_exports.rs index 31adea13f5a..bb1e4e8afa9 100644 --- a/crates/perry-runtime/src/object/native_module/callable_exports.rs +++ b/crates/perry-runtime/src/object/native_module/callable_exports.rs @@ -111,6 +111,9 @@ pub fn bound_native_callable_export_value(module_name: &str, property_name: &str if let Some(bits) = NATIVE_CALLABLE_EXPORTS.with(|c| c.borrow().get(&key).copied()) { return f64::from_bits(bits); } + // The export's one callable, minted on its first read whichever init + // reads it first: the SNAP census counts it as the runtime's. + let _census = crate::snap_census::first_use(); let method_bytes: &'static [u8] = property_name.as_bytes().to_vec().leak(); let scope = crate::gc::RuntimeHandleScope::new(); diff --git a/crates/perry-runtime/src/object/native_module/static_exports.rs b/crates/perry-runtime/src/object/native_module/static_exports.rs index 111970f8e3f..d306dcd4e55 100644 --- a/crates/perry-runtime/src/object/native_module/static_exports.rs +++ b/crates/perry-runtime/src/object/native_module/static_exports.rs @@ -73,6 +73,9 @@ fn export_begin( if let Some(bits) = existing { return ExportStep::Existing(f64::from_bits(bits)); } + // The export's one callable, minted on its first read whichever init + // reads it first: the SNAP census counts it as the runtime's. + let _census = crate::snap_census::first_use(); if let Some(value) = intrinsic_export(module, property) { let value = scope.root_nanbox_f64(value); NATIVE_CALLABLE_EXPORTS.with(|v| { @@ -148,6 +151,8 @@ fn export_value( ExportStep::Done(value) => value, ExportStep::Existing(value) => export_finish(module, property, value), ExportStep::Fresh(value) => { + // Its decoration is part of the first read (the runtime's). + let _census = crate::snap_census::first_use(); let scope = crate::gc::RuntimeHandleScope::new(); let value = scope.root_nanbox_f64(value); decorate( From 4107e6069e18a614f083eec23baae056eceaf480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 4 Nov 2026 17:33:22 +0000 Subject: [PATCH 70/84] census: singleton closure first use is the runtime's (G2) --- crates/perry-runtime/src/closure/alloc.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/perry-runtime/src/closure/alloc.rs b/crates/perry-runtime/src/closure/alloc.rs index 0805b2c2cbf..0bfb026cd8d 100644 --- a/crates/perry-runtime/src/closure/alloc.rs +++ b/crates/perry-runtime/src/closure/alloc.rs @@ -1042,6 +1042,9 @@ pub extern "C" fn js_closure_alloc_singleton( if let Some(cached) = SINGLETON_CLOSURES.with(|s| s.borrow().get(&(info as usize)).copied()) { return cached; } + // The body's one no-capture closure, minted on its first use whichever + // init asks first: the SNAP census counts it as the runtime's. + let _census = crate::snap_census::first_use(); let allocated = js_closure_alloc(info, 0); SINGLETON_CLOSURES.with(|s| { s.borrow_mut().insert(info as usize, allocated); From c24e54f92b773e771098dcea25e245a12af8f18f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 4 Nov 2026 17:33:22 +0000 Subject: [PATCH 71/84] census: spread slow-path key list first use is the runtime's (G2) --- crates/perry-runtime/src/object/spread.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/perry-runtime/src/object/spread.rs b/crates/perry-runtime/src/object/spread.rs index 3963f2d77dd..0a47aae2af4 100644 --- a/crates/perry-runtime/src/object/spread.rs +++ b/crates/perry-runtime/src/object/spread.rs @@ -591,6 +591,11 @@ unsafe fn birth_slow( } else { field_rep::REP_ANY }; + // The pair's first use, the same whichever init takes it first: the + // scratch key list, its canonical list, the shape and (below) the learned + // edge. The SNAP census counts them as the runtime's; the result object + // stays the caller's. + let census = crate::snap_census::first_use(); let proof = canonical_keys::SharedLayout::of_receiver(source.cast_mut()).unwrap(); let keys = if prefix_count == 0 && record.summary() == 0 { record.keys_view_of(count) @@ -631,6 +636,7 @@ unsafe fn birth_slow( canonical_keys::canonicalize(&proof, list, words.len() as u32).view() }; let shape = intern_result(keys, rep); + drop(census); let object = allocate_fields(keys.count()); shapes::store_kind::premark_plain_ordinary(object); shapes::stamp_object_shape_id_with_carrier_note(object, LayoutWord(shape)); @@ -638,6 +644,7 @@ unsafe fn birth_slow( let source_keys = positional_source(record, keys.arr()); copy_slots(object, prefix, source, record, Some(result), source_keys); if keys.count() != 0 && source_keys.is_null() { + let _census = crate::snap_census::first_use(); learn( prefix_id, source_id, From 2b486a099e55a3cf7942d0096f1ad70bbec6b932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 5 Nov 2026 00:41:35 +0000 Subject: [PATCH 72/84] census: an Ordinary object allocated past its live bound is named up to its key count (G1 widened; fixture T/fx/width2) --- crates/perry-runtime/src/snap_census/runner.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 25065173623..4ad15b86c6a 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -3237,6 +3237,12 @@ fn write_report(st: &mut State) { // its shape's live bound is named up to its key count; the slots // past it are capacity (the image keeps its own run's width: the // shape and the allocation agree, a later key spills as today). + // An Ordinary object allocated past its live bound (a floor birth + // whose zero-bound shape hides the floor slots, while a tracking + // birth in the other run is born at its learned width) is named + // the same way: the shape never addresses capacity past `live`. + // An arguments object keeps a private tail at the allocation end, + // so it is never widened. let slack_end = if st.slack && ty == crate::gc::GC_TYPE_OBJECT { let base = crate::gc::GC_HEADER_SIZE + std::mem::size_of::(); @@ -3246,7 +3252,13 @@ fn write_report(st: &mut State) { )) .and_then(|d| { let live = d.live_inline_slot_count as usize; - ((b.size as usize).checked_sub(base) == Some(8 * live)) + let size = b.size as usize; + let widened = size > base + 8 * live + && d.object_kind == crate::object::shapes::ShapeObjectKind::Ordinary + && unsafe { (*(b.addr as *const crate::gc::GcHeader))._reserved } + & crate::gc::OBJ_FLAG_ARGUMENTS + == 0; + (size == base + 8 * live || widened) .then(|| base + 8 * live.min(d.logical_key_count as usize)) }) } else { From c2bd0d8ca92233274c06778ce742a295455d6201 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 5 Nov 2026 00:41:35 +0000 Subject: [PATCH 73/84] census: an arguments tail names its birth ShapeId by the shape (fixture T/fx/argtail) --- .../perry-runtime/src/snap_census/runner.rs | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 4ad15b86c6a..d17fee52572 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -574,6 +574,11 @@ struct State { /// bound clipped to its key count and leaves the slack slots out. /// `PERRY_SNAP_SLACK=0`. slack: bool, + /// Tail shape rule: an arguments object's private tail + /// (`ArgumentsTail`: length, birth ShapeId) carries a second copy of a + /// ShapeId, a creation-order number; it is named by the shape it names, + /// as the header word is. `PERRY_SNAP_TAILSHAPE=0`. + tail_shape: bool, /// Size of every birth and realm image cell, by start. sizes: SizeMap, /// One bit per 8-byte word of the realm section: an image cell starts. @@ -837,6 +842,7 @@ unsafe fn start_census(out: String) { serial: !std::env::var("PERRY_SNAP_SERIAL").is_ok_and(|v| v == "0"), symid: !std::env::var("PERRY_SNAP_SYMID").is_ok_and(|v| v == "0"), slack: !std::env::var("PERRY_SNAP_SLACK").is_ok_and(|v| v == "0"), + tail_shape: !std::env::var("PERRY_SNAP_TAILSHAPE").is_ok_and(|v| v == "0"), first_use: !std::env::var("PERRY_SNAP_FIRSTUSE").is_ok_and(|v| v == "0"), sizes: SizeMap::new(26), realm_starts: map_zeroed::(((realm.1 - realm.0) / 8).div_ceil(64).max(1)), @@ -3389,8 +3395,18 @@ fn write_report(st: &mut State) { // another id when the env block differs. It hashes as the // shape it names: key list, counts, flags, prototype link, // kind and representation. - if off - 8 == crate::gc::GC_HEADER_SIZE - && (ty == perry_abi::GC_TYPE_OBJECT || ty == perry_abi::GC_TYPE_CLOSURE) + // An arguments object's tail word (`ArgumentsTail`: the length + // in the low half, the birth ShapeId in the high half) is the + // same kind of carrier: it hashes as the length and the shape. + let tail_word = st.tail_shape + && ty == perry_abi::GC_TYPE_OBJECT + && off - 8 + 16 == b.size as usize + && unsafe { (*(b.addr as *const crate::gc::GcHeader))._reserved } + & crate::gc::OBJ_FLAG_ARGUMENTS + != 0; + if tail_word + || off - 8 == crate::gc::GC_HEADER_SIZE + && (ty == perry_abi::GC_TYPE_OBJECT || ty == perry_abi::GC_TYPE_CLOSURE) { let sid = (v >> 32) as u32; if let Some(d) = crate::object::shapes::shape_descriptor_for_word( From 7a097d01f7a7aa9832745525f9015013e016b14e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 5 Nov 2026 00:41:36 +0000 Subject: [PATCH 74/84] census: a poisoned root slot registered in the check is D's binding, not the importer's (slot owner; fixture T/fx/statics) --- crates/perry-runtime/src/snap_census/runner.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index d17fee52572..4c08a52c442 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -579,6 +579,11 @@ struct State { /// ShapeId, a creation-order number; it is named by the shape it names, /// as the header word is. `PERRY_SNAP_TAILSHAPE=0`. tail_shape: bool, + /// Slot owner rule: a module-global root slot is owned by its first + /// registrant in the census (the defining module); in the check run a + /// slot of D's arrives poisoned, and an importer's registration of it is + /// the duplicate registration, never ownership. `PERRY_SNAP_SLOTOWNER=0`. + slot_owner_rule: bool, /// Size of every birth and realm image cell, by start. sizes: SizeMap, /// One bit per 8-byte word of the realm section: an image cell starts. @@ -844,6 +849,7 @@ unsafe fn start_census(out: String) { slack: !std::env::var("PERRY_SNAP_SLACK").is_ok_and(|v| v == "0"), tail_shape: !std::env::var("PERRY_SNAP_TAILSHAPE").is_ok_and(|v| v == "0"), first_use: !std::env::var("PERRY_SNAP_FIRSTUSE").is_ok_and(|v| v == "0"), + slot_owner_rule: !std::env::var("PERRY_SNAP_SLOTOWNER").is_ok_and(|v| v == "0"), sizes: SizeMap::new(26), realm_starts: map_zeroed::(((realm.1 - realm.0) / 8).div_ceil(64).max(1)), free_space: 0, @@ -1629,6 +1635,17 @@ pub(super) fn global_root(slot: usize) { if st.globals.contains(slot as u64) { return; } + // The check run: a slot whose first registrant in the census was a member + // of D (the defining module) arrives poisoned (the census's `.dslots`). + // 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) + { + return; + } count_unwatched_root(st, slot); st.globals.insert(slot as u64); st.globals_log.push(slot); From ffe56c3e1fe2b572e89fb8d5057fde24af531646 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 5 Nov 2026 09:05:10 +0000 Subject: [PATCH 75/84] snap census: the realm global is the runtime's, whichever init first asks for it Since main no longer seeds globalThis.AsyncLocalStorage from the entry of a program with a dynamic import() (9890c0b85c), the realm global is born inside the first module init that reads it, not at startup. Its birth now runs under the realm rule scope (snap_census::realm_build, init 0; PERRY_SNAP_REALM=0 leaves it the asking init's), like its population. Census builds only; production bytes unchanged. --- .../src/object/global_this/fetch_globals.rs | 3 ++ crates/perry-runtime/src/snap_census.rs | 35 +++++++++++++++++++ .../perry-runtime/src/snap_census/runner.rs | 12 +++++++ 3 files changed, 50 insertions(+) diff --git a/crates/perry-runtime/src/object/global_this/fetch_globals.rs b/crates/perry-runtime/src/object/global_this/fetch_globals.rs index 7ae64611298..442bb7740e5 100644 --- a/crates/perry-runtime/src/object/global_this/fetch_globals.rs +++ b/crates/perry-runtime/src/object/global_this/fetch_globals.rs @@ -124,6 +124,9 @@ pub extern "C" fn js_get_global_this() -> f64 { // The birth joins population's no-move window (`populate_global_this_builtins`): // the static layout's record is one allocation that can cross an arena // block, and no collection may run anywhere in the bootstrap. + // The realm global is the runtime's, whichever init first asks for it: + // its own birth as well as its population (SNAP census, design §2.10.20). + let _realm = crate::snap_census::realm_build(); let bootstrap_window = crate::gc::GcSuppressScope::new(); let new_ptr = unsafe { crate::realm_decl! { birth crate::object::layout_desc::perry_alloc_layout(crate::object::runtime_layouts::GLOBAL_OBJECT::desc()); realm { "%Global%", kind = namespace, never_move = true, members = { globalThis = ref("%Global%"); global = ref("%Global%"), attrs = writable | enumerable | configurable; Array = ref(Array); Object = ref(Object); String = ref(String); Number = ref(Number); Boolean = ref(Boolean); Function = ref(Function); RegExp = ref(RegExp); Date = ref(Date); Error = ref(Error); TypeError = ref(TypeError); RangeError = ref(RangeError); SyntaxError = ref(SyntaxError); ReferenceError = ref(ReferenceError); EvalError = ref(EvalError); URIError = ref(URIError); AggregateError = ref(AggregateError); Symbol = ref(Symbol); Promise = ref(Promise); Map = ref(Map); Set = ref(Set); WeakMap = ref(WeakMap); WeakSet = ref(WeakSet); WeakRef = ref(WeakRef); Proxy = ref(Proxy); BigInt = ref(BigInt); Uint8Array = ref(Uint8Array); Int8Array = ref(Int8Array); Uint16Array = ref(Uint16Array); Int16Array = ref(Int16Array); Uint32Array = ref(Uint32Array); Int32Array = ref(Int32Array); Float16Array = ref(Float16Array); Float32Array = ref(Float32Array); Float64Array = ref(Float64Array); Uint8ClampedArray = ref(Uint8ClampedArray); BigInt64Array = ref(BigInt64Array); BigUint64Array = ref(BigUint64Array); ArrayBuffer = ref(ArrayBuffer); SharedArrayBuffer = ref(SharedArrayBuffer); DataView = ref(DataView); TextEncoder = ref(TextEncoder); TextDecoder = ref(TextDecoder); TextEncoderStream = ref(TextEncoderStream); TextDecoderStream = ref(TextDecoderStream); CompressionStream = ref(CompressionStream); DecompressionStream = ref(DecompressionStream); ReadableStream = ref(ReadableStream); WritableStream = ref(WritableStream); TransformStream = ref(TransformStream); Navigator = ref(Navigator); URL = ref(URL); URLSearchParams = ref(URLSearchParams); URLPattern = ref(URLPattern); AbortController = ref(AbortController); AbortSignal = ref(AbortSignal); EventTarget = ref(EventTarget); Crypto = ref(Crypto); CryptoKey = ref(CryptoKey); SubtleCrypto = ref(SubtleCrypto); Event = ref(Event); CustomEvent = ref(CustomEvent); MessageEvent = ref(MessageEvent); DOMException = ref(DOMException); FormData = ref(FormData); Blob = ref(Blob); File = ref(File); Headers = ref(Headers); Request = ref(Request); Response = ref(Response); MessageChannel = ref(MessageChannel); MessagePort = ref(MessagePort); BroadcastChannel = ref(BroadcastChannel); Storage = ref(Storage); localStorage = accessor(get = "localStorage.", set = "localStorage."), attrs = enumerable | configurable; sessionStorage = accessor(get = "sessionStorage.", set = "sessionStorage."), attrs = enumerable | configurable; WebSocket = ref(WebSocket); FinalizationRegistry = ref(FinalizationRegistry); DisposableStack = ref(DisposableStack); AsyncDisposableStack = ref(AsyncDisposableStack); SuppressedError = ref(SuppressedError); Buffer = ref(Buffer); eval = ref(eval); fetch = ref(fetch), attrs = writable | enumerable | configurable; structuredClone = ref(structuredClone), attrs = writable | enumerable | configurable; atob = ref(atob), attrs = writable | enumerable | configurable; btoa = ref(btoa), attrs = writable | enumerable | configurable; setTimeout = ref(setTimeout), attrs = writable | enumerable | configurable; clearTimeout = ref(clearTimeout), attrs = writable | enumerable | configurable; setInterval = ref(setInterval), attrs = writable | enumerable | configurable; clearInterval = ref(clearInterval), attrs = writable | enumerable | configurable; setImmediate = ref(setImmediate), attrs = writable | enumerable | configurable; clearImmediate = ref(clearImmediate), attrs = writable | enumerable | configurable; queueMicrotask = ref(queueMicrotask), attrs = writable | enumerable | configurable; gc = ref(gc); parseInt = ref(parseInt); parseFloat = ref(parseFloat); isNaN = ref(isNaN); isFinite = ref(isFinite); encodeURI = ref(encodeURI); decodeURI = ref(decodeURI); encodeURIComponent = ref(encodeURIComponent); decodeURIComponent = ref(decodeURIComponent); escape = ref(escape); unescape = ref(unescape); console = ref(console); process = ref(process); Math = ref(Math); JSON = ref(JSON); Reflect = ref(Reflect); Atomics = ref(Atomics); Intl = ref(Intl); WebAssembly = ref(WebAssembly); Temporal = ref(Temporal); performance = ref(performance), attrs = writable | enumerable | configurable; Performance = ref(Performance), attrs = writable | enumerable | configurable; PerformanceEntry = ref(PerformanceEntry), attrs = writable | enumerable | configurable; PerformanceMark = ref(PerformanceMark), attrs = writable | enumerable | configurable; PerformanceMeasure = ref(PerformanceMeasure), attrs = writable | enumerable | configurable; PerformanceObserver = ref(PerformanceObserver), attrs = writable | enumerable | configurable; PerformanceObserverEntryList = ref(PerformanceObserverEntryList), attrs = writable | enumerable | configurable; PerformanceResourceTiming = ref(PerformanceResourceTiming), attrs = writable | enumerable | configurable; crypto = accessor(get = "crypto."), attrs = enumerable | configurable; navigator = accessor(get = "navigator."), attrs = enumerable | configurable; NaN = NaN; Infinity = Infinity; undefined = undefined; }; } } diff --git a/crates/perry-runtime/src/snap_census.rs b/crates/perry-runtime/src/snap_census.rs index 769ce904ef2..444b14b4277 100644 --- a/crates/perry-runtime/src/snap_census.rs +++ b/crates/perry-runtime/src/snap_census.rs @@ -454,6 +454,41 @@ pub(crate) fn first_use() -> RuntimeScope { RuntimeScope { active: false } } +/// The lazy birth and build of a realm intrinsic (the global object, +/// %Object%, a generator or iterator tower, %TypedArray%): the same +/// whichever init asks first, so the census counts it as the runtime's, like +/// [`runtime_scope`] (design §2.10.20; `PERRY_SNAP_REALM=0` leaves it the +/// asking init's). Outside census builds the scope is a unit value with no +/// drop, so the builders compile exactly as without it. +#[inline(always)] +pub(crate) fn realm_build() -> RealmScope { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + if runner::on() && runner::realm_on() { + runner::job_enter(0); + return RealmScope { active: true }; + } + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + return RealmScope { active: false }; + #[cfg(not(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64")))] + RealmScope {} +} + +/// The scope of [`realm_build`]. +pub(crate) struct RealmScope { + #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] + active: bool, +} + +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +impl Drop for RealmScope { + #[inline(always)] + fn drop(&mut self) { + if self.active { + runner::job_exit(); + } + } +} + pub(crate) struct RuntimeScope { active: bool, } diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 4c08a52c442..346365a3a64 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -567,6 +567,12 @@ struct State { /// imaged init in the check: the fill is the runtime's (init 0). /// `PERRY_SNAP_FIRSTUSE=0`. first_use: bool, + /// Realm rule (design §2.10.20): a realm intrinsic (the global object, + /// %Object%, the generator and iterator towers, %TypedArray%) is born + /// lazily inside whichever init first asks for it, D in the census and + /// an imaged init in the check: its birth and build are the runtime's + /// (init 0). `PERRY_SNAP_REALM=0`. + realm_rule: bool, /// Slack rule (design §2.10.20): an object's inline width is learned /// allocation state (`shapes_birth_width`, `LEARNED_INLINE_FIELDS`) that /// D's births and spills move: capacity past the key count is @@ -849,6 +855,7 @@ unsafe fn start_census(out: String) { slack: !std::env::var("PERRY_SNAP_SLACK").is_ok_and(|v| v == "0"), tail_shape: !std::env::var("PERRY_SNAP_TAILSHAPE").is_ok_and(|v| v == "0"), first_use: !std::env::var("PERRY_SNAP_FIRSTUSE").is_ok_and(|v| v == "0"), + realm_rule: !std::env::var("PERRY_SNAP_REALM").is_ok_and(|v| v == "0"), slot_owner_rule: !std::env::var("PERRY_SNAP_SLOTOWNER").is_ok_and(|v| v == "0"), sizes: SizeMap::new(26), realm_starts: map_zeroed::(((realm.1 - realm.0) / 8).div_ceil(64).max(1)), @@ -1309,6 +1316,11 @@ pub(super) fn first_use_on() -> bool { state().first_use } +/// The realm rule is on (`PERRY_SNAP_REALM` is not `0`). +pub(super) fn realm_on() -> bool { + state().realm_rule +} + pub(super) fn bookkeeping_depth(delta: i32) { state().bookkeeping += delta; } From 5bf0820543cba61bf41cdf7412b8fa3fc56ad311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 5 Nov 2026 09:05:10 +0000 Subject: [PATCH 76/84] snap census: %Object% and its prototype stand-in are the realm's ensure_object_intrinsics and ensure_object_prototype_shape build lazily inside whichever init first reads them; the build is the runtime's under the realm rule scope. Census builds only. --- .../perry-runtime/src/object/global_this/object_intrinsic.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/perry-runtime/src/object/global_this/object_intrinsic.rs b/crates/perry-runtime/src/object/global_this/object_intrinsic.rs index 578d50ca7a3..cbac6f0f0e0 100644 --- a/crates/perry-runtime/src/object/global_this/object_intrinsic.rs +++ b/crates/perry-runtime/src/object/global_this/object_intrinsic.rs @@ -60,6 +60,9 @@ pub(crate) fn ensure_object_intrinsics() -> ObjectPair { crate::object::OBJECT_INTRINSIC_PROTO_PTR.store(0, Ordering::Release); crate::array::forget_object_prototype_intrinsic(); } + // %Object% and %Object.prototype% are the realm's, whichever init first + // reads them (SNAP census, design §2.10.20). + let _realm = crate::snap_census::realm_build(); let built = build_object_intrinsics(); if let Some((ctor, proto)) = built { crate::object::OBJECT_INTRINSIC_PTR.store(ctor as i64, Ordering::Release); @@ -99,6 +102,8 @@ pub(crate) fn ensure_object_prototype_shape() -> *mut ObjectHeader { // No collection may see the raw object before the intrinsic root is // published. Unlike the complete intrinsic this sentinel is not immortal: // replacing the root lets the next collection reclaim it. + // The stand-in is the realm's as the intrinsic is (SNAP census). + let _realm = crate::snap_census::realm_build(); let _no_move = crate::gc::GcSuppressScope::new(); let proto = js_object_alloc(0, 0); if !proto.is_null() { From 352b0b434084e6045e8adb3c83743a33686b5ac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 5 Nov 2026 09:05:10 +0000 Subject: [PATCH 77/84] snap census: every lazy realm intrinsic builder is the runtime's The generator and async towers, the iterator prototypes and %TypedArray% are built on first use like %Object%; their builds run under the same realm rule scope. Census builds only. --- crates/perry-runtime/src/object/global_this/generator.rs | 6 ++++++ crates/perry-runtime/src/object/global_this/typed_array.rs | 2 ++ crates/perry-runtime/src/object/iterator_prototypes.rs | 2 ++ 3 files changed, 10 insertions(+) diff --git a/crates/perry-runtime/src/object/global_this/generator.rs b/crates/perry-runtime/src/object/global_this/generator.rs index 3a2a403bf85..817baffef2e 100644 --- a/crates/perry-runtime/src/object/global_this/generator.rs +++ b/crates/perry-runtime/src/object/global_this/generator.rs @@ -1008,9 +1008,13 @@ pub extern "C" fn js_generator_attach_closure_prototype( /// agent; called during its global bootstrap or by the lazy accessors. (#3664) pub(crate) fn ensure_generator_intrinsics() { if crate::object::ASYNC_FUNCTION_INTRINSIC_PTR.load(Ordering::Acquire) == 0 { + // A realm intrinsic is the runtime's, whichever init first asks (SNAP census). + let _realm = crate::snap_census::realm_build(); build_async_function_tower(); } if crate::object::GENERATOR_FUNCTION_INTRINSIC_PTR.load(Ordering::Acquire) == 0 { + // A realm intrinsic is the runtime's, whichever init first asks (SNAP census). + let _realm = crate::snap_census::realm_build(); build_generator_tower( false, &crate::object::GENERATOR_FUNCTION_INTRINSIC_PTR, @@ -1019,6 +1023,8 @@ pub(crate) fn ensure_generator_intrinsics() { ); } if crate::object::ASYNC_GENERATOR_FUNCTION_INTRINSIC_PTR.load(Ordering::Acquire) == 0 { + // A realm intrinsic is the runtime's, whichever init first asks (SNAP census). + let _realm = crate::snap_census::realm_build(); build_generator_tower( true, &crate::object::ASYNC_GENERATOR_FUNCTION_INTRINSIC_PTR, diff --git a/crates/perry-runtime/src/object/global_this/typed_array.rs b/crates/perry-runtime/src/object/global_this/typed_array.rs index 7a8e38ae562..2eecc3b4898 100644 --- a/crates/perry-runtime/src/object/global_this/typed_array.rs +++ b/crates/perry-runtime/src/object/global_this/typed_array.rs @@ -683,6 +683,8 @@ fn install_typed_array_iterator_symbol(proto_obj: *mut ObjectHeader) { // Reachable ahead of `populate_global_this_builtins` from // `typedarray_props.rs:812` on the allocation-point route. See // `gc::tests::lazy_intrinsic_towers` for the gate. + // %TypedArray% is the realm's, whichever init first asks (SNAP census). + let _realm = crate::snap_census::realm_build(); let _no_move = crate::gc::GcSuppressScope::new(); let ctor = crate::closure::js_closure_alloc( crate::fn_info!(#[realm(realm_decl! { BigInt64Array, kind = constructor, provider = builtin_constructor, extends = "%TypedArray%", members = { BYTES_PER_ELEMENT = 8.0; }, spec = {length = 3}, emit = metadata(realm_suppressederror_metadata); BigUint64Array, kind = constructor, provider = builtin_constructor, extends = "%TypedArray%", members = { BYTES_PER_ELEMENT = 8.0; }, spec = {length = 3}, emit = metadata(realm_suppressederror_metadata); Float16Array, kind = constructor, provider = builtin_constructor, extends = "%TypedArray%", members = { BYTES_PER_ELEMENT = 2.0; }, spec = {length = 3}, emit = metadata(realm_suppressederror_metadata); Float32Array, kind = constructor, provider = builtin_constructor, extends = "%TypedArray%", members = { BYTES_PER_ELEMENT = 4.0; }, spec = {length = 3}, emit = metadata(realm_suppressederror_metadata); Float64Array, kind = constructor, provider = builtin_constructor, extends = "%TypedArray%", members = { BYTES_PER_ELEMENT = 8.0; }, spec = {length = 3}, emit = metadata(realm_suppressederror_metadata); Int16Array, kind = constructor, provider = builtin_constructor, extends = "%TypedArray%", members = { BYTES_PER_ELEMENT = 2.0; }, spec = {length = 3}, emit = metadata(realm_suppressederror_metadata); Int32Array, kind = constructor, provider = builtin_constructor, extends = "%TypedArray%", members = { BYTES_PER_ELEMENT = 4.0; }, spec = {length = 3}, emit = metadata(realm_suppressederror_metadata); Int8Array, kind = constructor, provider = builtin_constructor, extends = "%TypedArray%", members = { BYTES_PER_ELEMENT = 1.0; }, spec = {length = 3}, emit = metadata(realm_suppressederror_metadata); Uint16Array, kind = constructor, provider = builtin_constructor, extends = "%TypedArray%", members = { BYTES_PER_ELEMENT = 2.0; }, spec = {length = 3}, emit = metadata(realm_suppressederror_metadata); Uint32Array, kind = constructor, provider = builtin_constructor, extends = "%TypedArray%", members = { BYTES_PER_ELEMENT = 4.0; }, spec = {length = 3}, emit = metadata(realm_suppressederror_metadata); Uint8Array, kind = constructor, provider = builtin_constructor, extends = "%TypedArray%", members = { fromBase64; fromHex; prototype; BYTES_PER_ELEMENT = 1.0; }, spec = {length = 3}, emit = metadata(realm_suppressederror_metadata); Uint8ClampedArray, kind = constructor, provider = builtin_constructor, extends = "%TypedArray%", members = { BYTES_PER_ELEMENT = 1.0; }, spec = {length = 3}, emit = metadata(realm_suppressederror_metadata); "%TypedArray%", kind = constructor, capture = [ref(Uint8Array.prototype)], members = { from; of; [Symbol.species] = accessor(get = "%TypedArray%[\"@@species\"]."); }, spec = {name = "TypedArray"}, emit = metadata(realm_typed_array_public), births = [ArrayBuffer]; })] export crate::object::global_this::ctor_thunks::typed_array_constructor_call_thunk, 1; with_declared(0)), diff --git a/crates/perry-runtime/src/object/iterator_prototypes.rs b/crates/perry-runtime/src/object/iterator_prototypes.rs index c1ac91c379c..6891918add1 100644 --- a/crates/perry-runtime/src/object/iterator_prototypes.rs +++ b/crates/perry-runtime/src/object/iterator_prototypes.rs @@ -524,6 +524,8 @@ mod override_probe_premise_tests { pub(crate) fn ensure_iterator_prototypes() { if ITERATOR_PROTOTYPE_PTR.load(Ordering::Acquire) == 0 { + // A realm intrinsic is the runtime's, whichever init first asks (SNAP census). + let _realm = crate::snap_census::realm_build(); build_iterator_prototypes(); } } From 5eb71be559c48e2e01b771e5ee386b2186a80299 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 5 Nov 2026 14:52:44 +0000 Subject: [PATCH 78/84] snap census: a module-global slot a member of D wrote is named in .wd, as a birth word D wrote --- .../perry-runtime/src/snap_census/runner.rs | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 346365a3a64..7f97cd88d4b 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -590,6 +590,11 @@ struct State { /// slot of D's arrives poisoned, and an importer's registration of it is /// the duplicate registration, never ownership. `PERRY_SNAP_SLOTOWNER=0`. slot_owner_rule: bool, + /// D-written slot rule: a module-global slot a member of D wrote is + /// left out of the census digest and named in `.wd` (`0slot`, the + /// dig's own name for a slot), so the check leaves it out too, as a + /// birth word D wrote. `PERRY_SNAP_WDSLOT=0`. + wd_slot: bool, /// Size of every birth and realm image cell, by start. sizes: SizeMap, /// One bit per 8-byte word of the realm section: an image cell starts. @@ -857,6 +862,7 @@ unsafe fn start_census(out: String) { first_use: !std::env::var("PERRY_SNAP_FIRSTUSE").is_ok_and(|v| v == "0"), realm_rule: !std::env::var("PERRY_SNAP_REALM").is_ok_and(|v| v == "0"), slot_owner_rule: !std::env::var("PERRY_SNAP_SLOTOWNER").is_ok_and(|v| v == "0"), + wd_slot: !std::env::var("PERRY_SNAP_WDSLOT").is_ok_and(|v| v == "0"), sizes: SizeMap::new(26), realm_starts: map_zeroed::(((realm.1 - realm.0) / 8).div_ceil(64).max(1)), free_space: 0, @@ -3529,7 +3535,23 @@ fn write_report(st: &mut State) { // The module-global slots each imaged init registered. for (k, &slot) in st.globals_log.iter().enumerate() { let owner = st.slot_owner.get(k).copied().unwrap_or(0); - if owner == 0 || st.inits[owner as usize].in_d || st.w.contains(slot as u64) { + if owner == 0 || st.inits[owner as usize].in_d { + continue; + } + // A slot a member of D wrote (the CJS support module's + // `pendingParent`, set by a deferred require inside a D body) is + // named in `.wd` like a birth word D wrote: the check, where D + // never runs, leaves it out too. + if st.w.contains(slot as u64) { + if st.wd_slot { + if let Some(o) = wd_out.as_mut() { + use std::io::Write; + let _ = writeln!(o, "0\t{slot}"); + } + } + continue; + } + if st.wd_slot && st.wd_names.contains(&(0, slot)) { continue; } let e = per From 3ad95e77d7ef796d718eb3cdc16027730d7f93b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 5 Nov 2026 14:52:44 +0000 Subject: [PATCH 79/84] snap census: the JSON parse shape-keys cache miss is first-use state, the runtime's --- crates/perry-runtime/src/json/mod.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/perry-runtime/src/json/mod.rs b/crates/perry-runtime/src/json/mod.rs index b36dd1784ea..6646cde01b3 100644 --- a/crates/perry-runtime/src/json/mod.rs +++ b/crates/perry-runtime/src/json/mod.rs @@ -556,6 +556,10 @@ pub(crate) fn note_parse_shape_cache_carriers() { unsafe fn allocate_parse_shape_keys_array( keys: &[*const StringHeader], ) -> crate::object::canonical_keys::CanonicalKeys { + // A shape-keys cache fill on first use (the temporary source array): the + // first parser of a key list makes it, whichever init that is, so the SNAP + // census counts it as the runtime's. + let _census = crate::snap_census::first_use(); // Shape-cache entries and live receivers own this ordinary array. The // construction helper publishes its pointer layout and, for large arrays // born in old generation, remembers young key strings before return. From d14976cf7fa8e011d9ffed8e8882723cd8b99421 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 5 Nov 2026 14:52:44 +0000 Subject: [PATCH 80/84] snap census: the check evaluates the imaged inits the census entered inside a skipped D body, in entry order --- crates/perry-runtime/src/module_require.rs | 12 ++++ .../src/module_require/path_registry.rs | 14 +++++ .../perry-runtime/src/snap_census/runner.rs | 61 +++++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/crates/perry-runtime/src/module_require.rs b/crates/perry-runtime/src/module_require.rs index fc226282669..2431ba9dd9b 100644 --- a/crates/perry-runtime/src/module_require.rs +++ b/crates/perry-runtime/src/module_require.rs @@ -1338,6 +1338,18 @@ fn run_path_initializer(addr: usize) -> Result<(), u64> { outcome } +/// The SNAP census check run: evaluate the module whose initializer is +/// `addr` (an imaged init the census entered inside a skipped body of D), +/// once, through the registry, as a `require` of it would. +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +pub(crate) fn snap_evaluate_init(addr: usize) { + let Some(key) = path_registry::key_of_init_addr(addr) else { + return; + }; + let _require = crate::snap_census::require_scope(); + let _ = require_path_key(&key); +} + fn require_path_key(key: &str) -> Result, PathModuleRequireError> { path_registry::register_pending_path_inits(); MODULE_PATH_REGISTRY.with(|r| r.require_with(key, &run_path_initializer)) diff --git a/crates/perry-runtime/src/module_require/path_registry.rs b/crates/perry-runtime/src/module_require/path_registry.rs index 93720f8bb3b..2a8dd79b90a 100644 --- a/crates/perry-runtime/src/module_require/path_registry.rs +++ b/crates/perry-runtime/src/module_require/path_registry.rs @@ -642,6 +642,20 @@ fn register_init_addr(key: &str, init_addr: usize) -> bool { }) } +/// The canonical key an initializer address was registered under (the SNAP +/// census check run names an init by its address). +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +pub(super) fn key_of_init_addr(addr: usize) -> Option { + register_pending_path_inits(); + PATH_MODULE_INIT_ADDRS.with(|addrs| { + addrs + .borrow() + .iter() + .find(|(_, &a)| a == addr) + .map(|(k, _)| k.clone()) + }) +} + fn lookup_init_addr(key: &str) -> Option { PATH_MODULE_INIT_ADDRS.with(|addrs| addrs.borrow().get(key).copied()) } diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 7f97cd88d4b..f00f9a7a441 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -590,6 +590,11 @@ struct State { /// slot of D's arrives poisoned, and an importer's registration of it is /// the duplicate registration, never ownership. `PERRY_SNAP_SLOTOWNER=0`. slot_owner_rule: bool, + /// Check order rule: an imaged init the census entered inside the body + /// of a member of D runs in the check where that body is skipped, in + /// entry order (`.dkids`, beside the D file the check reads). + /// `PERRY_SNAP_DKIDS=0`. + dkids: Vec<(usize, usize)>, /// D-written slot rule: a module-global slot a member of D wrote is /// left out of the census digest and named in `.wd` (`0slot`, the /// dig's own name for a slot), so the check leaves it out too, as a @@ -862,6 +867,25 @@ unsafe fn start_census(out: String) { first_use: !std::env::var("PERRY_SNAP_FIRSTUSE").is_ok_and(|v| v == "0"), realm_rule: !std::env::var("PERRY_SNAP_REALM").is_ok_and(|v| v == "0"), slot_owner_rule: !std::env::var("PERRY_SNAP_SLOTOWNER").is_ok_and(|v| v == "0"), + dkids: if std::env::var("PERRY_SNAP_DKIDS").is_ok_and(|v| v == "0") { + Vec::new() + } else { + std::env::var("PERRY_SNAP_SKIP") + .ok() + .and_then(|p| std::fs::read_to_string(format!("{}kids", p)).ok()) + .map(|t| { + t.lines() + .filter_map(|l| { + let (a, b) = l.split_once('\t')?; + Some(( + usize::from_str_radix(a, 16).ok()?, + usize::from_str_radix(b, 16).ok()?, + )) + }) + .collect() + }) + .unwrap_or_default() + }, wd_slot: !std::env::var("PERRY_SNAP_WDSLOT").is_ok_and(|v| v == "0"), sizes: SizeMap::new(26), realm_starts: map_zeroed::(((realm.1 - realm.0) / 8).div_ceil(64).max(1)), @@ -3918,7 +3942,23 @@ pub(super) fn skip_body() -> bool { Some(f) if f.kind == FrameKind::Init => { let i = &mut st.inits[f.init as usize]; if !i.entry_module && st.skip.binary_search(&i.addr).is_ok() { + let first = !i.skipped; i.skipped = true; + if first { + // The imaged inits the census entered inside this body, + // evaluated here in entry order (the image holds them + // initialized: a later importer finds them done). + let addr = i.addr; + let kids: Vec = st + .dkids + .iter() + .filter(|k| k.0 == addr) + .map(|k| k.1) + .collect(); + for kid in kids { + crate::module_require::snap_evaluate_init(kid); + } + } return true; } false @@ -4172,6 +4212,27 @@ fn write_env_report(st: &State, f: &mut std::fs::File, rank: &[u64]) { } } } + // The next check run's order: per member of D whose body the check skips, + // the imaged inits entered inside it (directly or through members of D it + // entered), in entry order. + if st.skip.is_empty() { + if let Ok(mut d) = std::fs::File::create(format!("{}.dkids", st.out)) { + let skipped = |id: u32| { + let i = &st.inits[id as usize]; + id != 0 && i.in_d && !i.entry_module && i.addr != ENTRY_BODY_ADDR && i.addr != 0 + }; + for (id, i) in st.inits.iter().enumerate().skip(1) { + if in_d(id as u32) || i.addr == 0 || i.addr == ENTRY_BODY_ADDR { + continue; + } + let mut p = i.parent; + while skipped(p) { + let _ = writeln!(d, "{:x}\t{:x}", addr_of(p), i.addr); + p = st.inits[p as usize].parent; + } + } + } + } // The next check run's poison: the global slots members of D registered. if st.skip.is_empty() { if let Ok(mut d) = std::fs::File::create(format!("{}.dslots", st.out)) { From b5623eb55328373351659d8a98767cd9c5038586 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 5 Nov 2026 14:52:44 +0000 Subject: [PATCH 81/84] snap census: a word into a canonical key-list backing names the chain by its first key --- crates/perry-runtime/src/snap_census/runner.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index f00f9a7a441..4e6d388c4d8 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -590,6 +590,13 @@ struct State { /// slot of D's arrives poisoned, and an importer's registration of it is /// the duplicate registration, never ownership. `PERRY_SNAP_SLOTOWNER=0`. slot_owner_rule: bool, + /// Backing rule: canonical key lists share one backing per growth chain, + /// and whether a node sits on a backing with room (and a later list + /// extends it in place) depends on who built the chain first. A word + /// into a canonical backing outside a shape word names the chain by its + /// first key; the node's count is checked where it lives (shape words). + /// `PERRY_SNAP_BACKING=0`. + backing: bool, /// Check order rule: an imaged init the census entered inside the body /// of a member of D runs in the check where that body is skipped, in /// entry order (`.dkids`, beside the D file the check reads). @@ -867,6 +874,7 @@ unsafe fn start_census(out: String) { first_use: !std::env::var("PERRY_SNAP_FIRSTUSE").is_ok_and(|v| v == "0"), realm_rule: !std::env::var("PERRY_SNAP_REALM").is_ok_and(|v| v == "0"), slot_owner_rule: !std::env::var("PERRY_SNAP_SLOTOWNER").is_ok_and(|v| v == "0"), + backing: !std::env::var("PERRY_SNAP_BACKING").is_ok_and(|v| v == "0"), dkids: if std::env::var("PERRY_SNAP_DKIDS").is_ok_and(|v| v == "0") { Vec::new() } else { @@ -3149,6 +3157,14 @@ fn write_report(st: &mut State) { .map(|(i, n)| { let user = n.0 + crate::gc::GC_HEADER_SIZE; let len = (rolled(user) & 0xFFFF_FFFF) as usize; + if st.backing { + let root = key_words(user, n.1, len.min(1)) + .into_iter() + .fold(fnv(0x4b45_5953_524f_4f54, (len != 0) as u64), |h, w| { + fnv(h, kname(&names, w)) + }); + return (i, root); + } let h = key_words(user, n.1, len) .into_iter() .fold(fnv(0x4b45_5953_0000_0000, len as u64), |h, w| { From 6913231e4d259a2603aac7fd09298bb9550a2cb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 5 Nov 2026 20:35:16 +0000 Subject: [PATCH 82/84] fix: replay census evaluations through their once guards --- crates/perry-runtime/src/module_require.rs | 20 +++++++++++-- crates/perry-runtime/src/snap_census.rs | 12 ++++++++ .../perry-runtime/src/snap_census/runner.rs | 30 +++++++++++++------ 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/crates/perry-runtime/src/module_require.rs b/crates/perry-runtime/src/module_require.rs index 2431ba9dd9b..da580bc06a5 100644 --- a/crates/perry-runtime/src/module_require.rs +++ b/crates/perry-runtime/src/module_require.rs @@ -1281,6 +1281,8 @@ pub unsafe extern "C" fn js_run_module_init_guarded(init_addr: i64, guard_addr: assert!(registry.register_init(key.clone(), init_addr as usize)); registry.require_with(&key, &run_path_initializer) }); + #[cfg(perry_gc_instruments)] + crate::snap_census::init_guard(init_addr as usize, guard_addr as usize); match outcome { Ok(_) => {} Err(error) => { @@ -1340,9 +1342,23 @@ fn run_path_initializer(addr: usize) -> Result<(), u64> { /// The SNAP census check run: evaluate the module whose initializer is /// `addr` (an imaged init the census entered inside a skipped body of D), -/// once, through the registry, as a `require` of it would. +/// once, through its emitted guard, or for a path-wrapper frame through the +/// registry, as a `require` of it would. #[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] -pub(crate) fn snap_evaluate_init(addr: usize) { +pub(crate) fn snap_evaluate_init(addr: usize, guard: usize) { + if guard != 0 { + // Static dependencies enter the evaluation directly: their path entry + // names the outer wrapper, so an address lookup cannot replay them. + unsafe { + let done = guard as *mut u8; + if *done != 0 { + return; + } + *done = 1; + js_run_module_init_guarded(addr as i64, guard as i64); + } + return; + } let Some(key) = path_registry::key_of_init_addr(addr) else { return; }; diff --git a/crates/perry-runtime/src/snap_census.rs b/crates/perry-runtime/src/snap_census.rs index 444b14b4277..34e3e539a71 100644 --- a/crates/perry-runtime/src/snap_census.rs +++ b/crates/perry-runtime/src/snap_census.rs @@ -74,6 +74,18 @@ macro_rules! effect { #[path = "snap_census/runner.rs"] mod runner; +/// The evaluation's emitted once guard, retained on its census init record. +#[cfg(perry_gc_instruments)] +#[inline(always)] +pub(crate) fn init_guard(addr: usize, guard: usize) { + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + runner::init_guard(addr, guard); + } + #[cfg(not(all(target_os = "linux", target_arch = "x86_64")))] + let _ = (addr, guard); +} + /// The entry that declared `class` is running (see [`effect!`]). #[inline(always)] pub fn host(class: Effect, what: &'static str) { diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 4e6d388c4d8..438812c98af 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -399,6 +399,8 @@ struct Conflict { #[derive(Clone, Copy)] struct Init { addr: usize, + /// The emitted once guard of this evaluation; path-wrapper frames have none. + guard: usize, parent: u32, reach: Reach, /// In the dynamic subgraph: reached by `import()`, or below such an init. @@ -601,7 +603,7 @@ struct State { /// of a member of D runs in the check where that body is skipped, in /// entry order (`.dkids`, beside the D file the check reads). /// `PERRY_SNAP_DKIDS=0`. - dkids: Vec<(usize, usize)>, + dkids: Vec<(usize, usize, usize)>, /// D-written slot rule: a module-global slot a member of D wrote is /// left out of the census digest and named in `.wd` (`0slot`, the /// dig's own name for a slot), so the check leaves it out too, as a @@ -884,10 +886,11 @@ unsafe fn start_census(out: String) { .map(|t| { t.lines() .filter_map(|l| { - let (a, b) = l.split_once('\t')?; + let mut row = l.split('\t'); Some(( - usize::from_str_radix(a, 16).ok()?, - usize::from_str_radix(b, 16).ok()?, + usize::from_str_radix(row.next()?, 16).ok()?, + usize::from_str_radix(row.next()?, 16).ok()?, + usize::from_str_radix(row.next().unwrap_or("0"), 16).ok()?, )) }) .collect() @@ -1103,6 +1106,7 @@ fn realm_cell(st: &State, word: usize) -> Option { fn blank_init(addr: usize, parent: u32, reach: Reach) -> Init { Init { addr, + guard: 0, parent, reach, dynamic: false, @@ -1327,6 +1331,14 @@ pub(super) fn init_exit(threw: bool) { set_mode_for_top(st); } +/// Attach the evaluation's emitted once guard after its guarded call returns. +/// The existing init record owns this property, including failed evaluations. +pub(super) fn init_guard(addr: usize, guard: usize) { + if let Some(i) = state().inits.iter_mut().rfind(|i| i.addr == addr) { + i.guard = guard; + } +} + pub(super) fn init_part() { let st = state(); if let Some(f) = st.frames.last() { @@ -3965,14 +3977,14 @@ pub(super) fn skip_body() -> bool { // evaluated here in entry order (the image holds them // initialized: a later importer finds them done). let addr = i.addr; - let kids: Vec = st + let kids: Vec<(usize, usize)> = st .dkids .iter() .filter(|k| k.0 == addr) - .map(|k| k.1) + .map(|k| (k.1, k.2)) .collect(); - for kid in kids { - crate::module_require::snap_evaluate_init(kid); + for (kid, guard) in kids { + crate::module_require::snap_evaluate_init(kid, guard); } } return true; @@ -4243,7 +4255,7 @@ fn write_env_report(st: &State, f: &mut std::fs::File, rank: &[u64]) { } let mut p = i.parent; while skipped(p) { - let _ = writeln!(d, "{:x}\t{:x}", addr_of(p), i.addr); + let _ = writeln!(d, "{:x}\t{:x}\t{:x}", addr_of(p), i.addr, i.guard); p = st.inits[p as usize].parent; } } From 71e036ede9de069d0b97025f1580f0ee17ca29c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 5 Nov 2026 20:35:38 +0000 Subject: [PATCH 83/84] fix: name census string shape keys by their bytes --- .../perry-runtime/src/snap_census/runner.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/perry-runtime/src/snap_census/runner.rs b/crates/perry-runtime/src/snap_census/runner.rs index 438812c98af..571e346fca4 100644 --- a/crates/perry-runtime/src/snap_census/runner.rs +++ b/crates/perry-runtime/src/snap_census/runner.rs @@ -3129,6 +3129,25 @@ fn write_report(st: &mut State) { // key word hashes as its tag, like every foreign word of the digest. // `PERRY_SNAP_KEYNAME=0`: raw words. let kname = |names: &[(usize, usize, u64)], w: u64| -> u64 { + if st.keyname && st.r2 { + // A shape's string key is its bytes. Module pools may register + // its image atom only when they run, so omitting D can leave an + // equal heap atom (or inline string) in the canonical list. + let mut short = [0; crate::value::SHORT_STRING_MAX_LEN]; + if let Some(bytes) = unsafe { + crate::string::js_string_key_bytes(crate::JSValue::from_bits(w), &mut short) + } { + let name = bytes + .iter() + .fold(fnv(0x5354_5249_4e47_0000, bytes.len() as u64), |h, &c| { + fnv(h, c as u64) + }); + // The ordinary R2 string cell's user-pointer name. + return name + ^ (crate::gc::GC_HEADER_SIZE as u64).rotate_left(17) + ^ 0x7fff_u64.rotate_left(40); + } + } let a = (w & 0xFFFF_FFFF_FFFF) as usize; let named = pname(names, w); if !st.keyname || named != w || !crate::symbol::in_symbol_space(a) { From 9e2f5c26340d97a6024a9087c8a99db500c19797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 5 Nov 2026 21:35:17 +0000 Subject: [PATCH 84/84] fix: keep census additions clear of production line labels Move census helpers after thread-local declarations and retain existing declaration lines where event hooks must remain at their events. Production TLS names include the declaration line. --- crates/perry-runtime/src/arena/block.rs | 1 - crates/perry-runtime/src/async_context.rs | 4 -- crates/perry-runtime/src/closure/alloc.rs | 3 -- crates/perry-runtime/src/gc/mod.rs | 5 +- crates/perry-runtime/src/gc/policy.rs | 6 --- crates/perry-runtime/src/lib.rs | 3 +- crates/perry-runtime/src/map.rs | 5 +- crates/perry-runtime/src/module_require.rs | 52 +++++++++---------- .../src/module_require/path_registry.rs | 28 +++++----- .../src/object/global_this/populate.rs | 2 - .../src/os/os_process_emitter.rs | 2 - crates/perry-runtime/src/snap_census.rs | 24 ++++----- 12 files changed, 60 insertions(+), 75 deletions(-) diff --git a/crates/perry-runtime/src/arena/block.rs b/crates/perry-runtime/src/arena/block.rs index 56622f1b08c..32292af99c0 100644 --- a/crates/perry-runtime/src/arena/block.rs +++ b/crates/perry-runtime/src/arena/block.rs @@ -1307,7 +1307,6 @@ thread_local! { /// `arena_reset_empty_blocks`). pub(crate) static ARENA_TOTAL_BYTES: crate::gc::TriggerInput = const { crate::gc::TriggerInput::new(0) }; } - crate::perry_thread_local! { /// Cached running sum of `block.offset` across the old-gen arena — /// the delta-maintained twin of `ARENA_TOTAL_BYTES` above, same diff --git a/crates/perry-runtime/src/async_context.rs b/crates/perry-runtime/src/async_context.rs index 26765cab940..74dc00fc08f 100644 --- a/crates/perry-runtime/src/async_context.rs +++ b/crates/perry-runtime/src/async_context.rs @@ -139,9 +139,7 @@ impl AsyncLocalStoragePayload { Arc::as_ptr(&self.token) as i64 } } - fn install_noop(_proto: &mut crate::native_payload::PayloadPrototype) {} - static ASYNC_LOCAL_STORAGE_PAYLOAD_FAMILY: crate::native_payload::NativePayloadFamily = crate::native_payload::NativePayloadFamily { class_id: crate::native_class_ids::ASYNC_LOCAL_STORAGE_LEGACY, @@ -151,7 +149,6 @@ static ASYNC_LOCAL_STORAGE_PAYLOAD_FAMILY: crate::native_payload::NativePayloadF links_owner: false, install_prototype: install_noop, }; - /// Resolve an AsyncLocalStorage ordinary object (including a subclass) to the /// native context token used by runtime-owned diagnostics-channel helpers. pub fn token_from_storage_value(value: f64) -> Option { @@ -164,7 +161,6 @@ pub fn token_from_storage_value(value: f64) -> Option { .map(|payload| payload.token()) } } - // `perry_thread_local!`, not `thread_local!`: a landing pad reaches this // through `unwind_context_guards`, and `js_eh_pad_enter` is a Leaf. std's // `LocalKey::with` reads the value through the key's accessor *pointer*; diff --git a/crates/perry-runtime/src/closure/alloc.rs b/crates/perry-runtime/src/closure/alloc.rs index 0bfb026cd8d..38642a57fbd 100644 --- a/crates/perry-runtime/src/closure/alloc.rs +++ b/crates/perry-runtime/src/closure/alloc.rs @@ -1153,7 +1153,6 @@ pub(crate) fn test_captured_singleton_closure_cache_entries( .unwrap_or_default() }) } - /// Maximum number of (captures-tuple, ClosureHeader) entries cached /// per-`func_ptr` in `SINGLETON_CAPTURED_CLOSURES`. Sized to absorb the /// parallel-instance async-await pattern (e.g. `Promise.all` of N @@ -1175,7 +1174,6 @@ const _: () = assert!( MAX_CAPTURED_CLOSURE_SLOTS <= u8::MAX as usize, "hint_indices_plus_one stores an entry index plus one in a u8" ); - /// Per-body cache miss-streak counter for the adaptive bypass. /// Closures whose captures change every call (per-call boxes for /// `__step` / `__gen_state`, etc.) miss 100% of the time on the @@ -1187,7 +1185,6 @@ const _: () = assert!( /// threshold reset the counter. const CAPTURED_MISS_STREAK_DISABLE: u32 = 256; const CAPTURED_DISABLED_SENTINEL: u32 = u32::MAX; - crate::perry_thread_local! { static CAPTURED_MISS_STREAK: RefCell> = RefCell::new(crate::fast_hash::new_ptr_hash_map()); diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 92b5035fe3e..8d17b9fe089 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -92,8 +92,6 @@ pub use malloc::*; mod hot_tls; pub(crate) use hot_tls::*; mod roots; -#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] -pub(crate) use roots::for_each_global_root_slot; pub use roots::*; mod full_trace; pub(crate) use full_trace::*; @@ -1897,3 +1895,6 @@ pub fn initialize_runtime_fixture() { static KEEP_JS_GC_INIT_WITH_LAYOUTS: unsafe extern "C" fn( *const crate::codegen_abi::PerryLayoutImageV1, ) = js_gc_init_with_layouts; + +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +pub(crate) use roots::for_each_global_root_slot; diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index d5fc2ac0af5..b817a823bb1 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -3670,12 +3670,10 @@ pub(super) fn gc_mutator_assist_scaled_work_units() -> usize { .saturating_add(arena_units) .saturating_add(malloc_units) } - pub const JS_GC_STEP_STATUS_IDLE: u32 = 0; pub const JS_GC_STEP_STATUS_ACTIVE: u32 = 1; pub const JS_GC_STEP_STATUS_COMPLETED: u32 = 2; pub const JS_GC_STEP_STATUS_SKIPPED: u32 = 3; - #[repr(C)] #[derive(Clone, Copy, Debug, Default)] pub struct JsGcStepResult { @@ -3689,14 +3687,12 @@ pub struct JsGcStepResult { pub malloc_debt_objects: u64, pub old_reclaim_debt_bytes: u64, } - #[derive(Clone, Copy)] enum BudgetedGcRebaseline { ArenaBytes { pre_in_use: usize }, MallocCount { pre_count: usize, pre_in_use: usize }, OldReclaim, } - struct BudgetedGcCycle { state: GcCycleState, trigger_kind: GcTriggerKind, @@ -3705,7 +3701,6 @@ struct BudgetedGcCycle { /// Next automatic slice. Caller-requested budgets do not advance it. automatic_work_units: usize, } - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum BudgetedGcTrigger { OldReclaim, @@ -3722,7 +3717,6 @@ pub(super) enum BudgetedGcTrigger { YoungScavengeCap, MallocCount, } - crate::perry_thread_local! { static GC_BUDGETED_CYCLE: RefCell> = const { RefCell::new(None) }; static GC_BUDGETED_CYCLE_ACTIVE: TriggerInput = const { TriggerInput::new(false) }; diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index 1724ad395d4..f1ea234fda0 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -207,7 +207,6 @@ pub mod net_validate; pub mod node_api_host; mod param_type_guard; pub(crate) mod runtime_state_key; -pub mod snap_census; mod startup_memory_profile; /// wpfacts slice 2: run-time witness of each elided field access. pub mod wpfacts_check; @@ -1458,3 +1457,5 @@ mod diagnostic_report_roots; mod dyn_eval_hook_roots; mod interp_proto_guard; + +pub mod snap_census; diff --git a/crates/perry-runtime/src/map.rs b/crates/perry-runtime/src/map.rs index 4474331659e..38bd5db1063 100644 --- a/crates/perry-runtime/src/map.rs +++ b/crates/perry-runtime/src/map.rs @@ -319,8 +319,6 @@ mod entry; mod store; mod string_key; -#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] -pub(crate) use store::snap_native_index_range; use store::*; #[cfg(test)] pub(crate) use store::{ @@ -4105,3 +4103,6 @@ mod map_tombstone_tests; pub(crate) fn test_enter_catch_foreach(mark: usize) { map_foreach_enter_at(mark, js_map_alloc(4)); } + +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +pub(crate) use store::snap_native_index_range; diff --git a/crates/perry-runtime/src/module_require.rs b/crates/perry-runtime/src/module_require.rs index da580bc06a5..684256213d9 100644 --- a/crates/perry-runtime/src/module_require.rs +++ b/crates/perry-runtime/src/module_require.rs @@ -1340,32 +1340,6 @@ fn run_path_initializer(addr: usize) -> Result<(), u64> { outcome } -/// The SNAP census check run: evaluate the module whose initializer is -/// `addr` (an imaged init the census entered inside a skipped body of D), -/// once, through its emitted guard, or for a path-wrapper frame through the -/// registry, as a `require` of it would. -#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] -pub(crate) fn snap_evaluate_init(addr: usize, guard: usize) { - if guard != 0 { - // Static dependencies enter the evaluation directly: their path entry - // names the outer wrapper, so an address lookup cannot replay them. - unsafe { - let done = guard as *mut u8; - if *done != 0 { - return; - } - *done = 1; - js_run_module_init_guarded(addr as i64, guard as i64); - } - return; - } - let Some(key) = path_registry::key_of_init_addr(addr) else { - return; - }; - let _require = crate::snap_census::require_scope(); - let _ = require_path_key(&key); -} - fn require_path_key(key: &str) -> Result, PathModuleRequireError> { path_registry::register_pending_path_inits(); MODULE_PATH_REGISTRY.with(|r| r.require_with(key, &run_path_initializer)) @@ -1819,3 +1793,29 @@ mod builtin_allowlist_parity_tests { mod init_guard_tests; pub(crate) mod file_url_text; + +/// The SNAP census check run: evaluate the module whose initializer is +/// `addr` (an imaged init the census entered inside a skipped body of D), +/// once, through its emitted guard, or for a path-wrapper frame through the +/// registry, as a `require` of it would. +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +pub(crate) fn snap_evaluate_init(addr: usize, guard: usize) { + if guard != 0 { + // Static dependencies enter the evaluation directly: their path entry + // names the outer wrapper, so an address lookup cannot replay them. + unsafe { + let done = guard as *mut u8; + if *done != 0 { + return; + } + *done = 1; + js_run_module_init_guarded(addr as i64, guard as i64); + } + return; + } + let Some(key) = path_registry::key_of_init_addr(addr) else { + return; + }; + let _require = crate::snap_census::require_scope(); + let _ = require_path_key(&key); +} diff --git a/crates/perry-runtime/src/module_require/path_registry.rs b/crates/perry-runtime/src/module_require/path_registry.rs index 2a8dd79b90a..268b7bc9b08 100644 --- a/crates/perry-runtime/src/module_require/path_registry.rs +++ b/crates/perry-runtime/src/module_require/path_registry.rs @@ -642,20 +642,6 @@ fn register_init_addr(key: &str, init_addr: usize) -> bool { }) } -/// The canonical key an initializer address was registered under (the SNAP -/// census check run names an init by its address). -#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] -pub(super) fn key_of_init_addr(addr: usize) -> Option { - register_pending_path_inits(); - PATH_MODULE_INIT_ADDRS.with(|addrs| { - addrs - .borrow() - .iter() - .find(|(_, &a)| a == addr) - .map(|(k, _)| k.clone()) - }) -} - fn lookup_init_addr(key: &str) -> Option { PATH_MODULE_INIT_ADDRS.with(|addrs| addrs.borrow().get(key).copied()) } @@ -1324,3 +1310,17 @@ pub(crate) fn path_registry_census() -> Vec { )] }) } + +/// The canonical key an initializer address was registered under (the SNAP +/// census check run names an init by its address). +#[cfg(all(perry_gc_instruments, target_os = "linux", target_arch = "x86_64"))] +pub(super) fn key_of_init_addr(addr: usize) -> Option { + register_pending_path_inits(); + PATH_MODULE_INIT_ADDRS.with(|addrs| { + addrs + .borrow() + .iter() + .find(|(_, &a)| a == addr) + .map(|(k, _)| k.clone()) + }) +} diff --git a/crates/perry-runtime/src/object/global_this/populate.rs b/crates/perry-runtime/src/object/global_this/populate.rs index f41442d661d..892af7df344 100644 --- a/crates/perry-runtime/src/object/global_this/populate.rs +++ b/crates/perry-runtime/src/object/global_this/populate.rs @@ -1068,7 +1068,6 @@ fn alias_number_static_to_global_function(singleton_at_entry: *mut ObjectHeader, return; } let ctor_handle = scope.root_raw_mut_ptr(ctor_ptr); - let static_key = super::global_name_key(name); let ctor_ptr = ctor_handle.get_raw_mut_ptr::(); js_object_set_field_by_name( @@ -1082,7 +1081,6 @@ fn alias_number_static_to_global_function(singleton_at_entry: *mut ObjectHeader, super::super::PropertyAttrs::new(true, false, true), ); } - crate::perry_thread_local! { /// Raw address of THIS thread's `Error` constructor closure, captured at /// install. Read by `error::error_prepare_stack_trace_override` so diff --git a/crates/perry-runtime/src/os/os_process_emitter.rs b/crates/perry-runtime/src/os/os_process_emitter.rs index 5c13ca306b7..8cdd952a99a 100644 --- a/crates/perry-runtime/src/os/os_process_emitter.rs +++ b/crates/perry-runtime/src/os/os_process_emitter.rs @@ -431,14 +431,12 @@ pub extern "C" fn js_process_prepend_once_listener(event_bits: i64, listener_bit pub extern "C" fn js_process_emit_before_exit(code: f64) { let _ = emit_process_event("beforeExit", &[code]); } - // Codegen-only symbol: nothing inside the runtime calls it, so anchor it // against the auto-optimize whole-program dead-strip (#4876), like the other // epilogue hooks. #[cfg(feature = "keepalive-anchors")] #[used(compiler)] static KEEP_PROCESS_EMIT_BEFORE_EXIT_PENDING: extern "C" fn() = js_process_emit_before_exit_pending; - mod process_exit_tls { type Value = std::cell::Cell; type Storage = crate::tls_hot::HotCell() as usize }>; diff --git a/crates/perry-runtime/src/snap_census.rs b/crates/perry-runtime/src/snap_census.rs index 34e3e539a71..f17bf5e7808 100644 --- a/crates/perry-runtime/src/snap_census.rs +++ b/crates/perry-runtime/src/snap_census.rs @@ -74,18 +74,6 @@ macro_rules! effect { #[path = "snap_census/runner.rs"] mod runner; -/// The evaluation's emitted once guard, retained on its census init record. -#[cfg(perry_gc_instruments)] -#[inline(always)] -pub(crate) fn init_guard(addr: usize, guard: usize) { - #[cfg(all(target_os = "linux", target_arch = "x86_64"))] - if runner::on() { - runner::init_guard(addr, guard); - } - #[cfg(not(all(target_os = "linux", target_arch = "x86_64")))] - let _ = (addr, guard); -} - /// The entry that declared `class` is running (see [`effect!`]). #[inline(always)] pub fn host(class: Effect, what: &'static str) { @@ -681,3 +669,15 @@ pub(crate) fn skip_body() -> bool { } false } + +/// The evaluation's emitted once guard, retained on its census init record. +#[cfg(perry_gc_instruments)] +#[inline(always)] +pub(crate) fn init_guard(addr: usize, guard: usize) { + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + if runner::on() { + runner::init_guard(addr, guard); + } + #[cfg(not(all(target_os = "linux", target_arch = "x86_64")))] + let _ = (addr, guard); +}