From f5d4c5076290954203d7744e548ee4548ac87e4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 28 Sep 2026 01:43:32 +0000 Subject: [PATCH 01/13] perf(size): install optional runtime features from the link step so prebuilt links drop the rest Follow-up to the stdlib change. An installed perry links the prebuilt full-feature libperry_runtime.a, and always-live runtime code named optional subsystems directly: globalThis population (eval -> the interpreter and the parser, Intl members, Temporal), the generic operators and property / instanceof / construct paths (Temporal, Intl subclassing), the native-module member lookup (bun YAML/TOML/semver/JSONL), Date/Number/BigInt toLocale* with options (ICU), Date time-zone offsets (IANA db), the RegExp matchAll iterator (regex engine), URL host canonicalization and IDNA, and the interpreter's GC/exception integration. Each now goes through a feature_hooks::Hook slot filled by a js_runtime_install_ entry point; an empty slot answers exactly what the #[cfg(not(feature))] branch answered, and always-live tables keep their shape (the savepoint field, prune entry and root scanner stay and forward). The generated installer object also registers a runtime installer, run by js_gc_init before user code: the program's runtime features on a prebuilt link, js_runtime_install_compiled otherwise. stdlib_installs.rs gains the runtime trigger table with drift tests; perry-stdlib reuses the runtime Hook. Installed-mode (release recipe): net+fetch+TLS backend 19.45 -> 15.05 MB, runtime-only fs program 13.72 -> 8.76 MB. Auto-optimized links unchanged. perry-runtime suite 4706/0/12 on base and branch. --- changelog.d/runtime-link-time-features.md | 1 + crates/perry-codegen/src/stubs.rs | 65 +++--- .../perry-runtime/src/builtins/arithmetic.rs | 3 +- .../perry-runtime/src/builtins/formatting.rs | 8 +- .../perry-runtime/src/bun_compat/cli_utils.rs | 14 +- .../src/bun_compat/cli_utils_hooks.rs | 62 ++++++ .../src/bun_compat/cli_utils_stub.rs | 24 --- crates/perry-runtime/src/bun_compat/mod.rs | 9 + crates/perry-runtime/src/date.rs | 18 +- crates/perry-runtime/src/dyn_eval_hooks.rs | 87 ++++++++ .../perry-runtime/src/exception/savepoints.rs | 9 +- crates/perry-runtime/src/feature_hooks.rs | 186 ++++++++++++++++++ crates/perry-runtime/src/gc/dead_owner.rs | 7 +- crates/perry-runtime/src/gc/mod.rs | 8 +- crates/perry-runtime/src/gc/types.rs | 3 +- crates/perry-runtime/src/intl.rs | 25 ++- .../perry-runtime/src/intl/duration_format.rs | 3 +- crates/perry-runtime/src/intl/hooked.rs | 74 +++++++ crates/perry-runtime/src/json/stringify.rs | 6 +- crates/perry-runtime/src/lib.rs | 2 + crates/perry-runtime/src/module_require.rs | 5 +- .../src/module_require/data_import.rs | 9 +- .../src/object/class_constructors.rs | 13 +- .../src/object/date_proto_thunks.rs | 23 ++- .../perry-runtime/src/object/field_get_set.rs | 3 +- .../object/field_get_set/get_field_by_name.rs | 5 +- .../field_get_set/get_field_by_name_tail.rs | 7 +- .../perry-runtime/src/object/global_this.rs | 9 + .../src/object/global_this/builtin_thunks.rs | 25 ++- .../src/object/global_this/fetch_globals.rs | 66 ++++--- .../src/object/global_this/math_temporal.rs | 20 +- .../src/object/global_this/populate.rs | 9 +- .../src/object/instanceof/dynamic_dispatch.rs | 6 +- .../src/object/iterator_prototypes.rs | 6 +- crates/perry-runtime/src/object/mod.rs | 9 + .../src/object/native_call_method.rs | 5 +- .../native_call_method/collection_methods.rs | 10 +- .../object/native_call_method/object_proto.rs | 9 +- .../native_call_method/primitive_methods.rs | 3 +- .../src/object/object_ops/prototype.rs | 3 +- .../src/object/primitive_proto_thunks.rs | 22 +-- crates/perry-runtime/src/regex.rs | 37 ++++ crates/perry-runtime/src/symbol/iterator.rs | 3 +- crates/perry-runtime/src/temporal/hooked.rs | 137 +++++++++++++ crates/perry-runtime/src/temporal/mod.rs | 39 ++-- crates/perry-runtime/src/tls.rs | 9 +- crates/perry-runtime/src/url/mod.rs | 45 ++++- crates/perry-runtime/src/url/node_compat.rs | 8 +- crates/perry-runtime/src/url/url_class.rs | 14 +- crates/perry-runtime/src/value/dyn_index.rs | 1 - crates/perry-runtime/src/value/to_string.rs | 3 +- .../src/value/to_string_radix.rs | 3 +- .../perry-stdlib/src/common/feature_hooks.rs | 39 +--- .../src/commands/compile/optimized_libs.rs | 9 +- .../commands/compile/optimized_libs/driver.rs | 25 ++- .../src/commands/compile/run_pipeline.rs | 34 ++-- crates/perry/src/commands/stdlib_installs.rs | 115 ++++++++++- 57 files changed, 1090 insertions(+), 312 deletions(-) create mode 100644 changelog.d/runtime-link-time-features.md create mode 100644 crates/perry-runtime/src/bun_compat/cli_utils_hooks.rs create mode 100644 crates/perry-runtime/src/dyn_eval_hooks.rs create mode 100644 crates/perry-runtime/src/feature_hooks.rs create mode 100644 crates/perry-runtime/src/intl/hooked.rs create mode 100644 crates/perry-runtime/src/temporal/hooked.rs diff --git a/changelog.d/runtime-link-time-features.md b/changelog.d/runtime-link-time-features.md new file mode 100644 index 0000000000..08d2fc57e8 --- /dev/null +++ b/changelog.d/runtime-link-time-features.md @@ -0,0 +1 @@ +- **perf(size): the runtime's always-live hubs no longer pin optional runtime features into prebuilt links.** Follow-up to #11598 (which did this for perry-stdlib). An installed perry links the prebuilt full-feature `libperry_runtime.a`, and always-live runtime code named optional subsystems directly: `globalThis` population (`eval` → the script interpreter and the swc/perry-parser behind it, `Intl` namespace members, `Temporal`), the generic operators and property/`instanceof`/construct paths (Temporal's `==`/ToPrimitive/ToString/JSON/`valueOf` arms, Temporal and Intl subclassing), the native-module member lookup (`bun.YAML`/`TOML`/`semver`/`JSONL`), `Date.prototype.toLocale*String(locales, options)` (ICU date formatting), `Number`/`BigInt.prototype.toLocaleString(locales, options)`, Date time-zone offsets (the compiled IANA database), the RegExp `matchAll` iterator (the regex engine), URL host canonicalization and IDNA (`url`/`idna`), and the GC/exception integration of the script interpreter (root scanner, move hook, dead-owner prune, `try` savepoint). Each now goes through a `perry_runtime::feature_hooks::Hook` slot filled by a `js_runtime_install_` entry point (`dyn-eval`, `temporal`, `intl-namespace`, `intl-datetime`, `bun-cli-utils`, `regex-engine`, `url-engine`); an empty slot answers exactly what the `#[cfg(not(feature))]` branch answered, and every always-live table keeps its shape (the savepoint field, the prune entry and the root scanner stay registered and forward). The generated installer object from #11598 now also registers a runtime installer, which `js_gc_init` runs before any user code: the program's runtime features (from the same `auto_optimized_cross_features` analysis) on a prebuilt-archive link, `js_runtime_install_compiled` on an auto-optimized link or for programs with deferred dynamic code. `stdlib_installs.rs` gains the runtime trigger table, with tests recomputing it from `perry-runtime/Cargo.toml` and checking it against the defined install symbols. perry-stdlib now reuses the runtime's `Hook`. Installed-mode sizes (Linux x86_64, release recipe): the `node:net` + `fetch` + TLS backend 19.45 → 15.05 MB (24.10 MB before #11598), a runtime-only fs program 13.72 → 8.76 MB, `node:net` 16.18 → 11.23 MB, `node:sqlite` 15.96 → 11.00 MB, fetch 17.39 → 12.66 MB; auto-optimized links unchanged. Validation: perry-runtime's suite single-threaded 4706 passed / 0 failed / 12 ignored on base and branch; no new warnings across 10 runtime feature subsets; the Node builtin compatibility matrix and 259 stdlib + 108 runtime-feature `test-files/` programs identical between the #11598 and patched installed-mode packages (differences only in tests printing random bytes, `Date.now()` or per-build class names; four fixed-port `node:net` tests collided under the harness's parallelism and are identical run sequentially). Not changed: the GC/diagnostic instruments (`diagnostics`, `gc-instruments`, `hot-diag`) remain in the prebuilt archive — their probes sit on hot paths behind runtime env gates, so they are a packaging decision (build the prebuilt runtime without them) rather than a hook conversion. diff --git a/crates/perry-codegen/src/stubs.rs b/crates/perry-codegen/src/stubs.rs index 92eed80603..d173942c4f 100644 --- a/crates/perry-codegen/src/stubs.rs +++ b/crates/perry-codegen/src/stubs.rs @@ -150,42 +150,55 @@ pub fn generate_stub_object_full( compile_ll_to_object(&ll, triple.as_deref()) } -/// Generate the object that tells perry-stdlib which optional features this -/// program installs. +/// Generate the object that tells the runtime (and perry-stdlib, when linked) +/// which optional features this program installs. /// -/// It defines an internal `perry_stdlib_feature_installer()` calling each of -/// `install_symbols` (perry-stdlib `js_stdlib_install_*` entry points, all -/// `void()`), and a static constructor that hands that function to -/// `js_stdlib_register_feature_installer` — one atomic store, safe before -/// `main`. `js_stdlib_init_dispatch` runs the registered installer, so it runs -/// on whichever path initializes the stdlib (the entry prologue, or the lazy +/// For each half it defines an internal installer calling that half's install +/// entry points (`js_runtime_install_*` / `js_stdlib_install_*`, all +/// `void()`), and one static constructor that registers them through +/// `js_runtime_register_feature_installer` / `js_stdlib_register_feature_installer` +/// — atomic stores, safe before `main`. The runtime runs its installer at the +/// end of `js_gc_init`; `js_stdlib_init_dispatch` runs the stdlib one, on +/// whichever path initializes the stdlib (the entry prologue, or the lazy /// `ensure_pump_registered` path of a program codegen did not mark as needing /// the stdlib). Naming only the program's features is what lets the linker -/// drop every other optional subsystem from a prebuilt full-feature archive; -/// see perry-stdlib's `common::feature_hooks`. -pub fn generate_stdlib_installer_object( - install_symbols: &[String], +/// drop every other optional subsystem from the prebuilt full-feature archives; +/// see perry-runtime `feature_hooks` and perry-stdlib `common::feature_hooks`. +pub fn generate_feature_installer_object( + runtime_installs: &[String], + stdlib_installs: Option<&[String]>, target: Option<&str>, ) -> Result> { let mut ll = String::new(); - ll.push_str("; Perry stdlib feature installer — generated by perry-codegen::stubs\n\n"); - ll.push_str("declare void @js_stdlib_register_feature_installer(ptr)\n"); - for symbol in install_symbols { - ll.push_str(&format!("declare void @{}()\n", symbol)); + ll.push_str("; Perry feature installer — generated by perry-codegen::stubs\n\n"); + let mut halves = vec![("runtime", runtime_installs)]; + if let Some(stdlib) = stdlib_installs { + halves.push(("stdlib", stdlib)); } - ll.push_str("\ndefine internal void @perry_stdlib_feature_installer() {\n"); - for symbol in install_symbols { - ll.push_str(&format!(" call void @{}()\n", symbol)); + let mut ctor = String::from("define internal void @perry_register_feature_installers() {\n"); + for (half, symbols) in &halves { + ll.push_str(&format!( + "declare void @js_{half}_register_feature_installer(ptr)\n" + )); + for symbol in symbols.iter() { + ll.push_str(&format!("declare void @{}()\n", symbol)); + } + ll.push_str(&format!( + "\ndefine internal void @perry_{half}_feature_installer() {{\n" + )); + for symbol in symbols.iter() { + ll.push_str(&format!(" call void @{}()\n", symbol)); + } + ll.push_str(" ret void\n}\n\n"); + ctor.push_str(&format!( + " call void @js_{half}_register_feature_installer(ptr @perry_{half}_feature_installer)\n" + )); } - ll.push_str(" ret void\n}\n\n"); - ll.push_str("define internal void @perry_stdlib_register_installer() {\n"); - ll.push_str( - " call void @js_stdlib_register_feature_installer(ptr @perry_stdlib_feature_installer)\n", - ); - ll.push_str(" ret void\n}\n\n"); + ctor.push_str(" ret void\n}\n\n"); + ll.push_str(&ctor); ll.push_str("@llvm.global_ctors = appending global [1 x { i32, ptr, ptr }] "); ll.push_str( - "[{ i32, ptr, ptr } { i32 65535, ptr @perry_stdlib_register_installer, ptr null }]\n", + "[{ i32, ptr, ptr } { i32 65535, ptr @perry_register_feature_installers, ptr null }]\n", ); let triple = target.and_then(crate::resolve_target_triple); compile_ll_to_object(&ll, triple.as_deref()) diff --git a/crates/perry-runtime/src/builtins/arithmetic.rs b/crates/perry-runtime/src/builtins/arithmetic.rs index 762c2f503d..5e3618f1f5 100644 --- a/crates/perry-runtime/src/builtins/arithmetic.rs +++ b/crates/perry-runtime/src/builtins/arithmetic.rs @@ -251,9 +251,8 @@ unsafe fn rel_to_primitive(value: f64) -> f64 { // `TypeError` for every `Temporal.*` value (the spec bans relational ordering // of Temporal values: `plainDate < plainDate` throws). Without this the cell // fell through to the `DefaultString` arm and compared ISO strings silently. - #[cfg(feature = "temporal")] if crate::temporal::is_temporal_value(value) { - return crate::temporal::dispatch::call_method(value, "valueOf", &[]); + return crate::temporal::hooked::call_method(value, "valueOf", &[]); } match crate::value::to_primitive_number(value) { crate::value::OrdinaryToPrimitiveOutcome::Primitive(p) => p, diff --git a/crates/perry-runtime/src/builtins/formatting.rs b/crates/perry-runtime/src/builtins/formatting.rs index 61dd2d08d6..8fff5cabad 100644 --- a/crates/perry-runtime/src/builtins/formatting.rs +++ b/crates/perry-runtime/src/builtins/formatting.rs @@ -523,11 +523,10 @@ unsafe fn date_inspect_string(value: f64) -> String { /// not a Temporal cell, so the caller's `else if let Some(..)` chain falls /// through. Cfg-paired: with the Temporal engine gated off no cell can exist, so /// the off twin is a constant `None` (and doesn't reference the gated module). -#[cfg(feature = "temporal")] fn temporal_inspect_arm(addr: usize, value: f64) -> Option { if crate::temporal::is_temporal_cell_addr(addr) { Some( - crate::temporal::temporal_inspect_string(value) + crate::temporal::hooked::inspect_string(value) .unwrap_or_else(|| "[object Object]".to_string()), ) } else { @@ -535,11 +534,6 @@ fn temporal_inspect_arm(addr: usize, value: f64) -> Option { } } -#[cfg(not(feature = "temporal"))] -fn temporal_inspect_arm(_addr: usize, _value: f64) -> Option { - None -} - /// Print multiple values from an array (console.log with spread support) /// Takes a pointer to an ArrayHeader containing f64 values /// Helper function to format a JSValue as a string (for spread arrays) diff --git a/crates/perry-runtime/src/bun_compat/cli_utils.rs b/crates/perry-runtime/src/bun_compat/cli_utils.rs index 1a4f1c9a48..c004784c39 100644 --- a/crates/perry-runtime/src/bun_compat/cli_utils.rs +++ b/crates/perry-runtime/src/bun_compat/cli_utils.rs @@ -131,7 +131,7 @@ extern "C" fn yaml_stringify_closure( yaml_stringify(input, replacer, space) } -pub fn js_bun_yaml() -> f64 { +pub(crate) fn js_bun_yaml_impl() -> f64 { namespace_object(&[ (b"parse", closure1("parse", yaml_parse_closure)), ( @@ -143,7 +143,7 @@ pub fn js_bun_yaml() -> f64 { extern "C" fn toml_parse_closure(_closure: *const ClosureHeader, input: f64) -> f64 { let source = value_to_string(input); - match toml_parse_result(&source) { + match toml_parse_result_impl(&source) { Ok(value) => value, Err(error) => crate::exception::js_throw(error), } @@ -151,7 +151,7 @@ extern "C" fn toml_parse_closure(_closure: *const ClosureHeader, input: f64) -> /// Shared by Bun.TOML.parse and the runtime import loader. Returning errors /// lets import() reject its promise without throwing through Rust I/O frames. -pub(crate) fn toml_parse_result(source: &str) -> Result { +pub(crate) fn toml_parse_result_impl(source: &str) -> Result { // `Value::from_str` in toml 1.x parses a single TOML value expression; // Bun.TOML.parse consumes a complete document, whose root is a table. let parsed = match toml::from_str::(source) { @@ -177,7 +177,7 @@ pub(crate) fn toml_parse_result(source: &str) -> Result { } } -pub fn js_bun_toml() -> f64 { +pub(crate) fn js_bun_toml_impl() -> f64 { namespace_object(&[(b"parse", closure1("parse", toml_parse_closure))]) } @@ -233,7 +233,7 @@ extern "C" fn semver_satisfies_closure( bool_value(satisfied) } -pub fn js_bun_semver() -> f64 { +pub(crate) fn js_bun_semver_impl() -> f64 { namespace_object(&[ (b"order", closure2("order", semver_order_closure, 2)), ( @@ -252,7 +252,7 @@ extern "C" fn jsonl_parse_chunk_closure( jsonl_parse_chunk(input, start, end) } -pub fn js_bun_jsonl() -> f64 { +pub(crate) fn js_bun_jsonl_impl() -> f64 { namespace_object(&[( b"parseChunk", closure3("parseChunk", jsonl_parse_chunk_closure, 1), @@ -574,7 +574,7 @@ extern "C" fn xxhash64_closure(_closure: *const ClosureHeader, input: f64, seed: crate::value::js_nanbox_bigint(bigint as i64) } -pub fn decorate_bun_hash(value: f64) -> f64 { +pub(crate) fn decorate_bun_hash_impl(value: f64) -> f64 { let scope = RuntimeHandleScope::new(); let hash = scope.root_nanbox_f64(value); let xxhash = scope.root_nanbox_f64(closure2("xxHash64", xxhash64_closure, 1)); diff --git a/crates/perry-runtime/src/bun_compat/cli_utils_hooks.rs b/crates/perry-runtime/src/bun_compat/cli_utils_hooks.rs new file mode 100644 index 0000000000..8231478526 --- /dev/null +++ b/crates/perry-runtime/src/bun_compat/cli_utils_hooks.rs @@ -0,0 +1,62 @@ +//! The Bun CLI utility surface the always-live runtime reaches. +//! +//! `native_module_property_by_name` answers `bun.YAML` / `.TOML` / `.semver` / +//! `.JSONL` and decorates every `bun.hash` value, and the data-URL/`import +//! with { type: "toml" }` loader parses TOML. Those callers are live in every +//! program, so they must not name `cli_utils` (YAML, TOML, semver, serde_json, +//! zstd) directly or the prebuilt full-feature runtime keeps it in all of them. +//! They call these forwarders instead, which reach `cli_utils` only through +//! slots its `bun-cli-utils` install fills (see `crate::feature_hooks`). An +//! empty slot answers exactly what `cli_utils_stub` did in a build without the +//! feature. + +use crate::feature_hooks::Hook; + +static YAML: Hook f64> = Hook::empty(); +static TOML: Hook f64> = Hook::empty(); +static SEMVER: Hook f64> = Hook::empty(); +static JSONL: Hook f64> = Hook::empty(); +static DECORATE_HASH: Hook f64> = Hook::empty(); +static TOML_PARSE: Hook Result> = Hook::empty(); + +fn undefined() -> f64 { + f64::from_bits(crate::value::TAG_UNDEFINED) +} + +pub fn js_bun_yaml() -> f64 { + YAML.get().map_or_else(undefined, |f| f()) +} + +pub fn js_bun_toml() -> f64 { + TOML.get().map_or_else(undefined, |f| f()) +} + +pub fn js_bun_semver() -> f64 { + SEMVER.get().map_or_else(undefined, |f| f()) +} + +pub fn js_bun_jsonl() -> f64 { + JSONL.get().map_or_else(undefined, |f| f()) +} + +pub fn decorate_bun_hash(value: f64) -> f64 { + DECORATE_HASH.get().map_or(value, |f| f(value)) +} + +/// `None` when TOML support is not installed: the loader then takes its +/// deferred-error path, as a build without `bun-cli-utils` always did. +pub(crate) fn toml_parse_result(source: &str) -> Option> { + TOML_PARSE.get().map(|f| f(source)) +} + +/// The `bun-cli-utils` install: fill every slot above from the real backends. +#[cfg(feature = "bun-cli-utils")] +pub(crate) fn install() { + use super::cli_utils; + YAML.set(cli_utils::js_bun_yaml_impl); + TOML.set(cli_utils::js_bun_toml_impl); + SEMVER.set(cli_utils::js_bun_semver_impl); + JSONL.set(cli_utils::js_bun_jsonl_impl); + DECORATE_HASH.set(cli_utils::decorate_bun_hash_impl); + TOML_PARSE.set(cli_utils::toml_parse_result_impl); +} diff --git a/crates/perry-runtime/src/bun_compat/cli_utils_stub.rs b/crates/perry-runtime/src/bun_compat/cli_utils_stub.rs index 8cebf7a233..68722dcccb 100644 --- a/crates/perry-runtime/src/bun_compat/cli_utils_stub.rs +++ b/crates/perry-runtime/src/bun_compat/cli_utils_stub.rs @@ -5,10 +5,6 @@ use crate::string::js_string_from_bytes; use crate::value::JSValue; -fn undefined() -> f64 { - f64::from_bits(crate::value::TAG_UNDEFINED) -} - fn feature_disabled() -> ! { let message = b"Bun CLI utilities are not enabled in this optimized Perry runtime"; let message = js_string_from_bytes(message.as_ptr(), message.len() as u32); @@ -16,26 +12,6 @@ fn feature_disabled() -> ! { crate::exception::js_throw(f64::from_bits(JSValue::pointer(error as *const u8).bits())) } -pub fn js_bun_yaml() -> f64 { - undefined() -} - -pub fn js_bun_toml() -> f64 { - undefined() -} - -pub fn js_bun_semver() -> f64 { - undefined() -} - -pub fn js_bun_jsonl() -> f64 { - undefined() -} - -pub fn decorate_bun_hash(value: f64) -> f64 { - value -} - #[no_mangle] pub extern "C" fn js_bun_deep_equals(_left: f64, _right: f64, _strict: f64) -> f64 { feature_disabled() diff --git a/crates/perry-runtime/src/bun_compat/mod.rs b/crates/perry-runtime/src/bun_compat/mod.rs index 1e21a017f2..0ff7a19962 100644 --- a/crates/perry-runtime/src/bun_compat/mod.rs +++ b/crates/perry-runtime/src/bun_compat/mod.rs @@ -23,6 +23,7 @@ mod ant; #[cfg(feature = "bun-cli-utils")] mod cli_utils; +mod cli_utils_hooks; #[cfg(not(feature = "bun-cli-utils"))] mod cli_utils_stub; mod glob; @@ -49,6 +50,14 @@ use std::io::{Read, Write}; pub use ant::{js_bun_ant_get_peer_pid, js_bun_ant_get_peer_uid, js_bun_ant_memory_pressure_level}; #[cfg(feature = "bun-cli-utils")] pub use cli_utils::*; +// The members the always-live runtime reaches go through slots the +// `bun-cli-utils` install fills; see `cli_utils_hooks`. +#[cfg(feature = "bun-cli-utils")] +pub(crate) use cli_utils_hooks::install as install_cli_utils; +pub(crate) use cli_utils_hooks::toml_parse_result; +pub use cli_utils_hooks::{ + decorate_bun_hash, js_bun_jsonl, js_bun_semver, js_bun_toml, js_bun_yaml, +}; #[cfg(not(feature = "bun-cli-utils"))] pub use cli_utils_stub::*; pub use glob::js_bun_glob_new; diff --git a/crates/perry-runtime/src/date.rs b/crates/perry-runtime/src/date.rs index f71ac25c86..3c5c474bd6 100644 --- a/crates/perry-runtime/src/date.rs +++ b/crates/perry-runtime/src/date.rs @@ -414,13 +414,24 @@ pub fn zone_offset_seconds(tz: &str, secs: i64) -> i64 { // the correct (DST-aware) offset for `secs`. return timestamp_to_local_components(secs).6; } - #[cfg(feature = "intl-datetime")] - if let Some(offset) = compiled_zone_offset_seconds(tz, secs) { + // The compiled IANA database is reached through the `intl-datetime` + // install (see `crate::feature_hooks`); without it non-host named zones + // keep the UTC fallback, as a build without the feature does. + if let Some(offset) = COMPILED_ZONE_OFFSET.get().and_then(|f| f(tz, secs)) { return offset; } 0 } +static COMPILED_ZONE_OFFSET: crate::feature_hooks::Hook Option> = + crate::feature_hooks::Hook::empty(); + +/// The `intl-datetime` install's Date half. +#[cfg(feature = "intl-datetime")] +pub(crate) fn install_compiled_tzdb() { + COMPILED_ZONE_OFFSET.set(compiled_zone_offset_seconds); +} + /// Get current timestamp in milliseconds (Date.now()) #[no_mangle] pub extern "C" fn js_date_now() -> f64 { @@ -1259,9 +1270,8 @@ pub extern "C" fn js_date_value_of(timestamp: f64) -> f64 { // hard `TypeError` (the spec bans implicit numeric coercion / ordering), so // route a Temporal receiver to its brand dispatch, which throws — rather // than returning the opaque cell as a pseudo-Date timestamp. - #[cfg(feature = "temporal")] if crate::temporal::is_temporal_value(timestamp) { - return crate::temporal::dispatch::call_method(timestamp, "valueOf", &[]); + return crate::temporal::hooked::call_method(timestamp, "valueOf", &[]); } if let Some((_, payload)) = crate::builtins::boxed_primitive_payload(timestamp) { return payload; diff --git a/crates/perry-runtime/src/dyn_eval_hooks.rs b/crates/perry-runtime/src/dyn_eval_hooks.rs new file mode 100644 index 0000000000..0939c5580d --- /dev/null +++ b/crates/perry-runtime/src/dyn_eval_hooks.rs @@ -0,0 +1,87 @@ +//! The script evaluator's (`crate::dyn_eval`) touch points in always-live code. +//! +//! The `Function` constructor, dynamic `import()` of a JavaScript `data:` URL, +//! the GC (a root scanner, the move hook, the dead-owner prune) and the +//! exception savepoints all reach the interpreter. Those callers are live in +//! every program, so they must not name `crate::dyn_eval` directly, or the +//! prebuilt full-feature runtime keeps the interpreter and the JS parser behind +//! it in every binary. They call these forwarders, which reach it only through +//! slots the `dyn-eval` install fills (see `crate::feature_hooks`). +//! +//! The install runs from `js_gc_init`, before any user code, so the +//! interpreter never runs with a slot empty. An empty slot answers what a build +//! without `dyn-eval` answers: no function, no scan, no move bookkeeping, an +//! idle savepoint. + +use crate::feature_hooks::Hook; + +static FUNCTION_FROM_STRINGS: Hook f64> = Hook::empty(); +static SCAN_ROOTS: Hook)> = Hook::empty(); +static OWNER_MOVED: Hook = Hook::empty(); +static PRUNE_DEAD_OWNERS: Hook bool)> = Hook::empty(); +static PRUNE_DEAD_OWNERS_YOUNG: Hook bool)> = Hook::empty(); +static INTERP_SAVEPOINT: Hook u64> = Hook::empty(); +static INTERP_RESTORE: Hook = Hook::empty(); +static DATA_URL_IMPORT: Hook Option> = Hook::empty(); + +/// `new Function(...params, body)` from strings; `None` without the evaluator. +pub(crate) fn function_from_strings(args: &[String]) -> Option { + FUNCTION_FROM_STRINGS.get().map(|f| f(args)) +} + +/// Root scanner for the interpreter's rooted value stack (#6559). Registered +/// unconditionally in `gc_init`; scans only once the evaluator is installed. +pub fn scan_dyn_eval_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { + if let Some(scan) = SCAN_ROOTS.get() { + scan(visitor); + } +} + +pub(crate) fn function_owner_moved(old: usize, new: usize) { + if let Some(moved) = OWNER_MOVED.get() { + moved(old, new); + } +} + +pub(crate) fn prune_dead_function_owners(is_dead: &dyn Fn(usize) -> bool) { + if let Some(prune) = PRUNE_DEAD_OWNERS.get() { + prune(is_dead); + } +} + +pub(crate) fn prune_dead_function_owners_young(is_dead: &dyn Fn(usize) -> bool) { + if let Some(prune) = PRUNE_DEAD_OWNERS_YOUNG.get() { + prune(is_dead); + } +} + +/// The interpreter's `try` savepoint; `0` (the catch table's idle value) +/// without the evaluator. +pub(crate) fn interp_savepoint() -> u64 { + INTERP_SAVEPOINT.get().map_or(0, |f| f()) +} + +pub(crate) fn interp_restore(savepoint: u64) { + if let Some(restore) = INTERP_RESTORE.get() { + restore(savepoint); + } +} + +/// Dynamic `import()` of a JavaScript `data:` URL; `None` without the +/// evaluator, which leaves the loader's "cannot find module" path. +pub(crate) fn dynamic_import_data_url(specifier: &str) -> Option { + DATA_URL_IMPORT.get().and_then(|f| f(specifier)) +} + +/// The `dyn-eval` install. +#[cfg(feature = "dyn-eval")] +pub(crate) fn install() { + FUNCTION_FROM_STRINGS.set(crate::dyn_eval::dyn_function_from_strings); + SCAN_ROOTS.set(crate::dyn_eval::scan_dyn_eval_roots_mut); + OWNER_MOVED.set(crate::dyn_eval::function_owner_moved); + PRUNE_DEAD_OWNERS.set(crate::dyn_eval::prune_dead_function_owners); + PRUNE_DEAD_OWNERS_YOUNG.set(crate::dyn_eval::prune_dead_function_owners_young); + INTERP_SAVEPOINT.set(crate::dyn_eval::interp_savepoint); + INTERP_RESTORE.set(crate::dyn_eval::interp_restore); + DATA_URL_IMPORT.set(crate::module_require::dynamic_import_javascript_data_url); +} diff --git a/crates/perry-runtime/src/exception/savepoints.rs b/crates/perry-runtime/src/exception/savepoints.rs index a4f6a9e7c4..a6f4bf3b8a 100644 --- a/crates/perry-runtime/src/exception/savepoints.rs +++ b/crates/perry-runtime/src/exception/savepoints.rs @@ -36,7 +36,6 @@ pub(crate) mod catch_subsystem { // way, or a build without the feature fails `-D warnings` as dead code. #[cfg(feature = "regex-engine")] pub(crate) const REGEX_FACTORY: u32 = 1 << 9; - #[cfg(feature = "dyn-eval")] pub(crate) const DYN_EVAL: u32 = 1 << 10; pub(crate) const NAMESPACE_OVERRIDE: u32 = 1 << 11; } @@ -254,10 +253,12 @@ catch_savepoints! { restore: crate::regex::site_test::active_factory_stack_restore, latch: catch_subsystem::REGEX_FACTORY, idle: 0; // #6559: rooted interpreter values AND the packed call depth. - #[cfg(feature = "dyn-eval")] + // Always present: the capture/restore forward to the interpreter once + // `dyn-eval` is installed, and capture answers the idle value otherwise + // (see `crate::dyn_eval_hooks`). dyn_eval: u64, - capture: crate::dyn_eval::interp_savepoint, - restore: crate::dyn_eval::interp_restore, + capture: crate::dyn_eval_hooks::interp_savepoint, + restore: crate::dyn_eval_hooks::interp_restore, latch: catch_subsystem::DYN_EVAL, idle: 0; } diff --git a/crates/perry-runtime/src/feature_hooks.rs b/crates/perry-runtime/src/feature_hooks.rs new file mode 100644 index 0000000000..bfde1db5b5 --- /dev/null +++ b/crates/perry-runtime/src/feature_hooks.rs @@ -0,0 +1,186 @@ +//! Link-time feature installation for the runtime. +//! +//! The runtime's always-live hubs — the `globalThis` builder, the native-module +//! member lookup, the generic operators — must not name an optional subsystem +//! (the `eval` parser, Intl/ICU, Temporal, the Bun CLI utilities, …) directly. +//! A direct reference keeps that subsystem in every program linked against the +//! prebuilt full-feature `libperry_runtime.a`, which is what an installed perry +//! links when it has no workspace to rebuild from. +//! +//! Each optional feature instead fills [`Hook`] slots from its own +//! `js_runtime_install_` entry point, and a hub calls through the slot; +//! an empty slot takes the path the hub's `#[cfg(not(feature))]` branch takes +//! in a build without the feature. perry's link step generates an object whose +//! static constructor registers an installer through +//! [`js_runtime_register_feature_installer`]; [`crate::gc::js_gc_init`] runs it +//! before any user code. The installer names the program's runtime features on +//! a prebuilt-archive link and [`js_runtime_install_compiled`] otherwise (an +//! auto-optimized archive already holds exactly the needed features). +//! +//! perry-stdlib uses the same [`Hook`] for its own hubs +//! (`perry_stdlib::common::feature_hooks`). + +use std::marker::PhantomData; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// One function-pointer slot. `F` must be a plain `fn` pointer type. +pub struct Hook { + bits: AtomicUsize, + _f: PhantomData, +} + +impl Hook { + pub const fn empty() -> Self { + Self { + bits: AtomicUsize::new(0), + _f: PhantomData, + } + } + + #[inline] + pub fn set(&self, f: F) { + const { assert!(std::mem::size_of::() == std::mem::size_of::()) }; + // SAFETY: `F` is a fn pointer of pointer size (asserted above). + let bits: usize = unsafe { std::mem::transmute_copy(&f) }; + self.bits.store(bits, Ordering::Release); + } + + #[inline] + pub fn get(&self) -> Option { + #[cfg_attr(not(test), allow(unused_mut))] + let mut bits = self.bits.load(Ordering::Acquire); + // Unit tests reach hubs without the perry link step or `js_gc_init`, + // so an empty slot installs everything compiled (the pre-hook + // behavior) and is read again. Test builds only: a shipped archive + // must not reference `js_runtime_install_compiled` from here. + #[cfg(test)] + if bits == 0 { + install_compiled_for_tests(); + bits = self.bits.load(Ordering::Acquire); + } + if bits == 0 { + None + } else { + // SAFETY: only `set` stores non-zero bits, and it stores an `F`. + Some(unsafe { std::mem::transmute_copy(&bits) }) + } + } +} + +#[cfg(test)] +fn install_compiled_for_tests() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| js_runtime_install_compiled()); +} + +/// Run `$body` once per process, so installs are idempotent. +#[macro_export] +#[doc(hidden)] +macro_rules! perry_install_once { + ($body:block) => {{ + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| $body); + }}; +} + +/// The installer the program's generated object registered. +static FEATURE_INSTALLER: Hook = Hook::empty(); + +/// Called from a static constructor in the object perry's link step generates. +/// Only stores the pointer: it runs before `main`; installing happens from +/// [`crate::gc::js_gc_init`]. +#[no_mangle] +pub extern "C" fn js_runtime_register_feature_installer(installer: extern "C" fn()) { + FEATURE_INSTALLER.set(installer); +} + +/// Run the registered installer; called at the end of `js_gc_init`. +/// +/// With no registration nothing optional is installed. This must not name +/// [`js_runtime_install_compiled`] outside tests: `js_gc_init` is live in every +/// program, so a fallback reference here would pin every feature again. The +/// crate's own unit tests keep the pre-hook behavior. +pub(crate) fn run_feature_installer() { + if let Some(installer) = FEATURE_INSTALLER.get() { + installer(); + return; + } + #[cfg(test)] + js_runtime_install_compiled(); +} + +/// Install every optional runtime feature this archive was compiled with. +#[no_mangle] +pub extern "C" fn js_runtime_install_compiled() { + #[cfg(feature = "dyn-eval")] + js_runtime_install_dyn_eval(); + #[cfg(feature = "bun-cli-utils")] + js_runtime_install_bun_cli_utils(); + #[cfg(feature = "intl-namespace")] + js_runtime_install_intl_namespace(); + #[cfg(feature = "temporal")] + js_runtime_install_temporal(); + #[cfg(feature = "intl-datetime")] + js_runtime_install_intl_datetime(); + #[cfg(feature = "regex-engine")] + js_runtime_install_regex_engine(); + #[cfg(feature = "url-engine")] + js_runtime_install_url_engine(); +} + +#[cfg(feature = "dyn-eval")] +#[no_mangle] +pub extern "C" fn js_runtime_install_dyn_eval() { + perry_install_once!({ + crate::object::install_dyn_eval(); + crate::dyn_eval_hooks::install(); + }); +} + +#[cfg(feature = "bun-cli-utils")] +#[no_mangle] +pub extern "C" fn js_runtime_install_bun_cli_utils() { + perry_install_once!({ + crate::bun_compat::install_cli_utils(); + }); +} + +#[cfg(feature = "intl-namespace")] +#[no_mangle] +pub extern "C" fn js_runtime_install_intl_namespace() { + perry_install_once!({ + crate::intl::install_intl_namespace_feature(); + }); +} + +#[cfg(feature = "temporal")] +#[no_mangle] +pub extern "C" fn js_runtime_install_temporal() { + perry_install_once!({ + crate::temporal::hooked::install(); + }); +} + +#[cfg(feature = "intl-datetime")] +#[no_mangle] +pub extern "C" fn js_runtime_install_intl_datetime() { + perry_install_once!({ + crate::date::install_compiled_tzdb(); + }); +} + +#[cfg(feature = "regex-engine")] +#[no_mangle] +pub extern "C" fn js_runtime_install_regex_engine() { + perry_install_once!({ + crate::regex::install_iterator_hooks(); + }); +} + +#[cfg(feature = "url-engine")] +#[no_mangle] +pub extern "C" fn js_runtime_install_url_engine() { + perry_install_once!({ + crate::url::install_engine(); + }); +} diff --git a/crates/perry-runtime/src/gc/dead_owner.rs b/crates/perry-runtime/src/gc/dead_owner.rs index 32e7d37c6e..eef5bcbfcd 100644 --- a/crates/perry-runtime/src/gc/dead_owner.rs +++ b/crates/perry-runtime/src/gc/dead_owner.rs @@ -464,12 +464,13 @@ pub(super) const DEAD_KEY_PRUNES: &[DeadKeyPrune] = &[ prune: crate::closure::prune_dead_closure_side_table_owners, young_prune: Some(crate::closure::prune_dead_closure_side_table_owners_young), }, - #[cfg(feature = "dyn-eval")] + // Always listed; the prunes forward to the interpreter once `dyn-eval` is + // installed (see `crate::dyn_eval_hooks`). DeadKeyPrune { table: "dyn_eval::LIFETIME.owners + FN_REGISTRY", owner: DeadKeyOwner::Closure, - prune: crate::dyn_eval::prune_dead_function_owners, - young_prune: Some(crate::dyn_eval::prune_dead_function_owners_young), + prune: crate::dyn_eval_hooks::prune_dead_function_owners, + young_prune: Some(crate::dyn_eval_hooks::prune_dead_function_owners_young), }, DeadKeyPrune { table: "BUILTIN_CLOSURE_LENGTH + BUILTIN_CLOSURE_NON_CONSTRUCTABLE", diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 757fa71150..f044890b0c 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -1230,8 +1230,9 @@ pub fn gc_init() { // temporaries, arguments of in-flight interpreted frames). Mark + // REWRITE — interpreter state must survive moving collections triggered // from inside interpreted code. - #[cfg(feature = "dyn-eval")] - reg_scanner!(crate::dyn_eval::scan_dyn_eval_roots_mut); + // Registered unconditionally; it scans once the `dyn-eval` install has + // connected the interpreter (see `crate::dyn_eval_hooks`). + reg_scanner!(crate::dyn_eval_hooks::scan_dyn_eval_roots_mut); reg_scanner!(crate::tls::scan_tls_roots_mut); reg_scanner!(crate::process::scan_process_finalization_roots_mut); reg_scanner!(crate::process::scan_process_module_loader_roots_mut); @@ -1397,6 +1398,9 @@ pub extern "C" fn js_gc_init() { crate::object::disable_class_field_inline_guard(); } gc_init(); + // Optional runtime features install from the program's generated + // installer (see `crate::feature_hooks`), before any user code runs. + crate::feature_hooks::run_feature_installer(); } /// Release external Map/Set/JSON-tape storage owned by the current thread. diff --git a/crates/perry-runtime/src/gc/types.rs b/crates/perry-runtime/src/gc/types.rs index 57940b8638..378a0893de 100644 --- a/crates/perry-runtime/src/gc/types.rs +++ b/crates/perry-runtime/src/gc/types.rs @@ -915,8 +915,7 @@ pub(crate) fn gc_type_after_payload_move(obj_type: u8, old_user: usize, new_user GcMoveHookKind::ClosureDynamicProps => { crate::closure::closure_dynamic_props_owner_moved(old_user, new_user); crate::closure::closure_box_captures_owner_moved(old_user, new_user); - #[cfg(feature = "dyn-eval")] - crate::dyn_eval::function_owner_moved(old_user, new_user); + crate::dyn_eval_hooks::function_owner_moved(old_user, new_user); } GcMoveHookKind::MapForeachStack => { crate::map::map_header_moved_for_gc(old_user, new_user); diff --git a/crates/perry-runtime/src/intl.rs b/crates/perry-runtime/src/intl.rs index 7e195ee4ec..649c69f017 100644 --- a/crates/perry-runtime/src/intl.rs +++ b/crates/perry-runtime/src/intl.rs @@ -26,6 +26,7 @@ use crate::value::{js_jsvalue_to_string, js_nanbox_pointer, JSValue}; use crate::StringHeader; mod ctor_guard; +pub(crate) mod hooked; use ctor_guard::{constructor_target_prototype, require_new_target}; mod display_names; mod duration_format; @@ -1785,11 +1786,29 @@ fn set_proto_to_string_tag(proto: *mut ObjectHeader, tag: &str) { /// reclaims the constructor/option/format machinery that nothing else /// reaches. `toLocale*` / `localeCompare` are unaffected — their entry points /// and helpers live outside this gate. -#[cfg(not(feature = "intl-namespace"))] -pub fn install_intl_namespace(_ns_obj: *mut ObjectHeader) {} +/// +/// `globalThis` population is live in every program, so it reaches the members +/// only through a slot the `intl-namespace` install fills (see +/// `crate::feature_hooks`); an empty slot leaves the namespace empty, exactly +/// as a build without the feature does. +pub fn install_intl_namespace(ns_obj: *mut ObjectHeader) { + if let Some(install) = INTL_NAMESPACE_MEMBERS.get() { + install(ns_obj); + } +} + +static INTL_NAMESPACE_MEMBERS: crate::feature_hooks::Hook = + crate::feature_hooks::Hook::empty(); +/// The `intl-namespace` install. #[cfg(feature = "intl-namespace")] -pub fn install_intl_namespace(ns_obj: *mut ObjectHeader) { +pub(crate) fn install_intl_namespace_feature() { + INTL_NAMESPACE_MEMBERS.set(install_intl_namespace_members); + hooked::install(); +} + +#[cfg(feature = "intl-namespace")] +fn install_intl_namespace_members(ns_obj: *mut ObjectHeader) { if ns_obj.is_null() { return; } diff --git a/crates/perry-runtime/src/intl/duration_format.rs b/crates/perry-runtime/src/intl/duration_format.rs index c55e3eefa8..c29379f955 100644 --- a/crates/perry-runtime/src/intl/duration_format.rs +++ b/crates/perry-runtime/src/intl/duration_format.rs @@ -395,8 +395,7 @@ fn to_duration_record(value: f64) -> Vec { // `ToDurationRecord` first branch: a `Temporal.Duration` (or subclass) copies // its internal slots directly — no prototype getters observed, no field-order // side effects. Only reachable when the Temporal engine is compiled in. - #[cfg(feature = "temporal")] - if let Some(vals) = crate::temporal::duration_unit_values(value) { + if let Some(vals) = crate::temporal::hooked::duration_unit_values(value) { let vals = vals.to_vec(); validate_duration(&vals); return vals; diff --git a/crates/perry-runtime/src/intl/hooked.rs b/crates/perry-runtime/src/intl/hooked.rs new file mode 100644 index 0000000000..a29ecd08f1 --- /dev/null +++ b/crates/perry-runtime/src/intl/hooked.rs @@ -0,0 +1,74 @@ +//! The Intl operations the always-live runtime reaches. +//! +//! `instanceof`, class construction (`class X extends Intl.`) and +//! `Number`/`BigInt.prototype.toLocaleString(locales, options)` have an Intl +//! arm. Those callers are live in every program, so they must not name the +//! ECMA-402 machinery directly, or the prebuilt full-feature runtime keeps it +//! in every binary. They call these forwarders, which reach it only through +//! slots the `intl-namespace` install fills (see `crate::feature_hooks`). An +//! empty slot answers what a build without the feature answers: no Intl +//! constructor value can exist, so the probes never match, and the locale +//! formatting falls back to the plain ECMA-262 rendering. + +use crate::feature_hooks::Hook; +use crate::string::StringHeader; + +static INSTANCEOF: Hook Option> = Hook::empty(); +static IS_CONSTRUCTOR_VALUE: Hook bool> = Hook::empty(); +static SUBCLASS_SUPER: Hook bool> = Hook::empty(); +static NUMBER_TO_LOCALE_STRING: Hook *mut StringHeader> = Hook::empty(); +static BIGINT_TO_LOCALE_STRING: Hook *mut StringHeader> = Hook::empty(); + +pub(crate) fn intl_instanceof(value: f64, type_ref: f64) -> Option { + INSTANCEOF.get().and_then(|f| f(value, type_ref)) +} + +pub(crate) fn is_intl_constructor_value(value: f64) -> bool { + IS_CONSTRUCTOR_VALUE.get().is_some_and(|f| f(value)) +} + +/// # Safety +/// As `crate::intl::intl_subclass_super`. +pub(crate) unsafe fn intl_subclass_super( + parent: f64, + this_box: f64, + args_ptr: *const f64, + args_len: usize, +) -> bool { + SUBCLASS_SUPER + .get() + .is_some_and(|f| f(parent, this_box, args_ptr, args_len)) +} + +/// `None` without the ECMA-402 formatter. +pub(crate) fn number_to_locale_string( + value: f64, + locales: f64, + options: f64, +) -> Option<*mut StringHeader> { + NUMBER_TO_LOCALE_STRING + .get() + .map(|f| f(value, locales, options)) +} + +/// `None` without the ECMA-402 formatter. +pub(crate) fn bigint_to_locale_string( + value: f64, + locales: f64, + options: f64, +) -> Option<*mut StringHeader> { + BIGINT_TO_LOCALE_STRING + .get() + .map(|f| f(value, locales, options)) +} + +/// The hub half of the `intl-namespace` install. +#[cfg(feature = "intl-namespace")] +pub(crate) fn install() { + INSTANCEOF.set(super::intl_instanceof); + IS_CONSTRUCTOR_VALUE.set(super::is_intl_constructor_value); + SUBCLASS_SUPER.set(super::intl_subclass_super); + NUMBER_TO_LOCALE_STRING.set(super::number_to_locale_string); + BIGINT_TO_LOCALE_STRING.set(super::bigint_to_locale_string); + crate::object::date_proto_thunks::install_date_to_locale_opts(); +} diff --git a/crates/perry-runtime/src/json/stringify.rs b/crates/perry-runtime/src/json/stringify.rs index c200a700a2..7218c86df3 100644 --- a/crates/perry-runtime/src/json/stringify.rs +++ b/crates/perry-runtime/src/json/stringify.rs @@ -533,9 +533,8 @@ pub(crate) unsafe fn stringify_value(value: f64, type_hint: u32, buf: &mut Strin // Temporal (#4686): `JSON.stringify(temporal)` calls `toJSON`, which // returns the canonical ISO string — emitted quoted. Detect before the // generic object path (the cell is not an enumerable ObjectHeader). - #[cfg(feature = "temporal")] if crate::temporal::is_temporal_cell_addr(ptr as usize) { - if let Some(s) = crate::temporal::temporal_iso_string(value) { + if let Some(s) = crate::temporal::hooked::iso_string(value) { write_escaped_string(buf, &s); } else { buf.push_str("null"); @@ -811,9 +810,8 @@ pub(crate) unsafe fn stringify_value_depth( } // Temporal (#4686): `toJSON` → quoted ISO string. See the matching // branch in `stringify_value`. - #[cfg(feature = "temporal")] if crate::temporal::is_temporal_cell_addr(ptr as usize) { - if let Some(s) = crate::temporal::temporal_iso_string(value) { + if let Some(s) = crate::temporal::hooked::iso_string(value) { write_escaped_string(buf, &s); } else { buf.push_str("null"); diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index 9a51e9ea40..727801b960 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -87,6 +87,8 @@ pub mod bigint; pub mod r#box; pub mod buffer; mod build_stamp; +pub(crate) mod dyn_eval_hooks; +pub mod feature_hooks; /// The layout facts generated code bakes in (`perry-abi`). pub use perry_abi as codegen_abi; pub(crate) mod cold_sort; diff --git a/crates/perry-runtime/src/module_require.rs b/crates/perry-runtime/src/module_require.rs index 10c918edf4..9b207934cc 100644 --- a/crates/perry-runtime/src/module_require.rs +++ b/crates/perry-runtime/src/module_require.rs @@ -1577,8 +1577,7 @@ fn dynamic_import_fallback_promise(spec: f64, options: f64, deferred_note: Optio let promise = crate::promise::js_promise_resolved(ns_handle.get_nanbox_f64()); return js_nanbox_pointer(promise as i64); } - #[cfg(feature = "dyn-eval")] - if let Some(namespace) = dynamic_import_javascript_data_url(&spec_str) { + if let Some(namespace) = crate::dyn_eval_hooks::dynamic_import_data_url(&spec_str) { let promise = crate::promise::js_promise_resolved(namespace); return js_nanbox_pointer(promise as i64); } @@ -1617,7 +1616,7 @@ fn dynamic_import_fallback_promise(spec: f64, options: f64, deferred_note: Optio } #[cfg(feature = "dyn-eval")] -fn dynamic_import_javascript_data_url(specifier: &str) -> Option { +pub(crate) fn dynamic_import_javascript_data_url(specifier: &str) -> Option { let encoded = specifier.strip_prefix("data:text/javascript,")?; let mut decoded = Vec::with_capacity(encoded.len()); let bytes = encoded.as_bytes(); diff --git a/crates/perry-runtime/src/module_require/data_import.rs b/crates/perry-runtime/src/module_require/data_import.rs index 376e9b7de5..8d3afb7460 100644 --- a/crates/perry-runtime/src/module_require/data_import.rs +++ b/crates/perry-runtime/src/module_require/data_import.rs @@ -79,8 +79,13 @@ pub(super) fn load(specifier: &str, options: f64) -> Result, f64> { unsafe { crate::json::js_json_parse_result(source) } .map(|value| f64::from_bits(value.bits()))? } - #[cfg(feature = "bun-cli-utils")] - "toml" => crate::bun_compat::toml_parse_result(&source)?, + // TOML is parsed only when `bun-cli-utils` is installed (see + // `bun_compat::cli_utils_hooks`); otherwise this takes the + // deferred-error path below, as a runtime without it always has. + "toml" => match crate::bun_compat::toml_parse_result(&source) { + Some(parsed) => parsed?, + None => return Ok(None), + }, // Optimized builds retain bun-cli-utils for sites with options. // A deliberately minimal runtime still uses the deferred error. _ => return Ok(None), diff --git a/crates/perry-runtime/src/object/class_constructors.rs b/crates/perry-runtime/src/object/class_constructors.rs index 2aef916811..f2533cc878 100644 --- a/crates/perry-runtime/src/object/class_constructors.rs +++ b/crates/perry-runtime/src/object/class_constructors.rs @@ -743,7 +743,6 @@ pub unsafe extern "C" fn js_super_construct_apply( // and stash the returned cell as the subclass instance's brand — the // `super(...spread)` counterpart of the `js_fetch_or_value_super` branch // that handles non-spread `super(a, b)`. (#5587) - #[cfg(feature = "temporal")] { let parent_val = crate::object::class_registry::js_get_dynamic_parent_value(child_cid); if crate::object::global_this::temporal_ctor_kind(parent_val).is_some() { @@ -757,7 +756,7 @@ pub unsafe extern "C" fn js_super_construct_apply( for i in 0..n { flat.push(crate::array::js_array_get_f64(arr, i as u32)); } - crate::object::global_this::temporal_subclass_super( + crate::temporal::hooked::subclass_super( parent_val, this_box, flat.as_ptr(), @@ -773,10 +772,9 @@ pub unsafe extern "C" fn js_super_construct_apply( // reason as the instanceof probe: with the feature off no Intl // constructor value exists, so the branch is unreachable, and skipping it // keeps this always-live path from pinning the Intl constructor web. - #[cfg(feature = "intl-namespace")] { let parent_val = crate::object::class_registry::js_get_dynamic_parent_value(child_cid); - if crate::intl::is_intl_constructor_value(parent_val) { + if crate::intl::hooked::is_intl_constructor_value(parent_val) { let this_box = crate::value::js_nanbox_pointer(this_raw); let n = if arr.is_null() { 0 @@ -787,7 +785,12 @@ pub unsafe extern "C" fn js_super_construct_apply( for i in 0..n { flat.push(crate::array::js_array_get_f64(arr, i as u32)); } - crate::intl::intl_subclass_super(parent_val, this_box, flat.as_ptr(), flat.len()); + crate::intl::hooked::intl_subclass_super( + parent_val, + this_box, + flat.as_ptr(), + flat.len(), + ); } } undef diff --git a/crates/perry-runtime/src/object/date_proto_thunks.rs b/crates/perry-runtime/src/object/date_proto_thunks.rs index 267e1c987d..5bdd9f4803 100644 --- a/crates/perry-runtime/src/object/date_proto_thunks.rs +++ b/crates/perry-runtime/src/object/date_proto_thunks.rs @@ -584,15 +584,32 @@ date_setter_thunk!(date_set_utc_milliseconds, 1, 6); /// the feature off no program in this binary can call these thunks, so the /// fallback simply defers to the non-locale formatter instead of statically /// pinning the Intl formatting web from the always-installed Date prototype. -#[cfg(not(feature = "intl-namespace"))] -fn date_to_locale_opts_impl(_rest: f64, _ctx: crate::intl::TemporalLocaleCtx) -> f64 { +/// +/// The always-installed Date prototype reaches the Intl formatter only through +/// the slot the `intl-namespace` install fills (see `crate::feature_hooks`); +/// without it this defers to the non-locale formatter, exactly as a build +/// without the feature does. +fn date_to_locale_opts_impl(rest: f64, ctx: crate::intl::TemporalLocaleCtx) -> f64 { + if let Some(format) = DATE_TO_LOCALE_OPTS.get() { + return format(rest, ctx); + } let this = require_date_this(); let s = crate::date::js_date_to_locale_string(this); crate::value::js_nanbox_string(s as i64) } +static DATE_TO_LOCALE_OPTS: crate::feature_hooks::Hook< + fn(f64, crate::intl::TemporalLocaleCtx) -> f64, +> = crate::feature_hooks::Hook::empty(); + +/// The `intl-namespace` install's Date-prototype half. +#[cfg(feature = "intl-namespace")] +pub(crate) fn install_date_to_locale_opts() { + DATE_TO_LOCALE_OPTS.set(date_to_locale_opts_intl); +} + #[cfg(feature = "intl-namespace")] -fn date_to_locale_opts_impl(rest: f64, ctx: crate::intl::TemporalLocaleCtx) -> f64 { +fn date_to_locale_opts_intl(rest: f64, ctx: crate::intl::TemporalLocaleCtx) -> f64 { let this = require_date_this(); let epoch_ms = crate::date::date_cell_timestamp(this); if epoch_ms.is_nan() { diff --git a/crates/perry-runtime/src/object/field_get_set.rs b/crates/perry-runtime/src/object/field_get_set.rs index eba8ac46d8..b3c4601ba6 100644 --- a/crates/perry-runtime/src/object/field_get_set.rs +++ b/crates/perry-runtime/src/object/field_get_set.rs @@ -125,7 +125,8 @@ pub extern "C" fn js_fetch_unwrap_handle(value: f64) -> f64 { /// instance (a plain heap object) can only reach its members through this /// stashed cell. Stored as a real pointer-valued field so GC keeps the cell /// alive and rewrites the slot on evacuation. (#5587) -#[cfg(feature = "temporal")] +// Ungated: always-live property lookup compares against it (see +// `crate::temporal::hooked`), and a constant keeps nothing alive. pub(crate) const TEMPORAL_SUBCLASS_CELL_FIELD: &[u8] = b"__perry_temporal_cell__"; /// Has any `class X extends Temporal.` instance EVER stashed a cell in diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs index caf9a0ea7b..17b459c502 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs @@ -1222,7 +1222,6 @@ fn get_field_by_name_past_data_probe( // bare value is rare; the `value.method()` call form is handled in // `js_native_call_method`). `obj` may be NaN-boxed (top16 0x7FFD) or a // raw-I64 pointer (top16 0). - #[cfg(feature = "temporal")] { let bits = obj as u64; let top16 = bits >> 48; @@ -1253,7 +1252,7 @@ fn get_field_by_name_past_data_probe( ) { return JSValue::from_bits(v.to_bits()); } - if let Some(v) = crate::temporal::dispatch::get_property(boxed, &name) { + if let Some(v) = crate::temporal::hooked::get_property(boxed, &name) { return JSValue::from_bits(v.to_bits()); } // A prototype METHOD read as a value (`d.abs`, not `d.abs()`): @@ -1262,7 +1261,7 @@ fn get_field_by_name_past_data_probe( // spread/dynamic call `d[m](...args)` to a property read + apply, // so the read must yield a callable. Only bind genuine method // names so an unknown property still reads as `undefined`. (#5587) - if crate::temporal::dispatch::has_method(boxed, &name) { + if crate::temporal::hooked::has_method(boxed, &name) { let heap_name = { let layout = std::alloc::Layout::from_size_align(key_bytes.len().max(1), 1) diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs index b78abaeaf2..a0453a0ff6 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs @@ -1834,14 +1834,13 @@ pub(crate) fn get_field_by_name_object_tail( // (cross-) trigger the other marker's reader, an infinite recursion that // stack-overflows. Methods read as fused `inst.m(...)` calls are handled // in `native_call_method.rs`. (#5587) - #[cfg(feature = "temporal")] if !key.is_null() && key_bytes != crate::object::TEMPORAL_SUBCLASS_CELL_FIELD && key_bytes != FETCH_SUBCLASS_HANDLE_FIELD { - if let Some(cell) = crate::object::temporal_subclass_cell(obj as usize) { + if let Some(cell) = crate::temporal::hooked::subclass_cell(obj as usize) { let name = String::from_utf8_lossy(key_bytes); - if let Some(v) = crate::temporal::dispatch::get_property(cell, &name) { + if let Some(v) = crate::temporal::hooked::get_property(cell, &name) { return JSValue::from_bits(v.to_bits()); } // A prototype METHOD read as a value (`sub.abs`, not `sub.abs()`): @@ -1849,7 +1848,7 @@ pub(crate) fn get_field_by_name_object_tail( // `js_native_call_method` (whose Temporal-subclass arm forwards to // the cell). Only bind genuine method names so an unknown property // still reads as `undefined`. Mirrors the fetch body-method bind. - if crate::temporal::dispatch::has_method(cell, &name) { + if crate::temporal::hooked::has_method(cell, &name) { let this_f64 = crate::value::js_nanbox_pointer(obj as i64); let heap_name = { let layout = diff --git a/crates/perry-runtime/src/object/global_this.rs b/crates/perry-runtime/src/object/global_this.rs index 79c061d9e3..cc3d82d5ef 100644 --- a/crates/perry-runtime/src/object/global_this.rs +++ b/crates/perry-runtime/src/object/global_this.rs @@ -42,6 +42,13 @@ mod bigint_promise; mod builtin_thunks; mod ctor_thunks; mod fetch_globals; + +/// `dyn-eval` install: every hub slot the script evaluator fills in this +/// module (see `crate::feature_hooks`). +#[cfg(feature = "dyn-eval")] +pub(crate) fn install_dyn_eval() { + fetch_globals::install_global_eval(); +} mod generator; mod install_static; mod math_temporal; @@ -172,6 +179,8 @@ pub(crate) use install_static::{ #[cfg(feature = "temporal")] pub(crate) use math_temporal::install_temporal_namespace; #[cfg(feature = "temporal")] +pub(crate) use math_temporal::temporal_ctor_kind_impl; +#[cfg(feature = "temporal")] pub(crate) use math_temporal::temporal_kind_prototype; pub(crate) use math_temporal::{install_math_namespace, temporal_ctor_kind}; pub(crate) use populate::{ diff --git a/crates/perry-runtime/src/object/global_this/builtin_thunks.rs b/crates/perry-runtime/src/object/global_this/builtin_thunks.rs index 0f5cfc3eac..68b3fde04a 100644 --- a/crates/perry-runtime/src/object/global_this/builtin_thunks.rs +++ b/crates/perry-runtime/src/object/global_this/builtin_thunks.rs @@ -520,19 +520,19 @@ fn js_function_ctor_from_strings_impl(args_ptr: *const f64, args_len: usize) -> // feature-probing libraries (zod's JIT probe, #6031) still get an honest // signal: the probe now SUCCEEDS and their generated code runs // interpreted. - #[cfg(feature = "dyn-eval")] - { - return crate::dyn_eval::dyn_function_from_strings(&args_vec); - } - // Without the `dyn-eval` feature (size-optimized builds that carry no - // dynamic-eval site), keep the historical clean throw: it lets - // feature-detecting libraries take their non-`Function` fallback. The - // eprintln names the offending library for diagnostics. - #[cfg(not(feature = "dyn-eval"))] - { - let body = args_vec.last().map(String::as_str).unwrap_or(""); - refuse_dynamic_function(args_len, body) + // The interpreter is reached through the slot the `dyn-eval` install + // fills (see `crate::dyn_eval_hooks`), so this always-live constructor does + // not keep it in programs that never build a function from source. + if let Some(function) = crate::dyn_eval_hooks::function_from_strings(&args_vec) { + return function; } + // Without the evaluator (a size-optimized build with no dynamic-eval site, + // or a prebuilt runtime whose program did not install it), keep the + // historical clean throw: it lets feature-detecting libraries take their + // non-`Function` fallback. The eprintln names the offending library for + // diagnostics. + let body = args_vec.last().map(String::as_str).unwrap_or(""); + refuse_dynamic_function(args_len, body) } /// #10423: the `Function` constructor called WITHOUT `new` through a value — @@ -566,7 +566,6 @@ fn function_call_thunk_impl(rest: f64) -> f64 { js_function_ctor_from_strings_impl(values.as_ptr(), values.len()) } -#[cfg(any(not(feature = "dyn-eval"), test))] fn refuse_dynamic_function(args_len: usize, body: &str) -> ! { let preview: String = body.chars().take(160).collect(); eprintln!( 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 488b43b269..e26c36b698 100644 --- a/crates/perry-runtime/src/object/global_this/fetch_globals.rs +++ b/crates/perry-runtime/src/object/global_this/fetch_globals.rs @@ -811,7 +811,6 @@ pub unsafe extern "C" fn js_fetch_or_value_super( // Request/Response branch below recovers via the decl-time stash. Mirror that: // when the immediate value isn't a Temporal ctor, fall back to the parent // value recorded against this instance's class id at declaration time. - #[cfg(feature = "temporal")] { let temporal_parent = if super::temporal_ctor_kind(parent_val).is_some() { parent_val @@ -821,7 +820,7 @@ pub unsafe extern "C" fn js_fetch_or_value_super( } else { parent_val }; - if temporal_subclass_super(temporal_parent, this_box, args_ptr, args_len) { + if crate::temporal::hooked::subclass_super(temporal_parent, this_box, args_ptr, args_len) { return undef; } } @@ -834,9 +833,8 @@ pub unsafe extern "C" fn js_fetch_or_value_super( // Behind `intl-namespace`: with the feature off no Intl constructor value // exists, so this probe can never match — and skipping it keeps this // always-live construct path from pinning the Intl web. - #[cfg(feature = "intl-namespace")] { - let intl_parent = if crate::intl::is_intl_constructor_value(parent_val) { + let intl_parent = if crate::intl::hooked::is_intl_constructor_value(parent_val) { parent_val } else if let Some(obj) = subclass_this_object_ptr(this_box) { let cid = crate::object::js_object_get_class_id(obj); @@ -844,7 +842,7 @@ pub unsafe extern "C" fn js_fetch_or_value_super( } else { parent_val }; - if crate::intl::intl_subclass_super(intl_parent, this_box, args_ptr, args_len) { + if crate::intl::hooked::intl_subclass_super(intl_parent, this_box, args_ptr, args_len) { return undef; } } @@ -1198,31 +1196,45 @@ pub(crate) extern "C" fn global_this_eval_thunk( let ptr = crate::string::js_string_from_bytes(s.as_ptr(), s.len() as u32); crate::value::js_nanbox_string(ptr as i64) } - _ => { - #[cfg(feature = "dyn-eval")] - { - let body = body.to_string(); - let scope = crate::gc::RuntimeHandleScope::new(); - let global = scope.root_nanbox_f64(js_get_global_this()); - let lexical = scope.root_nanbox_f64(crate::dyn_eval::script_environment( - global.get_nanbox_f64(), - &[], - )); - crate::dyn_eval::eval_script_in( - &body, - global.get_nanbox_f64(), - global.get_nanbox_f64(), - lexical.get_nanbox_f64(), - ) - } - #[cfg(not(feature = "dyn-eval"))] - { - f64::from_bits(crate::value::TAG_UNDEFINED) - } - } + // The script evaluator (the JS parser and everything behind it) is + // reached through a slot the `dyn-eval` install fills, so a program + // that never evaluates source does not link it (see + // `crate::feature_hooks`). Without it this answers `undefined`, as a + // build compiled without `dyn-eval` always has. + _ => match GLOBAL_EVAL.get() { + Some(eval) => eval(body), + None => f64::from_bits(crate::value::TAG_UNDEFINED), + }, } } +/// Indirect `globalThis.eval(source)` of a body the fast paths above did not +/// answer; filled by the `dyn-eval` install. +static GLOBAL_EVAL: crate::feature_hooks::Hook f64> = + crate::feature_hooks::Hook::empty(); + +#[cfg(feature = "dyn-eval")] +fn global_eval_script(body: &str) -> f64 { + let body = body.to_string(); + let scope = crate::gc::RuntimeHandleScope::new(); + let global = scope.root_nanbox_f64(js_get_global_this()); + let lexical = scope.root_nanbox_f64(crate::dyn_eval::script_environment( + global.get_nanbox_f64(), + &[], + )); + crate::dyn_eval::eval_script_in( + &body, + global.get_nanbox_f64(), + global.get_nanbox_f64(), + lexical.get_nanbox_f64(), + ) +} + +#[cfg(feature = "dyn-eval")] +pub(crate) fn install_global_eval() { + GLOBAL_EVAL.set(global_eval_script); +} + #[cfg(test)] mod dynamic_super_new_target_tests { use super::*; diff --git a/crates/perry-runtime/src/object/global_this/math_temporal.rs b/crates/perry-runtime/src/object/global_this/math_temporal.rs index 1ade09495d..f399ff833d 100644 --- a/crates/perry-runtime/src/object/global_this/math_temporal.rs +++ b/crates/perry-runtime/src/object/global_this/math_temporal.rs @@ -751,8 +751,18 @@ fn build_zoned_date_time_prototype() -> *mut ObjectHeader { /// same-named user closure never matches). Used by `instanceof` to make /// `zdt instanceof Temporal.ZonedDateTime` resolve to `true` even though /// Temporal values dispatch via brand arms, not a real prototype chain. -#[cfg(feature = "temporal")] +/// +/// `instanceof`, class construction and `Function.prototype` reads are live in +/// every program; comparing against the constructor closures would pin every +/// Temporal constructor, so they reach the comparison through the slot the +/// `temporal` install fills (see `crate::temporal::hooked`). Without it no +/// Temporal constructor exists and this answers `None`. pub(crate) fn temporal_ctor_kind(type_ref: f64) -> Option { + crate::temporal::hooked::ctor_kind(type_ref) +} + +#[cfg(feature = "temporal")] +pub(crate) fn temporal_ctor_kind_impl(type_ref: f64) -> Option { use crate::temporal::TemporalKind; let jv = JSValue::from_bits(type_ref.to_bits()); if !jv.is_pointer() { @@ -807,14 +817,6 @@ pub(crate) fn temporal_ctor_kind(type_ref: f64) -> Option Option { - None -} - /// Resolve `Temporal..prototype` for a Temporal value's `kind` by /// navigating the live `globalThis.Temporal..prototype` chain (the /// prototype object is stamped on each constructor closure's `prototype` diff --git a/crates/perry-runtime/src/object/global_this/populate.rs b/crates/perry-runtime/src/object/global_this/populate.rs index c64501e709..81104ff910 100644 --- a/crates/perry-runtime/src/object/global_this/populate.rs +++ b/crates/perry-runtime/src/object/global_this/populate.rs @@ -751,10 +751,13 @@ pub(crate) fn populate_global_this_builtins(singleton_at_entry: *mut ObjectHeade set_intrinsic_to_string_tag(ns_obj, "Atomics"); } "Intl" => crate::intl::install_intl_namespace(ns_obj), - #[cfg(feature = "temporal")] + // Members come from the `temporal` install (see + // `crate::temporal::hooked`); without it the namespace stays a + // plain empty object, as in a build without the feature. "Temporal" => { - install_temporal_namespace(ns_obj); - set_intrinsic_to_string_tag(ns_obj, "Temporal"); + if crate::temporal::hooked::install_namespace(ns_obj) { + set_intrinsic_to_string_tag(ns_obj, "Temporal"); + } } _ => {} } diff --git a/crates/perry-runtime/src/object/instanceof/dynamic_dispatch.rs b/crates/perry-runtime/src/object/instanceof/dynamic_dispatch.rs index af839762a9..c73bea93e9 100644 --- a/crates/perry-runtime/src/object/instanceof/dynamic_dispatch.rs +++ b/crates/perry-runtime/src/object/instanceof/dynamic_dispatch.rs @@ -44,7 +44,6 @@ pub extern "C" fn js_instanceof_dynamic(value: f64, type_ref: f64) -> f64 { // recover that cell and compare its kind. The receiver reaches here both // NaN-boxed (top16 == 0x7FFD) and as a raw-I64 heap pointer (top16 == 0, // how module-level object vars are stored) — accept both. (#5587) - #[cfg(feature = "temporal")] { let bits = value.to_bits(); let top16 = bits >> 48; @@ -56,7 +55,7 @@ pub extern "C" fn js_instanceof_dynamic(value: f64, type_ref: f64) -> f64 { 0 }; if raw != 0 { - if let Some(cell) = unsafe { crate::object::temporal_subclass_cell(raw) } { + if let Some(cell) = unsafe { crate::temporal::hooked::subclass_cell(raw) } { if crate::temporal::temporal_kind(cell) == Some(kind) { return f64::from_bits(crate::value::TAG_TRUE); } @@ -425,8 +424,7 @@ pub extern "C" fn js_instanceof_dynamic(value: f64, type_ref: f64) -> f64 { // Intl constructor value can exist (the namespace install is a no-op), so // the probe could never match — and skipping it keeps this always-live // dispatcher from statically pinning every Intl constructor thunk (~204 KB). - #[cfg(feature = "intl-namespace")] - if let Some(is_inst) = crate::intl::intl_instanceof(value, type_ref) { + if let Some(is_inst) = crate::intl::hooked::intl_instanceof(value, type_ref) { return if is_inst { f64::from_bits(crate::value::TAG_TRUE) } else { diff --git a/crates/perry-runtime/src/object/iterator_prototypes.rs b/crates/perry-runtime/src/object/iterator_prototypes.rs index 6c197b35a1..d3b4b1cc75 100644 --- a/crates/perry-runtime/src/object/iterator_prototypes.rs +++ b/crates/perry-runtime/src/object/iterator_prototypes.rs @@ -209,9 +209,11 @@ unsafe fn dispatch_on_implicit_this(method: &str) -> f64 { crate::string::STRING_ITERATOR_CLASS_ID => { crate::string::dispatch_string_iterator_method_builtin(obj, method) } - #[cfg(feature = "regex-engine")] crate::regex::REGEXP_STRING_ITERATOR_CLASS_ID => { - crate::regex::dispatch_regexp_string_iterator_method_builtin(obj, method) + match crate::regex::hooked_iterator_method_builtin(obj, method) { + Some(value) => value, + None => brand_type_error(method), + } } _ => brand_type_error(method), } diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index d761a5ed66..9552f41e12 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -127,6 +127,15 @@ pub(crate) use global_fetch::scan_pending_fetch_signal_root_mut; /// the GC contract. pub(crate) mod chain_store; mod global_this; +#[cfg(feature = "dyn-eval")] +pub(crate) use global_this::install_dyn_eval; +#[cfg(feature = "temporal")] +pub(crate) use global_this::{ + install_temporal_namespace as global_this_install_temporal_namespace, + temporal_ctor_kind_impl as global_this_temporal_ctor_kind, + temporal_kind_prototype as global_this_temporal_kind_prototype, + temporal_subclass_super as global_this_temporal_subclass_super, +}; pub mod handle_expando; pub(crate) mod inherited_read_cache; pub(crate) mod prop_plan; diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 055c52795a..82fa38ce3d 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -2681,12 +2681,11 @@ pub unsafe extern "C-unwind" fn js_native_call_method( // prototype walk) has missed by here, so a subclass override still wins; // only genuinely inherited Temporal methods reach this forward. Route them // to the stashed cell (`temporal_subclass_cell`). (#5587) - #[cfg(feature = "temporal")] if jsval().is_pointer() { let raw = crate::value::js_nanbox_get_pointer(object()) as usize; - if let Some(cell) = crate::object::temporal_subclass_cell(raw) { + if let Some(cell) = crate::temporal::hooked::subclass_cell(raw) { let args = refreshed_args(); - return crate::temporal::dispatch::call_method(cell, method_name, &args); + return crate::temporal::hooked::call_method(cell, method_name, &args); } } diff --git a/crates/perry-runtime/src/object/native_call_method/collection_methods.rs b/crates/perry-runtime/src/object/native_call_method/collection_methods.rs index fca8c802f2..92321f9064 100644 --- a/crates/perry-runtime/src/object/native_call_method/collection_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/collection_methods.rs @@ -419,14 +419,14 @@ pub(super) unsafe fn dispatch_raw_pointer( method_name, )); } - #[cfg(feature = "regex-engine")] if (*obj).class_id == crate::regex::REGEXP_STRING_ITERATOR_CLASS_ID && crate::collection_iter_object::is_intrinsic_iterator_method(method_name) { - return Some(crate::regex::dispatch_regexp_string_iterator_method( - obj as *mut ObjectHeader, - method_name, - )); + if let Some(value) = + crate::regex::hooked_iterator_method(obj as *mut ObjectHeader, method_name) + { + return Some(value); + } } // #2874: lazy iterator-helper objects, same as the NaN-boxed path. if (*obj).class_id == crate::iterator_helpers::ITERATOR_HELPER_CLASS_ID { diff --git a/crates/perry-runtime/src/object/native_call_method/object_proto.rs b/crates/perry-runtime/src/object/native_call_method/object_proto.rs index 8a88e9f98c..397a638863 100644 --- a/crates/perry-runtime/src/object/native_call_method/object_proto.rs +++ b/crates/perry-runtime/src/object/native_call_method/object_proto.rs @@ -167,9 +167,8 @@ pub(crate) unsafe fn js_object_default_to_locale_string(receiver: f64) -> f64 { // the Temporal dispatch via the generic method-call path (which preserves // args). Only the zero-arg form arrives here; dispatch it with an empty // slice so the Temporal method applies its type-appropriate defaults. - #[cfg(feature = "temporal")] if crate::temporal::is_temporal_value(receiver) { - return crate::temporal::dispatch::call_method(receiver, "toLocaleString", &[]); + return crate::temporal::hooked::call_method(receiver, "toLocaleString", &[]); } // #7428: a BigInt receiver must format through // `BigInt.prototype.toLocaleString`, i.e. with the DEFAULT locale's digit @@ -188,11 +187,11 @@ pub(crate) unsafe fn js_object_default_to_locale_string(receiver: f64) -> f64 { // `bigint_proto_to_locale_string_thunk`. That asymmetry is why the explicit // `toLocaleString(undefined)` was already correct while the bare call was // not — the two forms never met. - #[cfg(feature = "intl-namespace")] if jsval.is_bigint() { let undef = f64::from_bits(crate::value::TAG_UNDEFINED); - let s = crate::intl::bigint_to_locale_string(receiver, undef, undef); - return f64::from_bits(JSValue::string_ptr(s).bits()); + if let Some(s) = crate::intl::hooked::bigint_to_locale_string(receiver, undef, undef) { + return f64::from_bits(JSValue::string_ptr(s).bits()); + } } // Primitive receivers (including pointer-tagged Symbols) inherit the // Object method but resolve `toString` on their own prototype chain. diff --git a/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs b/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs index c66c4b9c01..9b3879d5b5 100644 --- a/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs @@ -24,10 +24,9 @@ pub(super) unsafe fn dispatch_primitive( // `Temporal.*` value is a NaN-boxed pointer to a custom cell with no // codegen fast-path, so every method call funnels through here. The router // throws `TypeError` for an unknown method name on a real Temporal receiver. - #[cfg(feature = "temporal")] if crate::temporal::is_temporal_value(object) { let args = refreshed_args(); - return Some(crate::temporal::dispatch::call_method( + return Some(crate::temporal::hooked::call_method( object, method_name, &args, diff --git a/crates/perry-runtime/src/object/object_ops/prototype.rs b/crates/perry-runtime/src/object/object_ops/prototype.rs index 653400db9a..33578632a6 100644 --- a/crates/perry-runtime/src/object/object_ops/prototype.rs +++ b/crates/perry-runtime/src/object/object_ops/prototype.rs @@ -204,10 +204,9 @@ fn get_prototype_of_resolved(obj_value: f64) -> f64 { // (the test262 subclassing-ignored shape) requires that object, not `null`. // Resolve it via the live namespace; fall back to `null` only if Temporal // isn't reachable. (#5587) - #[cfg(feature = "temporal")] if crate::temporal::is_temporal_value(obj_value) { if let Some(kind) = crate::temporal::temporal_kind(obj_value) { - let proto = crate::object::global_this::temporal_kind_prototype(kind); + let proto = crate::temporal::hooked::kind_prototype(kind); if crate::value::JSValue::from_bits(proto.to_bits()).is_pointer() { return proto; } diff --git a/crates/perry-runtime/src/object/primitive_proto_thunks.rs b/crates/perry-runtime/src/object/primitive_proto_thunks.rs index b7ffdddcc0..fdc7a199ef 100644 --- a/crates/perry-runtime/src/object/primitive_proto_thunks.rs +++ b/crates/perry-runtime/src/object/primitive_proto_thunks.rs @@ -360,7 +360,6 @@ pub(super) extern "C" fn number_proto_to_locale_string_thunk( rest: f64, ) -> f64 { let n = number_receiver_or_throw("toLocaleString"); - #[cfg(feature = "intl-namespace")] { let args = super::global_this::global_this_rest_array_values(rest); let undef = f64::from_bits(crate::value::TAG_UNDEFINED); @@ -368,12 +367,15 @@ pub(super) extern "C" fn number_proto_to_locale_string_thunk( let options = args.get(1).copied().unwrap_or(undef); let defaulted = crate::value::JSValue::from_bits(locales.to_bits()).is_undefined() && crate::value::JSValue::from_bits(options.to_bits()).is_undefined(); + // The ECMA-402 formatter is reached through the `intl-namespace` + // install (see `crate::intl::hooked`); without it this keeps the + // plain grouping helper, as a build without the feature does. if !defaulted { - return string_value(crate::intl::number_to_locale_string(n, locales, options)); + if let Some(s) = crate::intl::hooked::number_to_locale_string(n, locales, options) { + return string_value(s); + } } } - #[cfg(not(feature = "intl-namespace"))] - let _ = rest; string_value(crate::date::js_number_to_locale_string(n)) } @@ -436,11 +438,8 @@ pub(super) extern "C" fn bigint_proto_to_locale_string_thunk( let undef = f64::from_bits(crate::value::TAG_UNDEFINED); let _locales = args.first().copied().unwrap_or(undef); let _options = args.get(1).copied().unwrap_or(undef); - #[cfg(feature = "intl-namespace")] - { - string_value(crate::intl::bigint_to_locale_string( - value, _locales, _options, - )) + if let Some(s) = crate::intl::hooked::bigint_to_locale_string(value, _locales, _options) { + return string_value(s); } // Binary size: this thunk is the ONLY retainer of the ECMA-402 // number-formatting machinery in a program that never mentions @@ -453,10 +452,7 @@ pub(super) extern "C" fn bigint_proto_to_locale_string_thunk( // supplied `locales`/`options` either. ECMA-262 leaves the result // implementation-defined when ECMA-402 is absent, so render plain // decimal digits. - #[cfg(not(feature = "intl-namespace"))] - { - string_value(crate::value::js_jsvalue_to_string_radix(value, 10.0)) - } + string_value(crate::value::js_jsvalue_to_string_radix(value, 10.0)) } /// `String.prototype.toString()` — brand-checked: returns the underlying string diff --git a/crates/perry-runtime/src/regex.rs b/crates/perry-runtime/src/regex.rs index a7d2b185ba..79cf40271d 100644 --- a/crates/perry-runtime/src/regex.rs +++ b/crates/perry-runtime/src/regex.rs @@ -103,6 +103,43 @@ use utf16::{byte_index_to_utf16_index, utf16_index_to_byte}; /// always-linked iterator-prototype dispatch, so it stays ungated even when /// the regex engine (which produces these iterators) is compiled out. pub const REGEXP_STRING_ITERATOR_CLASS_ID: u32 = 0xFFFF_000A; + +/// `matchAll` iterator methods reached from the always-live generic dispatchers +/// (`js_native_call_method`, the iterator prototypes' `next`). They reach the +/// regex engine only through slots the `regex-engine` install fills (see +/// `crate::feature_hooks`); without it no RegExp string iterator exists and +/// these answer `None`, so the dispatchers take their no-regex path. +static ITERATOR_METHOD: crate::feature_hooks::Hook< + unsafe fn(*mut crate::ObjectHeader, &str) -> f64, +> = crate::feature_hooks::Hook::empty(); +static ITERATOR_METHOD_BUILTIN: crate::feature_hooks::Hook< + unsafe fn(*mut crate::ObjectHeader, &str) -> f64, +> = crate::feature_hooks::Hook::empty(); + +/// # Safety +/// `iter` must be a live RegExp string iterator object. +pub(crate) unsafe fn hooked_iterator_method( + iter: *mut crate::ObjectHeader, + method: &str, +) -> Option { + ITERATOR_METHOD.get().map(|f| f(iter, method)) +} + +/// # Safety +/// `iter` must be a live RegExp string iterator object. +pub(crate) unsafe fn hooked_iterator_method_builtin( + iter: *mut crate::ObjectHeader, + method: &str, +) -> Option { + ITERATOR_METHOD_BUILTIN.get().map(|f| f(iter, method)) +} + +/// The `regex-engine` install's hub half. +#[cfg(feature = "regex-engine")] +pub(crate) fn install_iterator_hooks() { + ITERATOR_METHOD.set(dispatch_regexp_string_iterator_method); + ITERATOR_METHOD_BUILTIN.set(dispatch_regexp_string_iterator_method_builtin); +} #[cfg(feature = "regex-engine")] pub use perex_replace_compat::*; #[cfg(not(feature = "regex-engine"))] diff --git a/crates/perry-runtime/src/symbol/iterator.rs b/crates/perry-runtime/src/symbol/iterator.rs index 0ca5f2d9a1..510c884d0f 100644 --- a/crates/perry-runtime/src/symbol/iterator.rs +++ b/crates/perry-runtime/src/symbol/iterator.rs @@ -560,12 +560,11 @@ pub unsafe extern "C" fn js_to_primitive(value: f64, hint: i32) -> f64 { // `"string"`/`"default"` — which is exactly what `"x" + plainDateTime` and // template interpolation need. (Direct `String(x)` already brand-checks; the // `+`/template coercion routed here did not.) - #[cfg(feature = "temporal")] if crate::temporal::is_temporal_value(value) { if hint == 1 { crate::object::throw_object_type_error(b"Cannot convert a Temporal value to a number"); } - if let Some(s) = crate::temporal::temporal_iso_string(value) { + if let Some(s) = crate::temporal::hooked::iso_string(value) { let p = js_string_from_bytes(s.as_ptr(), s.len() as u32); return crate::value::js_nanbox_string(p as i64); } diff --git a/crates/perry-runtime/src/temporal/hooked.rs b/crates/perry-runtime/src/temporal/hooked.rs new file mode 100644 index 0000000000..0b34ea93f5 --- /dev/null +++ b/crates/perry-runtime/src/temporal/hooked.rs @@ -0,0 +1,137 @@ +//! The Temporal operations the always-live runtime reaches. +//! +//! Generic operators (`==` / ToPrimitive / ToString / `JSON.stringify` / +//! `valueOf`), property lookup, `instanceof`, subclass construction, the GC +//! finalizer and `globalThis` population all have a Temporal arm. Those +//! callers are live in every program, so they must not name the +//! `temporal_rs`-backed implementation directly, or the prebuilt full-feature +//! runtime keeps Temporal (and the ICU calendar/time-zone data behind it) in +//! every binary. They keep their cheap `is_temporal_value` / +//! `is_temporal_cell_addr` guards and call these forwarders for the rest; the +//! forwarders reach the implementation only through slots the `temporal` +//! install fills (see `crate::feature_hooks`). +//! +//! A Temporal cell exists only after Temporal code ran, so an empty slot is +//! never reached behind a guard in practice; each forwarder still answers what +//! a build without the feature answers. + +use super::{TemporalCell, TemporalKind, TemporalValue}; +use crate::feature_hooks::Hook; +use crate::object::ObjectHeader; + +static CALL_METHOD: Hook f64> = Hook::empty(); +static ISO_STRING: Hook Option> = Hook::empty(); +static DURATION_UNIT_VALUES: Hook Option<[f64; 10]>> = Hook::empty(); +static GET_PROPERTY: Hook Option> = Hook::empty(); +static HAS_METHOD: Hook bool> = Hook::empty(); +static SUBCLASS_CELL: Hook Option> = Hook::empty(); +static SUBCLASS_SUPER: Hook bool> = Hook::empty(); +static KIND_PROTOTYPE: Hook f64> = Hook::empty(); +static INSTALL_NAMESPACE: Hook = Hook::empty(); +static FINALIZE_CELL: Hook = Hook::empty(); +static TO_EPOCH_MS: Hook Option> = Hook::empty(); +static CALENDAR_ID: Hook Option<&'static str>> = Hook::empty(); +static INSPECT_STRING: Hook Option> = Hook::empty(); +static CTOR_KIND: Hook Option> = Hook::empty(); + +pub fn call_method(recv: f64, name: &str, args: &[f64]) -> f64 { + CALL_METHOD + .get() + .map_or(f64::from_bits(crate::value::TAG_UNDEFINED), |f| { + f(recv, name, args) + }) +} + +pub fn iso_string(value: f64) -> Option { + ISO_STRING.get().and_then(|f| f(value)) +} + +pub fn duration_unit_values(value: f64) -> Option<[f64; 10]> { + DURATION_UNIT_VALUES.get().and_then(|f| f(value)) +} + +pub fn get_property(recv: f64, name: &str) -> Option { + GET_PROPERTY.get().and_then(|f| f(recv, name)) +} + +pub fn has_method(recv: f64, name: &str) -> bool { + HAS_METHOD.get().is_some_and(|f| f(recv, name)) +} + +/// # Safety +/// As `crate::object::temporal_subclass_cell`. +pub unsafe fn subclass_cell(obj: usize) -> Option { + SUBCLASS_CELL.get().and_then(|f| f(obj)) +} + +/// # Safety +/// As `crate::object::global_this::temporal_subclass_super`. +pub unsafe fn subclass_super( + parent: f64, + this_box: f64, + args_ptr: *const f64, + args_len: usize, +) -> bool { + SUBCLASS_SUPER + .get() + .is_some_and(|f| f(parent, this_box, args_ptr, args_len)) +} + +pub fn kind_prototype(kind: TemporalKind) -> f64 { + KIND_PROTOTYPE + .get() + .map_or(f64::from_bits(crate::value::TAG_UNDEFINED), |f| f(kind)) +} + +pub fn install_namespace(ns_obj: *mut ObjectHeader) -> bool { + match INSTALL_NAMESPACE.get() { + Some(install) => { + install(ns_obj); + true + } + None => false, + } +} + +/// # Safety +/// As `super::finalize_temporal_cell_for_gc`. +pub unsafe fn finalize_cell(cell: *mut TemporalCell) { + if let Some(f) = FINALIZE_CELL.get() { + f(cell); + } +} + +pub fn to_epoch_ms(tv: &TemporalValue) -> Option { + TO_EPOCH_MS.get().and_then(|f| f(tv)) +} + +pub fn calendar_id(value: f64) -> Option<&'static str> { + CALENDAR_ID.get().and_then(|f| f(value)) +} + +pub fn inspect_string(value: f64) -> Option { + INSPECT_STRING.get().and_then(|f| f(value)) +} + +pub fn ctor_kind(type_ref: f64) -> Option { + CTOR_KIND.get().and_then(|f| f(type_ref)) +} + +/// The `temporal` install. +#[cfg(feature = "temporal")] +pub(crate) fn install() { + CALL_METHOD.set(super::dispatch::call_method); + ISO_STRING.set(super::temporal_iso_string); + DURATION_UNIT_VALUES.set(super::duration_unit_values); + GET_PROPERTY.set(super::dispatch::get_property); + HAS_METHOD.set(super::dispatch::has_method); + SUBCLASS_CELL.set(crate::object::temporal_subclass_cell); + SUBCLASS_SUPER.set(crate::object::global_this_temporal_subclass_super); + KIND_PROTOTYPE.set(crate::object::global_this_temporal_kind_prototype); + INSTALL_NAMESPACE.set(crate::object::global_this_install_temporal_namespace); + FINALIZE_CELL.set(super::finalize_temporal_cell_impl); + TO_EPOCH_MS.set(super::temporal_to_epoch_ms_impl); + CALENDAR_ID.set(super::temporal_calendar_id_impl); + INSPECT_STRING.set(super::temporal_inspect_string); + CTOR_KIND.set(crate::object::global_this_temporal_ctor_kind); +} diff --git a/crates/perry-runtime/src/temporal/mod.rs b/crates/perry-runtime/src/temporal/mod.rs index 4c887b61f8..030445e978 100644 --- a/crates/perry-runtime/src/temporal/mod.rs +++ b/crates/perry-runtime/src/temporal/mod.rs @@ -30,6 +30,7 @@ use crate::value::JSValue; pub mod dispatch; #[cfg(feature = "temporal")] pub mod duration; +pub mod hooked; #[cfg(feature = "temporal")] pub mod instant; #[cfg(feature = "temporal")] @@ -312,8 +313,12 @@ pub fn duration_unit_values(value: f64) -> Option<[f64; 10]> { /// (PlainDate, PlainDateTime, PlainYearMonth, PlainMonthDay, ZonedDateTime), /// or `None` for types without a calendar (Instant, PlainTime, Duration) or /// non-Temporal values. -#[cfg(feature = "temporal")] pub fn temporal_calendar_id(value: f64) -> Option<&'static str> { + hooked::calendar_id(value) +} + +#[cfg(feature = "temporal")] +pub(crate) fn temporal_calendar_id_impl(value: f64) -> Option<&'static str> { match temporal_value_ref(value)? { TemporalValue::PlainDate(d) => Some(d.calendar().identifier()), TemporalValue::PlainDateTime(dt) => Some(dt.calendar().identifier()), @@ -324,11 +329,6 @@ pub fn temporal_calendar_id(value: f64) -> Option<&'static str> { } } -#[cfg(not(feature = "temporal"))] -pub fn temporal_calendar_id(_value: f64) -> Option<&'static str> { - None -} - /// Drop the embedded `temporal_rs` value when a Temporal cell is swept, /// releasing any Rust-heap it owns (e.g. a `ZonedDateTime` timezone string). /// Registered as the `TemporalCleanup` finalize hook in `gc/types.rs`. @@ -336,20 +336,22 @@ pub fn temporal_calendar_id(_value: f64) -> Option<&'static str> { /// # Safety /// `cell` must point at a live, fully-initialized `TemporalCell` that the GC is /// about to reclaim; it is not read again afterwards. -#[cfg(feature = "temporal")] +/// +/// The GC sweeper is live in every program, so it reaches the drop only through +/// the slot the `temporal` install fills (see `hooked`); without Temporal no +/// cell is ever allocated and nothing is reached. pub unsafe fn finalize_temporal_cell_for_gc(cell: *mut TemporalCell) { + hooked::finalize_cell(cell); +} + +#[cfg(feature = "temporal")] +pub(crate) unsafe fn finalize_temporal_cell_impl(cell: *mut TemporalCell) { if cell.is_null() { return; } std::ptr::drop_in_place(cell); } -/// Temporal gated off: no Temporal cell is ever allocated, so the GC never -/// reaches this finalize hook. Kept as a no-op so `gc/types.rs`'s registration -/// resolves without the engine. -#[cfg(not(feature = "temporal"))] -pub unsafe fn finalize_temporal_cell_for_gc(_cell: *mut TemporalCell) {} - /// Convert a Temporal value to epoch milliseconds for Intl.DateTimeFormat. /// /// Each Temporal type maps its fields to a Unix timestamp (UTC): @@ -359,8 +361,12 @@ pub unsafe fn finalize_temporal_cell_for_gc(_cell: *mut TemporalCell) {} /// - `PlainYearMonth`: use day=1 for the epoch base /// - `PlainMonthDay`: use year=1970 for the epoch base /// - `Duration`: no epoch representation → `None` -#[cfg(feature = "temporal")] pub fn temporal_to_epoch_ms(tv: &TemporalValue) -> Option { + hooked::to_epoch_ms(tv) +} + +#[cfg(feature = "temporal")] +pub(crate) fn temporal_to_epoch_ms_impl(tv: &TemporalValue) -> Option { let secs: i64 = match tv { TemporalValue::Instant(i) => return Some(i.epoch_milliseconds() as f64), TemporalValue::ZonedDateTime(z) => return Some(z.epoch_milliseconds() as f64), @@ -404,11 +410,6 @@ pub fn temporal_to_epoch_ms(tv: &TemporalValue) -> Option { Some(secs as f64 * 1000.0) } -#[cfg(not(feature = "temporal"))] -pub fn temporal_to_epoch_ms(_tv: &TemporalValue) -> Option { - match *_tv {} -} - /// Render a Temporal value as its canonical ISO-8601 / IXDTF string — the form /// `toString` and `toJSON` use. Returns `None` only if `value` is not a /// Temporal cell. diff --git a/crates/perry-runtime/src/tls.rs b/crates/perry-runtime/src/tls.rs index 0afc743a77..49a7537b85 100644 --- a/crates/perry-runtime/src/tls.rs +++ b/crates/perry-runtime/src/tls.rs @@ -1342,15 +1342,14 @@ pub fn tls_domain_to_ascii(host: &str) -> String { return String::new(); } - #[cfg(feature = "url-engine")] - { - return idna::domain_to_ascii(&normalized) - .ok() + if let Some(ascii) = crate::url::idna_domain_to_ascii(&normalized) { + return ascii .and_then(|ascii| crate::url::whatwg_canonicalize_host(&ascii)) .unwrap_or_default(); } - #[cfg(not(feature = "url-engine"))] + // Without the URL engine installed (the reduced TLS runtime omits the + // URL/IDNA tables): { // The reduced TLS runtime deliberately omits the URL/IDNA tables. Keep // Node's important numeric-host coercion and reject non-ASCII input diff --git a/crates/perry-runtime/src/url/mod.rs b/crates/perry-runtime/src/url/mod.rs index ad1eac3336..7ecd2d9475 100644 --- a/crates/perry-runtime/src/url/mod.rs +++ b/crates/perry-runtime/src/url/mod.rs @@ -171,16 +171,47 @@ pub(crate) fn object_prop_f64(obj: *mut ObjectHeader, key: &str) -> f64 { /// means for them (the hostname setter leaves the host unchanged; the /// `domainTo*` helpers return `""`), matching Node. pub(crate) fn whatwg_canonicalize_host(host: &str) -> Option { - #[cfg(feature = "url-engine")] - { + match CANONICALIZE_HOST.get() { + Some(canonicalize) => canonicalize(host), + // URL engine not installed: no WHATWG host parser, so pass the host + // through unchanged (the hand-rolled URL paths handle the common cases). + None => Some(host.to_string()), + } +} + +// The WHATWG host parser and IDNA (`url` / `idna`) are reached only through +// slots the `url-engine` install fills (see `crate::feature_hooks`): URL +// property setters, `domainTo*` and TLS servername handling are live in every +// program, and naming the crates directly would keep their tables in all of +// them. Each caller keeps the fallback a build without the feature has. +static CANONICALIZE_HOST: crate::feature_hooks::Hook Option> = + crate::feature_hooks::Hook::empty(); +static DOMAIN_TO_ASCII: crate::feature_hooks::Hook Option> = + crate::feature_hooks::Hook::empty(); +static DOMAIN_TO_UNICODE: crate::feature_hooks::Hook String> = + crate::feature_hooks::Hook::empty(); + +/// IDNA `domain_to_ascii`: `None` when the engine is not installed, +/// `Some(None)` when IDNA rejects the domain. +pub(crate) fn idna_domain_to_ascii(domain: &str) -> Option> { + DOMAIN_TO_ASCII.get().map(|f| f(domain)) +} + +/// IDNA `domain_to_unicode`; `None` when the engine is not installed. +pub(crate) fn idna_domain_to_unicode(domain: &str) -> Option { + DOMAIN_TO_UNICODE.get().map(|f| f(domain)) +} + +/// The `url-engine` install. +#[cfg(feature = "url-engine")] +pub(crate) fn install_engine() { + CANONICALIZE_HOST.set(|host| { url::Url::parse(&format!("http://{host}/")) .ok() .and_then(|u| u.host_str().map(str::to_string)) - } - // URL engine gated off: no WHATWG host parser, so pass the host through - // unchanged (the hand-rolled URL paths handle the common cases). - #[cfg(not(feature = "url-engine"))] - Some(host.to_string()) + }); + DOMAIN_TO_ASCII.set(|domain| idna::domain_to_ascii(domain).ok()); + DOMAIN_TO_UNICODE.set(|domain| idna::domain_to_unicode(domain).0); } /// True when `host` is a canonical dotted-quad IPv4 literal. Used by diff --git a/crates/perry-runtime/src/url/node_compat.rs b/crates/perry-runtime/src/url/node_compat.rs index 86e91319a7..471965d9a0 100644 --- a/crates/perry-runtime/src/url/node_compat.rs +++ b/crates/perry-runtime/src/url/node_compat.rs @@ -609,11 +609,9 @@ pub extern "C" fn js_url_domain_to_unicode(input_f64: f64) -> f64 { // CANONICALIZED host, not the raw input: `/`, `?`, `#` and `\` terminate // the host, so `domainToUnicode("a/b")` is `"a"`. Feeding the raw input to // `domain_to_unicode` skipped that truncation and echoed `"a/b"` back. - #[cfg(feature = "url-engine")] - Some(canon) => idna::domain_to_unicode(&canon).0, - // URL engine gated off: no IDNA, so return the canonical host unchanged. - #[cfg(not(feature = "url-engine"))] - Some(canon) => canon, + // Without the URL engine there is no IDNA: return the canonical host + // unchanged, as a build without `url-engine` does. + Some(canon) => super::idna_domain_to_unicode(&canon).unwrap_or(canon), }; create_string_f64(&out) } diff --git a/crates/perry-runtime/src/url/url_class.rs b/crates/perry-runtime/src/url/url_class.rs index fd2e0db9e2..a3056cd648 100644 --- a/crates/perry-runtime/src/url/url_class.rs +++ b/crates/perry-runtime/src/url/url_class.rs @@ -153,10 +153,9 @@ fn normalize_hostname_value(raw: &str) -> Option { { return None; } - #[cfg(feature = "url-engine")] - { - match idna::domain_to_ascii(raw) { - Ok(ascii) if !ascii.is_empty() => { + if let Some(ascii) = super::idna_domain_to_ascii(raw) { + return match ascii { + Some(ascii) if !ascii.is_empty() => { // #3056: apply the WHATWG numeric/IPv4-shorthand host parser as a // post-step. `idna::domain_to_ascii` only runs IDNA, so a numeric // host like `123` survives as `"123"` instead of canonicalizing to @@ -169,11 +168,10 @@ fn normalize_hostname_value(raw: &str) -> Option { super::whatwg_canonicalize_host(&ascii) } _ => None, - } + }; } - // URL engine gated off: no IDNA. Fall back to the hand-rolled host - // canonicalizer (which, also gated off, passes the host through unchanged). - #[cfg(not(feature = "url-engine"))] + // URL engine not installed: no IDNA. Fall back to the hand-rolled host + // canonicalizer (which, also without the engine, passes the host through). super::whatwg_canonicalize_host(raw) } diff --git a/crates/perry-runtime/src/value/dyn_index.rs b/crates/perry-runtime/src/value/dyn_index.rs index 41f1b53939..3a05f674af 100644 --- a/crates/perry-runtime/src/value/dyn_index.rs +++ b/crates/perry-runtime/src/value/dyn_index.rs @@ -637,7 +637,6 @@ pub extern "C" fn js_dyn_index_set_strict(obj: f64, index: f64, value: f64, stri } // A `Temporal.*` value is an opaque immutable cell — a dynamic property // write (`temporalValue[key] = v`) is a no-op, never an ObjectHeader write. - #[cfg(feature = "temporal")] if crate::temporal::is_temporal_value(obj) { return value; } diff --git a/crates/perry-runtime/src/value/to_string.rs b/crates/perry-runtime/src/value/to_string.rs index 7596e0c5be..68489afb98 100644 --- a/crates/perry-runtime/src/value/to_string.rs +++ b/crates/perry-runtime/src/value/to_string.rs @@ -270,9 +270,8 @@ pub(crate) fn js_jsvalue_to_string_impl( // `temporal.toString()` produce the value's canonical ISO-8601 / // IXDTF string, not "[object Object]". Detected here for the same // reason as Date — the cell is smaller than an ObjectHeader. - #[cfg(feature = "temporal")] if crate::temporal::is_temporal_cell_addr(ptr as usize) { - if let Some(s) = crate::temporal::temporal_iso_string(value) { + if let Some(s) = crate::temporal::hooked::iso_string(value) { return crate::string::js_string_from_bytes(s.as_ptr(), s.len() as u32); } } diff --git a/crates/perry-runtime/src/value/to_string_radix.rs b/crates/perry-runtime/src/value/to_string_radix.rs index 10f38cefaf..d699142b05 100644 --- a/crates/perry-runtime/src/value/to_string_radix.rs +++ b/crates/perry-runtime/src/value/to_string_radix.rs @@ -234,9 +234,8 @@ pub extern "C" fn js_jsvalue_to_string_radix( // the codegen routes any single-arg `.toString(x)` here. Dispatch back to // the Temporal method router so the options bag flows through, instead of // ToNumber-coercing it as a radix (which throws a spurious RangeError). - #[cfg(feature = "temporal")] if crate::temporal::is_temporal_value(value) { - let result = crate::temporal::dispatch::call_method(value, "toString", &[radix_value]); + let result = crate::temporal::hooked::call_method(value, "toString", &[radix_value]); let rv = JSValue::from_bits(result.to_bits()); if rv.is_string() { return rv.as_string_ptr() as *mut crate::string::StringHeader; diff --git a/crates/perry-stdlib/src/common/feature_hooks.rs b/crates/perry-stdlib/src/common/feature_hooks.rs index 4bff46090e..c65494eccf 100644 --- a/crates/perry-stdlib/src/common/feature_hooks.rs +++ b/crates/perry-stdlib/src/common/feature_hooks.rs @@ -29,42 +29,9 @@ //! spell out regardless of the order features are installed in, and every //! install is idempotent. -use std::marker::PhantomData; -use std::sync::atomic::{AtomicUsize, Ordering}; - -/// One function-pointer slot. `F` must be a plain `fn` pointer type. -pub(crate) struct Hook { - bits: AtomicUsize, - _f: PhantomData, -} - -impl Hook { - pub(crate) const fn empty() -> Self { - Self { - bits: AtomicUsize::new(0), - _f: PhantomData, - } - } - - #[inline] - pub(crate) fn set(&self, f: F) { - const { assert!(std::mem::size_of::() == std::mem::size_of::()) }; - // SAFETY: `F` is a fn pointer of pointer size (asserted above). - let bits: usize = unsafe { std::mem::transmute_copy(&f) }; - self.bits.store(bits, Ordering::Release); - } - - #[inline] - pub(crate) fn get(&self) -> Option { - let bits = self.bits.load(Ordering::Acquire); - if bits == 0 { - None - } else { - // SAFETY: only `set` stores non-zero bits, and it stores an `F`. - Some(unsafe { std::mem::transmute_copy(&bits) }) - } - } -} +/// The runtime's hook slot type, shared so both crates' hubs use one +/// implementation. +pub(crate) use perry_runtime::feature_hooks::Hook; /// A hub arm that may claim a method call on a native handle. pub(crate) type MethodArm = unsafe fn(i64, &str, &[f64]) -> Option; diff --git a/crates/perry/src/commands/compile/optimized_libs.rs b/crates/perry/src/commands/compile/optimized_libs.rs index 50650b8579..dceee1e111 100644 --- a/crates/perry/src/commands/compile/optimized_libs.rs +++ b/crates/perry/src/commands/compile/optimized_libs.rs @@ -66,7 +66,11 @@ pub struct OptimizedLibs { /// Which stdlib feature installs the generated /// `perry_stdlib_feature_installer` calls (see `stdlib_installs.rs`). /// `Compiled` unless the link uses the prebuilt full-feature stdlib. - pub stdlib_installs: crate::commands::stdlib_installs::StdlibInstalls, + pub stdlib_installs: crate::commands::stdlib_installs::FeatureInstalls, + /// Which runtime feature installs the generated installer calls; the + /// runtime counterpart of `stdlib_installs`. `Compiled` unless the link + /// uses a prebuilt full-feature runtime archive. + pub runtime_installs: crate::commands::stdlib_installs::FeatureInstalls, } impl OptimizedLibs { @@ -79,7 +83,8 @@ impl OptimizedLibs { extra_bc: Vec::new(), well_known_libs: Vec::new(), prefer_well_known_before_stdlib: false, - stdlib_installs: crate::commands::stdlib_installs::StdlibInstalls::Compiled, + stdlib_installs: crate::commands::stdlib_installs::FeatureInstalls::Compiled, + runtime_installs: crate::commands::stdlib_installs::FeatureInstalls::Compiled, } } } diff --git a/crates/perry/src/commands/compile/optimized_libs/driver.rs b/crates/perry/src/commands/compile/optimized_libs/driver.rs index 9731cd114f..13d5512541 100644 --- a/crates/perry/src/commands/compile/optimized_libs/driver.rs +++ b/crates/perry/src/commands/compile/optimized_libs/driver.rs @@ -649,12 +649,27 @@ pub(crate) fn build_optimized_libs( // A deferred dynamic-code site can reach a module by runtime // string, so such programs keep installing everything. stdlib_installs: if perry_hir::has_deferred_dynamic_code_sites() { - crate::commands::stdlib_installs::StdlibInstalls::Compiled + crate::commands::stdlib_installs::FeatureInstalls::Compiled } else { - crate::commands::stdlib_installs::StdlibInstalls::Selected( + crate::commands::stdlib_installs::FeatureInstalls::Selected( features.iter().map(|f| f.to_string()).collect(), ) }, + // Both runtime fallbacks here (the prebuilt `panic=abort` + // variant, or `None` = the prebuilt `libperry_runtime.a`) carry + // every runtime feature too: install the ones an auto-optimized + // rebuild would compile for this program. + runtime_installs: if perry_hir::has_deferred_dynamic_code_sites() { + crate::commands::stdlib_installs::FeatureInstalls::Compiled + } else { + crate::commands::stdlib_installs::FeatureInstalls::Selected( + auto_optimized_cross_features(ctx, &features, cli_features) + .iter() + .filter_map(|f| f.strip_prefix("perry-runtime/")) + .map(str::to_string) + .collect(), + ) + }, ..OptimizedLibs::empty() }; } @@ -816,7 +831,8 @@ pub(crate) fn build_optimized_libs( extra_bc: Vec::new(), prefer_well_known_before_stdlib: !well_known_libs.is_empty(), well_known_libs, - stdlib_installs: crate::commands::stdlib_installs::StdlibInstalls::Compiled, + stdlib_installs: crate::commands::stdlib_installs::FeatureInstalls::Compiled, + runtime_installs: crate::commands::stdlib_installs::FeatureInstalls::Compiled, }; } @@ -1309,6 +1325,7 @@ pub(crate) fn build_optimized_libs( extra_bc, prefer_well_known_before_stdlib: !well_known_libs.is_empty(), well_known_libs, - stdlib_installs: crate::commands::stdlib_installs::StdlibInstalls::Compiled, + stdlib_installs: crate::commands::stdlib_installs::FeatureInstalls::Compiled, + runtime_installs: crate::commands::stdlib_installs::FeatureInstalls::Compiled, } } diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index ae4f6b9c4e..688e3a24f1 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -6383,23 +6383,31 @@ pub fn run_with_parse_cache( .clone() .or_else(|| find_stdlib_library(target.as_deref())); - // perry-stdlib's optional features install through the installer this - // object registers (see `stdlib_installs.rs`): everything the archive was - // compiled with for an auto-optimized archive, only this program's - // features for the prebuilt full-feature one. Generated before the stub - // scan below so the scan sees its install references resolved by the - // stdlib archive. - if ctx.needs_stdlib && stdlib_lib_resolved.is_some() { - let install_symbols = - crate::commands::stdlib_installs::installer_callees(&optimized_libs.stdlib_installs); + // Optional runtime features — and perry-stdlib's, when the stdlib is + // linked — install through the installers this object registers (see + // `stdlib_installs.rs`): everything the archive was compiled with for an + // auto-optimized archive, only this program's features for a prebuilt + // full-feature one. Generated before the stub scan below so the scan sees + // its install references resolved by the runtime/stdlib archives. + { + let runtime_symbols = crate::commands::stdlib_installs::runtime_installer_callees( + &optimized_libs.runtime_installs, + ); + let stdlib_symbols = (ctx.needs_stdlib && stdlib_lib_resolved.is_some()).then(|| { + crate::commands::stdlib_installs::installer_callees(&optimized_libs.stdlib_installs) + }); if matches!(format, OutputFormat::Text) && verbose > 0 { - eprintln!(" stdlib installs: {}", install_symbols.join(", ")); + eprintln!(" runtime installs: {}", runtime_symbols.join(", ")); + if let Some(stdlib) = &stdlib_symbols { + eprintln!(" stdlib installs: {}", stdlib.join(", ")); + } } - let installer_bytes = perry_codegen::stubs::generate_stdlib_installer_object( - &install_symbols, + let installer_bytes = perry_codegen::stubs::generate_feature_installer_object( + &runtime_symbols, + stdlib_symbols.as_deref(), target.as_deref(), )?; - let installer_path = object_output_dir.join("_perry_stdlib_installs.o"); + let installer_path = object_output_dir.join("_perry_feature_installs.o"); fs::write(&installer_path, &installer_bytes)?; obj_cleanup_paths.push(installer_path.clone()); obj_paths.push(installer_path); diff --git a/crates/perry/src/commands/stdlib_installs.rs b/crates/perry/src/commands/stdlib_installs.rs index 0c37ab21bf..ec4a3eccf8 100644 --- a/crates/perry/src/commands/stdlib_installs.rs +++ b/crates/perry/src/commands/stdlib_installs.rs @@ -4,7 +4,7 @@ //! slots that each feature fills from its own `js_stdlib_install_*` entry point //! (perry-stdlib `common::feature_hooks`). Whenever the stdlib is linked, the //! link step generates an object (perry-codegen -//! `stubs::generate_stdlib_installer_object`) whose static constructor +//! `stubs::generate_feature_installer_object`) whose static constructor //! registers an installer calling the entry points chosen here; //! `js_stdlib_init_dispatch` runs it. //! @@ -23,7 +23,7 @@ pub const INSTALL_COMPILED_SYMBOL: &str = "js_stdlib_install_compiled"; /// What the generated installer should do. #[derive(Debug, Clone, PartialEq, Eq, Default)] -pub enum StdlibInstalls { +pub enum FeatureInstalls { /// Install every compiled feature (auto-optimized or opted-out links, and /// programs whose dynamic code the compiler cannot see through). #[default] @@ -135,10 +135,55 @@ pub fn install_symbols(features: &BTreeSet) -> Vec { } /// The install entry points the generated installer calls for `installs`. -pub fn installer_callees(installs: &StdlibInstalls) -> Vec { +pub fn installer_callees(installs: &FeatureInstalls) -> Vec { match installs { - StdlibInstalls::Compiled => vec![INSTALL_COMPILED_SYMBOL.to_string()], - StdlibInstalls::Selected(features) => install_symbols(features), + FeatureInstalls::Compiled => vec![INSTALL_COMPILED_SYMBOL.to_string()], + FeatureInstalls::Selected(features) => install_symbols(features), + } +} + +/// Installs every runtime feature the linked `libperry_runtime.a` was +/// compiled with. +pub const RUNTIME_INSTALL_COMPILED_SYMBOL: &str = "js_runtime_install_compiled"; + +/// perry-runtime's installable features, as [`INSTALLS`] is for perry-stdlib: +/// each entry point and the perry-runtime Cargo features whose `[features]` +/// closure contains it (checked against `crates/perry-runtime/Cargo.toml` by +/// `runtime_triggers_match_cargo_feature_closure`). The runtime's always-live +/// hubs reach these features only through slots the installs fill +/// (perry-runtime `feature_hooks`). +const RUNTIME_INSTALLS: &[(&str, &[&str])] = &[ + ("js_runtime_install_dyn_eval", &["default", "dyn-eval"]), + ( + "js_runtime_install_bun_cli_utils", + &["bun-cli-utils", "default"], + ), + ( + "js_runtime_install_intl_namespace", + &["default", "intl-namespace"], + ), + ("js_runtime_install_temporal", &["default", "temporal"]), + ( + "js_runtime_install_intl_datetime", + &["default", "intl-datetime"], + ), + ( + "js_runtime_install_regex_engine", + &["default", "regex-engine"], + ), + ("js_runtime_install_url_engine", &["default", "url-engine"]), +]; + +/// The runtime install entry points the generated installer calls for +/// `installs` (perry-runtime features, without the `perry-runtime/` prefix). +pub fn runtime_installer_callees(installs: &FeatureInstalls) -> Vec { + match installs { + FeatureInstalls::Compiled => vec![RUNTIME_INSTALL_COMPILED_SYMBOL.to_string()], + FeatureInstalls::Selected(features) => RUNTIME_INSTALLS + .iter() + .filter(|(_, triggers)| triggers.iter().any(|t| features.contains(*t))) + .map(|(symbol, _)| (*symbol).to_string()) + .collect(), } } @@ -151,8 +196,12 @@ mod tests { /// the workspace copy. Only same-crate entries (no `dep:` / `x/y`) matter /// for the closure. fn stdlib_feature_table() -> Option>> { + feature_table("perry-stdlib") + } + + fn feature_table(krate: &str) -> Option>> { let path = - std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../perry-stdlib/Cargo.toml"); + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("../{krate}/Cargo.toml")); let text = std::fs::read_to_string(path).ok()?; let doc: toml::Table = text.parse().ok()?; let features = doc.get("features")?.as_table()?; @@ -269,8 +318,60 @@ mod tests { ] ); assert_eq!( - installer_callees(&StdlibInstalls::Compiled), + installer_callees(&FeatureInstalls::Compiled), vec![INSTALL_COMPILED_SYMBOL.to_string()] ); } + + #[test] + fn runtime_triggers_match_cargo_feature_closure() { + let Some(table) = feature_table("perry-runtime") else { + return; + }; + for (symbol, triggers) in RUNTIME_INSTALLS { + let installed = symbol + .strip_prefix("js_runtime_install_") + .unwrap() + .replace('_', "-"); + let expected: BTreeSet = table + .keys() + .filter(|f| closure(&table, f).contains(&installed)) + .cloned() + .collect(); + let listed: BTreeSet = triggers.iter().map(|t| t.to_string()).collect(); + assert_eq!( + listed, expected, + "{symbol}: trigger list must equal every perry-runtime feature whose closure \ + contains `{installed}` (update RUNTIME_INSTALLS after changing Cargo.toml [features])" + ); + } + } + + #[test] + fn every_runtime_install_symbol_is_defined_by_perry_runtime() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../perry-runtime/src/feature_hooks.rs"); + let Ok(source) = std::fs::read_to_string(path) else { + return; + }; + for (symbol, _) in RUNTIME_INSTALLS { + assert!( + source.contains(&format!("pub extern \"C\" fn {symbol}()")), + "{symbol} is listed here but perry-runtime does not define it" + ); + } + for line in source.lines() { + if let Some(rest) = line + .trim() + .strip_prefix("pub extern \"C\" fn js_runtime_install_") + { + let symbol = format!("js_runtime_install_{}", rest.split('(').next().unwrap()); + assert!( + symbol == RUNTIME_INSTALL_COMPILED_SYMBOL + || RUNTIME_INSTALLS.iter().any(|(s, _)| *s == symbol), + "perry-runtime defines {symbol} but RUNTIME_INSTALLS does not list it" + ); + } + } + } } From d9cf777cdafbd9672756d3888df4261b1d37b5f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 28 Sep 2026 01:44:35 +0000 Subject: [PATCH 02/13] changelog: key the runtime link-time features fragment to PR 11605 --- ...-link-time-features.md => 11605-runtime-link-time-features.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{runtime-link-time-features.md => 11605-runtime-link-time-features.md} (100%) diff --git a/changelog.d/runtime-link-time-features.md b/changelog.d/11605-runtime-link-time-features.md similarity index 100% rename from changelog.d/runtime-link-time-features.md rename to changelog.d/11605-runtime-link-time-features.md From 9be8cca799b5147dffa941bf59b744a67e33b8ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 28 Sep 2026 09:32:50 +0000 Subject: [PATCH 03/13] lint: the feature Hook verdict names its new home in perry-runtime --- scripts/thread_exit_address_globals.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/thread_exit_address_globals.json b/scripts/thread_exit_address_globals.json index 9170d11a7b..5b300e236b 100644 --- a/scripts/thread_exit_address_globals.json +++ b/scripts/thread_exit_address_globals.json @@ -19,7 +19,7 @@ { "type": "Hook", "verdict": "no_heap_address", - "why": "perry-stdlib's link-time feature slot (common/feature_hooks.rs): an AtomicUsize holding only the bits of a plain `fn` pointer (set() asserts size_of::() == usize and stores a code address; 0 = uninstalled). It never holds a heap or arena address, so thread exit cannot leave it dangling." + "why": "the link-time feature slot (perry-runtime/src/feature_hooks.rs, also used by perry-stdlib): an AtomicUsize holding only the bits of a plain `fn` pointer (set() asserts size_of::() == usize and stores a code address; 0 = uninstalled). It never holds a heap or arena address, so thread exit cannot leave it dangling." } ], "entries": [ From 4005d171234982fff340bc53976de5d18c9c8448 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 28 Sep 2026 11:04:18 +0000 Subject: [PATCH 04/13] gates: follow the interpreter root scanner through its feature slot; intl.rs under the size cap; wasm ABI table - gc_runtime_root_holders: a registered scanner that forwards through a feature Hook slot (SLOT.get() then a call) now seeds the SLOT.set(path) targets in the same file, so the interpreter scanner behind dyn_eval_hooks::scan_dyn_eval_roots_mut keeps covering the dyn_eval key caches; a planted self-test case covers it (and fails with the rule disabled). Coverage counts equal main exactly (693 reached, 446 classified). - PASS1_MARKED: re-audited the gc/mod.rs change (forwarder registration and the startup feature installer call; neither is in the mark-complete to sweep-entry window) and re-pinned. - intl.rs: the Intl namespace forwarder, its slot and the feature install move to intl/hooked.rs (2004 -> 1982 lines). - runtime_abi.tsv: the js_runtime_install_* / register symbols. --- .../perry-codegen/src/wasm32/runtime_abi.tsv | 9 +++ crates/perry-runtime/src/intl.rs | 30 +------ crates/perry-runtime/src/intl/hooked.rs | 25 +++++- scripts/gc_runtime_root_holders.json | 4 +- scripts/gc_runtime_root_holders.py | 78 ++++++++++++++++++- 5 files changed, 113 insertions(+), 33 deletions(-) diff --git a/crates/perry-codegen/src/wasm32/runtime_abi.tsv b/crates/perry-codegen/src/wasm32/runtime_abi.tsv index 9ae4f23380..e8a40b22a4 100644 --- a/crates/perry-codegen/src/wasm32/runtime_abi.tsv +++ b/crates/perry-codegen/src/wasm32/runtime_abi.tsv @@ -3358,6 +3358,15 @@ js_run_ext_pump void js_run_module_init_catching void i64 js_run_stdlib_pump void js_runtime_init void +js_runtime_install_bun_cli_utils void +js_runtime_install_compiled void +js_runtime_install_dyn_eval void +js_runtime_install_intl_datetime void +js_runtime_install_intl_namespace void +js_runtime_install_regex_engine void +js_runtime_install_temporal void +js_runtime_install_url_engine void +js_runtime_register_feature_installer void ptr js_runtime_validate_crypto_key_arg void f64,ptr,i32u js_runtime_validate_integer_arg void f64,ptr,i32u,f64,f64 js_runtime_validate_string_arg void f64,ptr,i32u diff --git a/crates/perry-runtime/src/intl.rs b/crates/perry-runtime/src/intl.rs index 649c69f017..f20d6e538f 100644 --- a/crates/perry-runtime/src/intl.rs +++ b/crates/perry-runtime/src/intl.rs @@ -1779,34 +1779,12 @@ fn set_proto_to_string_tag(proto: *mut ObjectHeader, tag: &str) { ); } -/// Install the `Intl.*` namespace members. Behind `intl-namespace` (default-on; -/// the compiler enables it whenever the program mentions `Intl` or any -/// locale-formatting API): when the feature is off this is a no-op, the -/// `Intl` global is still a real (empty) namespace object, and `-dead_strip` -/// reclaims the constructor/option/format machinery that nothing else -/// reaches. `toLocale*` / `localeCompare` are unaffected — their entry points -/// and helpers live outside this gate. -/// -/// `globalThis` population is live in every program, so it reaches the members -/// only through a slot the `intl-namespace` install fills (see -/// `crate::feature_hooks`); an empty slot leaves the namespace empty, exactly -/// as a build without the feature does. -pub fn install_intl_namespace(ns_obj: *mut ObjectHeader) { - if let Some(install) = INTL_NAMESPACE_MEMBERS.get() { - install(ns_obj); - } -} - -static INTL_NAMESPACE_MEMBERS: crate::feature_hooks::Hook = - crate::feature_hooks::Hook::empty(); - -/// The `intl-namespace` install. +pub use hooked::install_intl_namespace; #[cfg(feature = "intl-namespace")] -pub(crate) fn install_intl_namespace_feature() { - INTL_NAMESPACE_MEMBERS.set(install_intl_namespace_members); - hooked::install(); -} +pub(crate) use hooked::install_intl_namespace_feature; +/// The `Intl.*` members `install_intl_namespace` adds once the +/// `intl-namespace` install has filled its slot. #[cfg(feature = "intl-namespace")] fn install_intl_namespace_members(ns_obj: *mut ObjectHeader) { if ns_obj.is_null() { diff --git a/crates/perry-runtime/src/intl/hooked.rs b/crates/perry-runtime/src/intl/hooked.rs index a29ecd08f1..cbc94c77c9 100644 --- a/crates/perry-runtime/src/intl/hooked.rs +++ b/crates/perry-runtime/src/intl/hooked.rs @@ -11,6 +11,7 @@ //! formatting falls back to the plain ECMA-262 rendering. use crate::feature_hooks::Hook; +use crate::object::ObjectHeader; use crate::string::StringHeader; static INSTANCEOF: Hook Option> = Hook::empty(); @@ -18,6 +19,25 @@ static IS_CONSTRUCTOR_VALUE: Hook bool> = Hook::empty(); static SUBCLASS_SUPER: Hook bool> = Hook::empty(); static NUMBER_TO_LOCALE_STRING: Hook *mut StringHeader> = Hook::empty(); static BIGINT_TO_LOCALE_STRING: Hook *mut StringHeader> = Hook::empty(); +static INTL_NAMESPACE_MEMBERS: Hook = Hook::empty(); + +/// Install the `Intl.*` namespace members. Behind `intl-namespace` (default-on; +/// the compiler enables it whenever the program mentions `Intl` or any +/// locale-formatting API): when the feature is off this is a no-op, the +/// `Intl` global is still a real (empty) namespace object, and `-dead_strip` +/// reclaims the constructor/option/format machinery that nothing else +/// reaches. `toLocale*` / `localeCompare` are unaffected — their entry points +/// and helpers live outside this gate. +/// +/// `globalThis` population is live in every program, so it reaches the members +/// only through a slot the `intl-namespace` install fills (see +/// `crate::feature_hooks`); an empty slot leaves the namespace empty, exactly +/// as a build without the feature does. +pub fn install_intl_namespace(ns_obj: *mut ObjectHeader) { + if let Some(install) = INTL_NAMESPACE_MEMBERS.get() { + install(ns_obj); + } +} pub(crate) fn intl_instanceof(value: f64, type_ref: f64) -> Option { INSTANCEOF.get().and_then(|f| f(value, type_ref)) @@ -62,9 +82,10 @@ pub(crate) fn bigint_to_locale_string( .map(|f| f(value, locales, options)) } -/// The hub half of the `intl-namespace` install. +/// The `intl-namespace` install. #[cfg(feature = "intl-namespace")] -pub(crate) fn install() { +pub(crate) fn install_intl_namespace_feature() { + INTL_NAMESPACE_MEMBERS.set(super::install_intl_namespace_members); INSTANCEOF.set(super::intl_instanceof); IS_CONSTRUCTOR_VALUE.set(super::is_intl_constructor_value); SUBCLASS_SUPER.set(super::intl_subclass_super); diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 04faee59c5..e7e19b371b 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -386,7 +386,7 @@ "file": "crates/perry-runtime/src/gc/census.rs", "name": "PASS1_MARKED", "verdict": "non_moving_snapshot", - "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete \u2192 sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs \u2014 it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase \u2014 after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged \u2014 `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` \u2014 and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` \u2192 `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only \u2014 no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound \u2014 the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses \u2014 no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects \u2014 and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module \u2014 all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete \u2192 sweep-entry window of a synchronous full \u2014 where PASS1_MARKED is populated and consumed within one `run_to_completion` \u2014 is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize \u2014 INSIDE the window \u2014 the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-13 for #10182's full-collection throughput follow-up, which touched `gc/cycle.rs` in one hunk, INSIDE the window: the `RememberedSetRebuild` subphase of a synchronous full now first asks `verify::full_remembered_rebuild_provably_empty` and, when it holds, installs `OldToYoungRememberedRebuildState::provably_empty()` (an empty sticky set, no walk) instead of the require-marked rebuild. The predicate reads `arena_block_snapshots()` (one `Vec` through the global allocator), the census's per-block reached/pre-marked facts and the malloc registry's length; the constructor bumps a `Cell` counter and prints one line under `PERRY_GC_DIAG`. None of it allocates a GC object, relocates anything, collects, or runs a JS callback, and both census boundaries stay where they were. Re-audited 2026-09-14 for #10182's pacing-full work, which touched `gc/cycle.rs`, `gc/mod.rs` and `gc/policy.rs`. `gc/cycle.rs`: `GcCycleState::new_full` no longer calls `materialize_all_promoted_page_runs`; that call ran in the constructor, before the census and far before `census_pass1_if_armed`, and removing it adds nothing to the window. `gc/mod.rs`: one `mod promoted_cohort;` declaration. `gc/policy.rs`: (a) `credit_promoted_bytes_to_old_baseline` also credits a `Cell` cohort counter (it runs after a copying minor completes); (b) `finish_full_old_reclaim_baseline` also records the verified old live bytes and resets that counter (Publish, after `step_sweep` consumed the snapshot); (c) `gc_safepoint_moving_minor` arms and disarms the promotion-census record around its nursery minor and calls `run_promoted_cohort_full_if_due`, which starts a synchronous full through the same `gc_collect_full_mark_sweep_with_trigger` entry and reads byte counters before and after it. All of it runs before a cycle starts or after it completes; none of it runs between mark completion and sweep entry, allocates a GC object, relocates anything, or calls into JS. The census the promoted-cohort full may adopt from the promotion walk is built in `BuildValidPointerSet`, before either boundary. Both boundaries are unchanged. Re-audited 2026-09-14 for the #10182 dead-stack scrub in `gc/cycle.rs`: `step_build_valid_pointer_set` now calls `scrub_dead_stack_below`, which zeroes a local array in its own frame (dead stack below the caller), right after the census finishes \u2014 in `BuildValidPointerSet`, before the root scan and far before `census_pass1_if_armed`. It writes no heap memory, allocates nothing, relocates nothing and calls no JS; both boundaries are unchanged. Re-audited 2026-09-14 for #10241 (cohort survival), which touched `gc/cycle.rs` and `gc/policy.rs`. `gc/cycle.rs`: one call, `promoted_cohort::survival::check_minor_view_at_full_sweep_start()`, in `step_sweep` immediately AFTER `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS, i.e. outside the window. It is a no-op unless a promoted-cohort full armed its survival probe; when armed it walks the old page index over the preceding minor's dirty pages (`old_arena_walk_objects_on_pages`, Rust-allocator Vecs), reads GC headers' mark flags and the slots of unmarked ones, and records one enum. It writes no heap memory, allocates no GC object, relocates nothing and calls no JS. `gc/policy.rs`: `run_promoted_cohort_full_if_due` arms the probe before `gc_collect_full_mark_sweep_with_trigger` and takes it after the full returns (feeding `note_full_measured_promotion_survival` and one diagnostic line); both run before a cycle starts or after it completes. Both boundaries are unchanged. Re-audited 2026-09-14 for #10241's in-place-only cohort: `gc/policy.rs` drops the `promoted_cohort::note_promoted` call from `credit_promoted_bytes_to_old_baseline` (the copying minor now calls `promoted_cohort::note_minor_promotion` itself, after the credit). Both run at the end of a copying minor, outside any full cycle; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-14 for the parse-boundary side-allocation band (medium-parse pacing), which touched `gc/policy.rs`. Three hunks: (a) a `Cell` thread-local (`GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES`, a byte COUNT, no pointer) plus three pure predicates over it and `external_side_live_bytes()`; (b) that predicate added as a third disjunct of `tiny_parse_generational_collection_due`, which is read only from the three JSON.parse mutator-side boundaries (`gc_bump_malloc_trigger_inner`, `gc_collect_pending_suppressed_parse_slow`, `gc_schedule_parse_boundary_collection_if_pressure`), none of them reachable from `step_mark_propagation` or `step_sweep`; and (c) one extra `Cell` store in `note_collection_finished_arena_occupancy` plus two extra reads in the `PERRY_GC_DIAG` tiny-parse line. `note_collection_finished_arena_occupancy` runs from `publish_reclaim_outcome` in the Publish subphase, i.e. AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local, exactly as #9831's store on the same line does. Nothing added allocates a GC object, relocates anything, or runs a JS callback, and neither census boundary moved. Re-audited 2026-09-14 for the drained-bytes counterweight to that band, which touched `gc/policy.rs` again. Four hunks: a second `Cell` thread-local (`GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL`, a byte COUNT); one increment of it inside `gc_note_external_side_free`; a pure read (`external_side_old_reclaim_pressure_bytes`) substituted for `external_side_live_bytes()` at the four old-reclaim pressure sites; and one `Cell` store at the top of `finish_full_old_reclaim_baseline`. None of it can run between the census boundaries. `gc_note_external_side_free` is also reached by mutator-side tape materialization, regex scratch teardown, native-addon adjustments and buffer replacement. Its added operation is only a saturating increment of a scalar Cell, with no GC allocation, relocation, collection or JS callback, so this wider caller set does not invalidate the census window. `finish_full_old_reclaim_baseline` runs from `publish_reclaim_outcome` in the Publish subphase, the same place #9831's store already sits. The pressure reads happen at trigger decisions, before a cycle starts. No allocation, relocation, collection or JS callback is added to the mark-complete -> sweep-entry window, and neither boundary moved. Re-audited 2026-09-15 for turnloop P0, which touched `gc/mod.rs` with one added call: `crate::event_pump::shutdown_wait_driver()` inside `js_gc_release_current_thread_collection_side_allocations`, the process-exit funnel. That function runs once no more JavaScript can run on the thread, never from inside a collection cycle; the added call drops the thread's turnloop wait loop (closing its kqueue/epoll descriptor) and may print a diagnostic line. It allocates no GC object, relocates nothing, starts no collection and runs no JS callback. The census boundaries and the mark-complete -> sweep-entry window are untouched.. Re-audited 2026-09-16 for the copying minor's per-parent weak-holder fact: `gc/mod.rs` gains exactly one line, `mod copying_parent_facts;`, a module declaration. The module it declares holds `weak_holder_fact` (a read of the parent's `obj_type`/`class_id` via `weakref::is_weak_holder_header`) and the copying minor's `visit_slot_with_parent`, moved verbatim out of `gc/copying.rs` for the 2000-line lint. Both run only inside a COPYING MINOR, which skips both census boundaries (`census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` are synchronous-full only). Nothing was added to any full-cycle phase, and the declaration itself executes no code. Neither boundary moved and the synchronous mark-complete to sweep-entry window gains no allocation, relocation, collection or JS callback. Re-audited 2026-09-18 for the #10532 follow-up argument-list rooting fix, which touched `gc/mod.rs`. The only change there is `mod collection_points;` plus a `pub(crate) use collection_points::collection_point;` re-export (and, under `#[cfg(test)]`, `arm_collection_point`). `collection_point` is an inline no-op outside `cfg(test)`; under test it only runs a copying minor when called from ordinary MUTATOR code (`proxy.rs`'s `Reflect.apply` rebind path and `registry.rs`'s rest-array bundler), never from inside `step_mark_propagation` or `step_sweep`. Neither `census_pass1_if_armed` nor `census_take_if_armed_at_full_sweep_start` is reachable from it, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-18 (same PR, round 2) for the added `arm_collection_point_after` re-export in `gc/mod.rs`: another pure re-export line, same as the `collection_point`/`arm_collection_point` one already covered above. `arm_collection_point_after` only changes test-only arming state in `collection_points.rs` (which named site fires and on which hit); it still runs no mark/sweep control flow. Re-audited 2026-09-19 for #10735 (require.main threading): gc/mod.rs gains exactly one line, `reg_scanner!(crate::module_require::scan_cjs_main_module_root_mut);`, registering the new CJS_MAIN_MODULE thread-local's mutable-root scanner beside the existing `scan_module_path_roots_mut` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it runs during root scanning, before mark propagation completes, and does not execute between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start`. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-20 for #10834 (inherited-property read cache). `gc/mod.rs` gains exactly one line: `reg_scanner!(crate::object::inherited_read_cache::scan_inherited_read_cache_roots_mut);` in `gc_init()`. A scanner registration adds a root SOURCE for the mutable-root walks. The walk runs inside `RootScanCycleState::step_current_subphase`, i.e. entirely within the RootScan phase: `step_root_scan` only sets `self.phase = GcCyclePhase::MarkPropagation` once that loop reports done (`gc/cycle.rs:958-961`), and `census_pass1_if_armed()` fires at the END of `step_mark_propagation` (`gc/cycle.rs:982`). The scanner therefore runs strictly BEFORE the window opens and can never execute between the boundaries. Its body is a bounded walk of a fixed 512-entry thread-local array calling `visit_tagged_usize_slot` / `visit_usize_slot`; it allocates nothing, relocates nothing and runs no JS callback. Same shape as #9769, #9976/#9977, #10054, #10055 and #10735, all previously cleared. The PR also adds an `INHERITED_READ_CACHE` entry to `DEAD_KEY_PRUNES` in `gc/dead_owner.rs` (not a pinned source). That registry is consumed by `IncrementalSweepState::with_dead_collection_finalize` at `gc/cycle.rs:1548`, which is AFTER `census_take_if_armed_at_full_sweep_start` at `gc/cycle.rs:1505` has already `take()`n the snapshot out of the thread-local -- the same argument that cleared #9845's `collect_dead_registered_regexps_post_trace`. The prune reads addresses and zeroes entries; no GC allocation, relocation or callback. Both additions sit outside the window, on opposite sides of it. Neither boundary moved and the synchronous mark-complete to sweep-entry interval is unchanged. Re-audited 2026-09-22 for #10399 (per-thread module init), which touched `gc/mod.rs`. Two hunks, both init-time: a new free function `raise_default_thread_stack_floor()` and one call to it at the top of `js_gc_init`, before `enter_current_thread_image`'s successor statements. The function reads `RUST_MIN_STACK` from the environment and, only when it is unset, sets it to 32 MiB so a thread spawned against a multi-megabyte static TLS block still has usable stack (glibc carves static TLS out of the thread's stack mapping). It touches no heap object, allocates no GC object, relocates nothing and runs no JS callback. `js_gc_init` is the first runtime call of a compiled `main`, so it runs once before any cycle exists, and it is not reachable from `step_mark_propagation` or `step_sweep`. Same shape as the 2026-09-11 startup-memory-profile re-audit, which cleared the pre-main allocator-policy constructor in the same function. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-23 (size/runtime-decouple, #11135) after the binary-size branch touched `gc/census.rs`, `gc/mod.rs` and `gc/policy.rs`. census.rs: `census_pass1_if_armed` / `census_take_if_armed_at_full_sweep_start` keep their bodies verbatim, moved into `_impl` functions compiled only with the new `gc-instruments` feature (without it both are empty and `census_path()` is `None`, so nothing is ever armed); the take still empties PASS1_MARKED before `take_census`. gc/mod.rs: `gc_init` gains a startup env check that aborts when an instrument knob is set without the feature, before any cycle exists. gc/policy.rs: env-knob OnceLock caches now initialize through `crate::once_init::get_or_init` (same closures, same values). No mark/sweep control flow between the two census boundaries changed; the window is unchanged. Re-audited for Fetch handle reclamation: cycle.rs only redirects the incomplete-cycle Drop cancellation hook to also cancel the Fetch trace. The full-trace finish hook removes native records and cached slots without allocating GC objects or invoking JS; it cannot relocate the census addresses before sweep entry. Re-audited 2026-09-22 for #10928 (one proportional old-reclaim rule), which touched `gc/policy.rs`. Six hunks. (a) Two new thread-locals, `GC_OLD_RECLAIM_PRE_IN_USE_BYTES` (`Cell`) and `GC_OLD_RECLAIM_BACKOFF_SHIFT` (`Cell`): both are byte/shift COUNTS, neither holds a pointer. (b) `gc_old_reclaim_growth_band_bytes` gains a `Cell` read and a left shift -- pure arithmetic over byte counts. (c) `old_reclaim_pressure_due` loses the #7937 absolute first-crossing arm, splits its pure form out as `old_reclaim_pressure_due_inner`, and calls `note_old_reclaim_cycle_started()` when the answer is true. That predicate is read at TRIGGER decisions only -- the allocation-point `gc_check_trigger` and `gc_budgeted_due_trigger` at safepoints -- i.e. before a cycle starts, never between the boundaries; an allocation inside the window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before this code, the same argument the 2026-09-14 due-check fast-path re-audit made for the same function. Even if it did run there it would be sound: `note_old_reclaim_cycle_started` stores one scalar `Cell` from `pacing_arena_in_use_bytes()` (a read of `arena_live_allocated_bytes`), which allocates no GC object, relocates nothing and runs no JS callback -- the window's contract. (d) `update_old_reclaim_backoff` is called only from `finish_full_old_reclaim_baseline`, which runs from `publish_reclaim_outcome` in the Publish subphase, AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local -- exactly where #9831's store and the medium-parse pacing store already sit. (e) `gc_old_reclaim_debt_bytes` drops the absolute arm it mirrored; it remains pure arithmetic read at debt/trigger decisions. (f) `#[cfg(test)]` seams, absent from production builds. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback between them; the change alters only WHEN a collection is scheduled, never what runs inside one. Neither boundary moved and the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-24 for #10960 (growth-aware old-reclaim backoff), which touched `gc/policy.rs` again. One new thread-local, `GC_OLD_RECLAIM_LAST_POST_IN_USE_BYTES` (`Cell`), a byte COUNT that holds no pointer. It is written only by `update_old_reclaim_backoff`, which runs from `finish_full_old_reclaim_baseline` in the Publish subphase, after `step_sweep` has already taken the snapshot out of the thread-local; the change there is pure integer arithmetic deciding whether to widen the band. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback, and neither window boundary moved. Re-audited 2026-09-24 after the class-capture environment added one `reg_scanner!` registration (`scan_class_env_roots_mut`, visiting each guarded class environment's owner class object) to `gc/mod.rs`: a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-25 after the inherited-access lane touched `gc/mod.rs`: the change is one `reg_scanner!` registration for `object::chain_store::scan_chain_store_roots_mut`, a root scanner that visits store-site chain verdicts (one interned key pointer each) during root scanning. It runs at the start of a cycle, never between mark completion and sweep entry, relocates nothing and runs no JS callback. The synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-25 for #10698 (allocation-point trigger watermark), which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains `pub(crate) mod trigger_watermark;` and a `pub(crate) use` re-export -- no control flow. In `gc/policy.rs`: (a) `gc_check_trigger` becomes an out-of-line wrapper over an inlined fast path that reads the watermark cell, the malloc registry's length through an unguarded shared borrow and the inline allocator's offset, then either returns or runs the previous body unchanged (`gc_check_trigger_evaluate`). An allocation inside the window reaches it with `GC_FLAG_IN_ALLOC` set: the fast path returns without acting, which is the outcome of the `GC_FLAG_IN_ALLOC` early return it would otherwise reach, and the slow path still takes that early return. (b) The due-trigger evaluation also returns a watermark -- integer arithmetic over values it already read -- published only on the no-action return past every entry guard. (c) The ladder's thresholds and flags are retyped `Cell` -> `TriggerInput`, whose writes add one store retiring the watermark; heap-generation advances and `Arena::set_current` retire it too. (d) `#[cfg(test)]` seams. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback; `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-25 for #10498 (class-accessor cache), which adds one `reg_scanner!` registration (`scan_class_accessor_cache_roots_mut`, marking and rewriting the cache's key strings) to `gc/mod.rs`: again a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-26 for #10572: `gc/mod.rs` gains one `#[cfg(not(feature = \"hot-diag\"))]` call to `hot_diag::refuse_knobs_without_hot_diag()` in `gc_init`, a startup-only knob check that runs before any cycle and alters no mark/sweep control flow; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-27 for #11416: `gc/mod.rs` loses the `reg_scanner!` registration for `class_accessor_cache::scan_class_accessor_cache_roots_mut` (the cache is deleted); removing a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-27 for the method-calls lane, which adds one `reg_scanner!` registration (`scan_method_site_roots_mut`, marking and rewriting the method closures inherited method-site entries hold) to `gc/mod.rs`: a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window.", + "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete \u2192 sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs \u2014 it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase \u2014 after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged \u2014 `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` \u2014 and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` \u2192 `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only \u2014 no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound \u2014 the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses \u2014 no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects \u2014 and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module \u2014 all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete \u2192 sweep-entry window of a synchronous full \u2014 where PASS1_MARKED is populated and consumed within one `run_to_completion` \u2014 is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize \u2014 INSIDE the window \u2014 the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-13 for #10182's full-collection throughput follow-up, which touched `gc/cycle.rs` in one hunk, INSIDE the window: the `RememberedSetRebuild` subphase of a synchronous full now first asks `verify::full_remembered_rebuild_provably_empty` and, when it holds, installs `OldToYoungRememberedRebuildState::provably_empty()` (an empty sticky set, no walk) instead of the require-marked rebuild. The predicate reads `arena_block_snapshots()` (one `Vec` through the global allocator), the census's per-block reached/pre-marked facts and the malloc registry's length; the constructor bumps a `Cell` counter and prints one line under `PERRY_GC_DIAG`. None of it allocates a GC object, relocates anything, collects, or runs a JS callback, and both census boundaries stay where they were. Re-audited 2026-09-14 for #10182's pacing-full work, which touched `gc/cycle.rs`, `gc/mod.rs` and `gc/policy.rs`. `gc/cycle.rs`: `GcCycleState::new_full` no longer calls `materialize_all_promoted_page_runs`; that call ran in the constructor, before the census and far before `census_pass1_if_armed`, and removing it adds nothing to the window. `gc/mod.rs`: one `mod promoted_cohort;` declaration. `gc/policy.rs`: (a) `credit_promoted_bytes_to_old_baseline` also credits a `Cell` cohort counter (it runs after a copying minor completes); (b) `finish_full_old_reclaim_baseline` also records the verified old live bytes and resets that counter (Publish, after `step_sweep` consumed the snapshot); (c) `gc_safepoint_moving_minor` arms and disarms the promotion-census record around its nursery minor and calls `run_promoted_cohort_full_if_due`, which starts a synchronous full through the same `gc_collect_full_mark_sweep_with_trigger` entry and reads byte counters before and after it. All of it runs before a cycle starts or after it completes; none of it runs between mark completion and sweep entry, allocates a GC object, relocates anything, or calls into JS. The census the promoted-cohort full may adopt from the promotion walk is built in `BuildValidPointerSet`, before either boundary. Both boundaries are unchanged. Re-audited 2026-09-14 for the #10182 dead-stack scrub in `gc/cycle.rs`: `step_build_valid_pointer_set` now calls `scrub_dead_stack_below`, which zeroes a local array in its own frame (dead stack below the caller), right after the census finishes \u2014 in `BuildValidPointerSet`, before the root scan and far before `census_pass1_if_armed`. It writes no heap memory, allocates nothing, relocates nothing and calls no JS; both boundaries are unchanged. Re-audited 2026-09-14 for #10241 (cohort survival), which touched `gc/cycle.rs` and `gc/policy.rs`. `gc/cycle.rs`: one call, `promoted_cohort::survival::check_minor_view_at_full_sweep_start()`, in `step_sweep` immediately AFTER `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS, i.e. outside the window. It is a no-op unless a promoted-cohort full armed its survival probe; when armed it walks the old page index over the preceding minor's dirty pages (`old_arena_walk_objects_on_pages`, Rust-allocator Vecs), reads GC headers' mark flags and the slots of unmarked ones, and records one enum. It writes no heap memory, allocates no GC object, relocates nothing and calls no JS. `gc/policy.rs`: `run_promoted_cohort_full_if_due` arms the probe before `gc_collect_full_mark_sweep_with_trigger` and takes it after the full returns (feeding `note_full_measured_promotion_survival` and one diagnostic line); both run before a cycle starts or after it completes. Both boundaries are unchanged. Re-audited 2026-09-14 for #10241's in-place-only cohort: `gc/policy.rs` drops the `promoted_cohort::note_promoted` call from `credit_promoted_bytes_to_old_baseline` (the copying minor now calls `promoted_cohort::note_minor_promotion` itself, after the credit). Both run at the end of a copying minor, outside any full cycle; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-14 for the parse-boundary side-allocation band (medium-parse pacing), which touched `gc/policy.rs`. Three hunks: (a) a `Cell` thread-local (`GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES`, a byte COUNT, no pointer) plus three pure predicates over it and `external_side_live_bytes()`; (b) that predicate added as a third disjunct of `tiny_parse_generational_collection_due`, which is read only from the three JSON.parse mutator-side boundaries (`gc_bump_malloc_trigger_inner`, `gc_collect_pending_suppressed_parse_slow`, `gc_schedule_parse_boundary_collection_if_pressure`), none of them reachable from `step_mark_propagation` or `step_sweep`; and (c) one extra `Cell` store in `note_collection_finished_arena_occupancy` plus two extra reads in the `PERRY_GC_DIAG` tiny-parse line. `note_collection_finished_arena_occupancy` runs from `publish_reclaim_outcome` in the Publish subphase, i.e. AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local, exactly as #9831's store on the same line does. Nothing added allocates a GC object, relocates anything, or runs a JS callback, and neither census boundary moved. Re-audited 2026-09-14 for the drained-bytes counterweight to that band, which touched `gc/policy.rs` again. Four hunks: a second `Cell` thread-local (`GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL`, a byte COUNT); one increment of it inside `gc_note_external_side_free`; a pure read (`external_side_old_reclaim_pressure_bytes`) substituted for `external_side_live_bytes()` at the four old-reclaim pressure sites; and one `Cell` store at the top of `finish_full_old_reclaim_baseline`. None of it can run between the census boundaries. `gc_note_external_side_free` is also reached by mutator-side tape materialization, regex scratch teardown, native-addon adjustments and buffer replacement. Its added operation is only a saturating increment of a scalar Cell, with no GC allocation, relocation, collection or JS callback, so this wider caller set does not invalidate the census window. `finish_full_old_reclaim_baseline` runs from `publish_reclaim_outcome` in the Publish subphase, the same place #9831's store already sits. The pressure reads happen at trigger decisions, before a cycle starts. No allocation, relocation, collection or JS callback is added to the mark-complete -> sweep-entry window, and neither boundary moved. Re-audited 2026-09-15 for turnloop P0, which touched `gc/mod.rs` with one added call: `crate::event_pump::shutdown_wait_driver()` inside `js_gc_release_current_thread_collection_side_allocations`, the process-exit funnel. That function runs once no more JavaScript can run on the thread, never from inside a collection cycle; the added call drops the thread's turnloop wait loop (closing its kqueue/epoll descriptor) and may print a diagnostic line. It allocates no GC object, relocates nothing, starts no collection and runs no JS callback. The census boundaries and the mark-complete -> sweep-entry window are untouched.. Re-audited 2026-09-16 for the copying minor's per-parent weak-holder fact: `gc/mod.rs` gains exactly one line, `mod copying_parent_facts;`, a module declaration. The module it declares holds `weak_holder_fact` (a read of the parent's `obj_type`/`class_id` via `weakref::is_weak_holder_header`) and the copying minor's `visit_slot_with_parent`, moved verbatim out of `gc/copying.rs` for the 2000-line lint. Both run only inside a COPYING MINOR, which skips both census boundaries (`census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` are synchronous-full only). Nothing was added to any full-cycle phase, and the declaration itself executes no code. Neither boundary moved and the synchronous mark-complete to sweep-entry window gains no allocation, relocation, collection or JS callback. Re-audited 2026-09-18 for the #10532 follow-up argument-list rooting fix, which touched `gc/mod.rs`. The only change there is `mod collection_points;` plus a `pub(crate) use collection_points::collection_point;` re-export (and, under `#[cfg(test)]`, `arm_collection_point`). `collection_point` is an inline no-op outside `cfg(test)`; under test it only runs a copying minor when called from ordinary MUTATOR code (`proxy.rs`'s `Reflect.apply` rebind path and `registry.rs`'s rest-array bundler), never from inside `step_mark_propagation` or `step_sweep`. Neither `census_pass1_if_armed` nor `census_take_if_armed_at_full_sweep_start` is reachable from it, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-18 (same PR, round 2) for the added `arm_collection_point_after` re-export in `gc/mod.rs`: another pure re-export line, same as the `collection_point`/`arm_collection_point` one already covered above. `arm_collection_point_after` only changes test-only arming state in `collection_points.rs` (which named site fires and on which hit); it still runs no mark/sweep control flow. Re-audited 2026-09-19 for #10735 (require.main threading): gc/mod.rs gains exactly one line, `reg_scanner!(crate::module_require::scan_cjs_main_module_root_mut);`, registering the new CJS_MAIN_MODULE thread-local's mutable-root scanner beside the existing `scan_module_path_roots_mut` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it runs during root scanning, before mark propagation completes, and does not execute between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start`. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-20 for #10834 (inherited-property read cache). `gc/mod.rs` gains exactly one line: `reg_scanner!(crate::object::inherited_read_cache::scan_inherited_read_cache_roots_mut);` in `gc_init()`. A scanner registration adds a root SOURCE for the mutable-root walks. The walk runs inside `RootScanCycleState::step_current_subphase`, i.e. entirely within the RootScan phase: `step_root_scan` only sets `self.phase = GcCyclePhase::MarkPropagation` once that loop reports done (`gc/cycle.rs:958-961`), and `census_pass1_if_armed()` fires at the END of `step_mark_propagation` (`gc/cycle.rs:982`). The scanner therefore runs strictly BEFORE the window opens and can never execute between the boundaries. Its body is a bounded walk of a fixed 512-entry thread-local array calling `visit_tagged_usize_slot` / `visit_usize_slot`; it allocates nothing, relocates nothing and runs no JS callback. Same shape as #9769, #9976/#9977, #10054, #10055 and #10735, all previously cleared. The PR also adds an `INHERITED_READ_CACHE` entry to `DEAD_KEY_PRUNES` in `gc/dead_owner.rs` (not a pinned source). That registry is consumed by `IncrementalSweepState::with_dead_collection_finalize` at `gc/cycle.rs:1548`, which is AFTER `census_take_if_armed_at_full_sweep_start` at `gc/cycle.rs:1505` has already `take()`n the snapshot out of the thread-local -- the same argument that cleared #9845's `collect_dead_registered_regexps_post_trace`. The prune reads addresses and zeroes entries; no GC allocation, relocation or callback. Both additions sit outside the window, on opposite sides of it. Neither boundary moved and the synchronous mark-complete to sweep-entry interval is unchanged. Re-audited 2026-09-22 for #10399 (per-thread module init), which touched `gc/mod.rs`. Two hunks, both init-time: a new free function `raise_default_thread_stack_floor()` and one call to it at the top of `js_gc_init`, before `enter_current_thread_image`'s successor statements. The function reads `RUST_MIN_STACK` from the environment and, only when it is unset, sets it to 32 MiB so a thread spawned against a multi-megabyte static TLS block still has usable stack (glibc carves static TLS out of the thread's stack mapping). It touches no heap object, allocates no GC object, relocates nothing and runs no JS callback. `js_gc_init` is the first runtime call of a compiled `main`, so it runs once before any cycle exists, and it is not reachable from `step_mark_propagation` or `step_sweep`. Same shape as the 2026-09-11 startup-memory-profile re-audit, which cleared the pre-main allocator-policy constructor in the same function. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-23 (size/runtime-decouple, #11135) after the binary-size branch touched `gc/census.rs`, `gc/mod.rs` and `gc/policy.rs`. census.rs: `census_pass1_if_armed` / `census_take_if_armed_at_full_sweep_start` keep their bodies verbatim, moved into `_impl` functions compiled only with the new `gc-instruments` feature (without it both are empty and `census_path()` is `None`, so nothing is ever armed); the take still empties PASS1_MARKED before `take_census`. gc/mod.rs: `gc_init` gains a startup env check that aborts when an instrument knob is set without the feature, before any cycle exists. gc/policy.rs: env-knob OnceLock caches now initialize through `crate::once_init::get_or_init` (same closures, same values). No mark/sweep control flow between the two census boundaries changed; the window is unchanged. Re-audited for Fetch handle reclamation: cycle.rs only redirects the incomplete-cycle Drop cancellation hook to also cancel the Fetch trace. The full-trace finish hook removes native records and cached slots without allocating GC objects or invoking JS; it cannot relocate the census addresses before sweep entry. Re-audited 2026-09-22 for #10928 (one proportional old-reclaim rule), which touched `gc/policy.rs`. Six hunks. (a) Two new thread-locals, `GC_OLD_RECLAIM_PRE_IN_USE_BYTES` (`Cell`) and `GC_OLD_RECLAIM_BACKOFF_SHIFT` (`Cell`): both are byte/shift COUNTS, neither holds a pointer. (b) `gc_old_reclaim_growth_band_bytes` gains a `Cell` read and a left shift -- pure arithmetic over byte counts. (c) `old_reclaim_pressure_due` loses the #7937 absolute first-crossing arm, splits its pure form out as `old_reclaim_pressure_due_inner`, and calls `note_old_reclaim_cycle_started()` when the answer is true. That predicate is read at TRIGGER decisions only -- the allocation-point `gc_check_trigger` and `gc_budgeted_due_trigger` at safepoints -- i.e. before a cycle starts, never between the boundaries; an allocation inside the window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before this code, the same argument the 2026-09-14 due-check fast-path re-audit made for the same function. Even if it did run there it would be sound: `note_old_reclaim_cycle_started` stores one scalar `Cell` from `pacing_arena_in_use_bytes()` (a read of `arena_live_allocated_bytes`), which allocates no GC object, relocates nothing and runs no JS callback -- the window's contract. (d) `update_old_reclaim_backoff` is called only from `finish_full_old_reclaim_baseline`, which runs from `publish_reclaim_outcome` in the Publish subphase, AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local -- exactly where #9831's store and the medium-parse pacing store already sit. (e) `gc_old_reclaim_debt_bytes` drops the absolute arm it mirrored; it remains pure arithmetic read at debt/trigger decisions. (f) `#[cfg(test)]` seams, absent from production builds. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback between them; the change alters only WHEN a collection is scheduled, never what runs inside one. Neither boundary moved and the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-24 for #10960 (growth-aware old-reclaim backoff), which touched `gc/policy.rs` again. One new thread-local, `GC_OLD_RECLAIM_LAST_POST_IN_USE_BYTES` (`Cell`), a byte COUNT that holds no pointer. It is written only by `update_old_reclaim_backoff`, which runs from `finish_full_old_reclaim_baseline` in the Publish subphase, after `step_sweep` has already taken the snapshot out of the thread-local; the change there is pure integer arithmetic deciding whether to widen the band. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback, and neither window boundary moved. Re-audited 2026-09-24 after the class-capture environment added one `reg_scanner!` registration (`scan_class_env_roots_mut`, visiting each guarded class environment's owner class object) to `gc/mod.rs`: a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-25 after the inherited-access lane touched `gc/mod.rs`: the change is one `reg_scanner!` registration for `object::chain_store::scan_chain_store_roots_mut`, a root scanner that visits store-site chain verdicts (one interned key pointer each) during root scanning. It runs at the start of a cycle, never between mark completion and sweep entry, relocates nothing and runs no JS callback. The synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-25 for #10698 (allocation-point trigger watermark), which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains `pub(crate) mod trigger_watermark;` and a `pub(crate) use` re-export -- no control flow. In `gc/policy.rs`: (a) `gc_check_trigger` becomes an out-of-line wrapper over an inlined fast path that reads the watermark cell, the malloc registry's length through an unguarded shared borrow and the inline allocator's offset, then either returns or runs the previous body unchanged (`gc_check_trigger_evaluate`). An allocation inside the window reaches it with `GC_FLAG_IN_ALLOC` set: the fast path returns without acting, which is the outcome of the `GC_FLAG_IN_ALLOC` early return it would otherwise reach, and the slow path still takes that early return. (b) The due-trigger evaluation also returns a watermark -- integer arithmetic over values it already read -- published only on the no-action return past every entry guard. (c) The ladder's thresholds and flags are retyped `Cell` -> `TriggerInput`, whose writes add one store retiring the watermark; heap-generation advances and `Arena::set_current` retire it too. (d) `#[cfg(test)]` seams. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback; `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-25 for #10498 (class-accessor cache), which adds one `reg_scanner!` registration (`scan_class_accessor_cache_roots_mut`, marking and rewriting the cache's key strings) to `gc/mod.rs`: again a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-26 for #10572: `gc/mod.rs` gains one `#[cfg(not(feature = \"hot-diag\"))]` call to `hot_diag::refuse_knobs_without_hot_diag()` in `gc_init`, a startup-only knob check that runs before any cycle and alters no mark/sweep control flow; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-27 for #11416: `gc/mod.rs` loses the `reg_scanner!` registration for `class_accessor_cache::scan_class_accessor_cache_roots_mut` (the cache is deleted); removing a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-27 for the method-calls lane, which adds one `reg_scanner!` registration (`scan_method_site_roots_mut`, marking and rewriting the method closures inherited method-site entries hold) to `gc/mod.rs`: a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-28 for #11605 (link-time runtime feature installs): `gc/mod.rs` registers the interpreter root scanner through its always-present forwarder (`dyn_eval_hooks::scan_dyn_eval_roots_mut`, which calls the real scanner through a slot the `dyn-eval` install fills) instead of a feature-gated direct registration, and `js_gc_init` ends by running the program\u2019s feature installer, which only stores fn pointers into `Hook` slots at startup, before any user code or collection. Neither alters mark/sweep control flow or runs inside the mark-complete to sweep-entry window.", "window": { "start": { "file": "crates/perry-runtime/src/gc/census.rs", @@ -403,7 +403,7 @@ "sources": { "crates/perry-runtime/src/gc/census.rs": "25601f25ac70aa998f8cb5c1939d11e7c43a96235d4edf39a78e261b68709471", "crates/perry-runtime/src/gc/cycle.rs": "4744196ba5e9c5ac40912154cf5b45b4a618d81ddc776ab1095fbc585f27c878", - "crates/perry-runtime/src/gc/mod.rs": "bb38a949495846fd30598064bb04d8e8d384721c25a9a21d1029da1e0c5caa3c", + "crates/perry-runtime/src/gc/mod.rs": "7152d3897fe23c32012d51218a613ca993cc40c7042d2735bf1c1a501b63c19e", "crates/perry-runtime/src/gc/policy.rs": "7e2304ec822d26877df4eaf7aefb55c485c66425b8946b56a9b271a0abcc635b", "crates/perry-runtime/src/gc/progress.rs": "a5ad3971bbe4047229ca57325234780daa85921dbc778e1c08dff4ad07ccfb96" } diff --git a/scripts/gc_runtime_root_holders.py b/scripts/gc_runtime_root_holders.py index 2fb970aeaf..5864011ebe 100755 --- a/scripts/gc_runtime_root_holders.py +++ b/scripts/gc_runtime_root_holders.py @@ -326,6 +326,9 @@ def repo_relative(path: PurePath, root: PurePath) -> str: # C-ABI registration the ext crates use, and a `[^()]*` argument body # silently missed it — every holder that trampoline covers then read as # uncovered. +# `SLOT.get()` on a `Hook` static inside a registered scanner — see the +# feature-slot step in `_scan` for why its `SLOT.set(path)` targets seed. +HOOK_GET = re.compile(r"\b([A-Z][A-Z0-9_]*)\s*\.get\(\)") REGISTER_CALL = re.compile( r"(?:gc_register_\w*root_scanner\w*|reg_scanner!|reg_budgeted_scanner!)" r"\s*\((?P(?:[^;()]|\([^()]*\))*)\)", @@ -925,21 +928,53 @@ def path_matches(defining: Path, segments: list[str]) -> bool: # qualification; one that resolves to several must match the path. seeds: set[str] = set() seed_files: dict[str, set[Path]] = {} - for name, segments in registered_paths: + + def add_seed(name: str, segments: list[str]) -> bool: definitions = bodies.get(name) if not definitions: # Not a function in these crates — a type name (`as # MutableRootScanner`) or a path segment. Seeding it would make half # the crate reachable. - continue + return False if len(definitions) == 1: matched = definitions else: matched = [(p, b) for (p, b) in definitions if path_matches(p, segments)] if not matched: matched = definitions # unresolvable: fall back, over-approximate + files = seed_files.setdefault(name, set()) + before = (name in seeds, len(files)) seeds.add(name) - seed_files.setdefault(name, set()).update(p for p, _ in matched) + files.update(p for p, _ in matched) + return before != (True, len(files)) + + for name, segments in registered_paths: + add_seed(name, segments) + + # A registered scanner may forward through a link-time feature slot + # (`perry_runtime::feature_hooks::Hook`): its body reads `SLOT.get()` and + # calls the fn pointer, so the call graph ends there. The functions that + # can be in the slot are exactly the bare paths the SAME file stores with + # `SLOT.set(path)` (a Hook static is private to its file), so each of + # those is registered as far as coverage goes. Resolving it here keeps the + # coverage computed: delete the real scanner or its `set` and the holders + # it reaches read as uncovered again. + changed = True + while changed: + changed = False + for name in sorted(seeds): + for path in sorted(seed_files.get(name, ())): + file_text = strip_comments(texts[path]) + for defining, body in bodies.get(name, []): + if defining != path: + continue + for slot in HOOK_GET.findall(body): + for target in re.findall( + rf"\b{slot}\s*\.set\(\s*((?:\w+::)*[A-Za-z_]\w*)\s*\)", + file_text, + ): + segments = target.split("::") + changed |= add_seed(segments[-1], segments[:-1]) def reachable_text(call_pattern: re.Pattern) -> dict[Path, str]: reachable: set[str] = set() @@ -1348,11 +1383,36 @@ def print_list(root: Path) -> int: gc_register_mutable_root_scanner(crate::thing::scan_thing_roots_mut); gc_register_mutable_root_scanner(crate::other::scan_other_roots_mut); gc_register_mutable_root_scanner(crate::dup_a::scan_dup_roots_mut); + gc_register_mutable_root_scanner(crate::fwd::scan_fwd_roots_mut); """ + "\n".join( f" gc_register_mutable_root_scanner(crate::pad::scan_pad_{i}_mut);" for i in range(MIN_REGISTERED) ) + """ } +""", + # A registered forwarder that reaches its scanner only through a + # link-time feature slot. The slot's `set` target is covered; a scanner + # stored into a slot no registered function reads is not. + "crates/perry-runtime/src/fwd.rs": """ +static SCAN: Hook = Hook::empty(); +static UNREAD: Hook = Hook::empty(); +pub fn scan_fwd_roots_mut(v: &mut V) { + if let Some(scan) = SCAN.get() { scan(v); } +} +pub fn install() { + SCAN.set(crate::behind_slot::scan_behind_slot_mut); + UNREAD.set(crate::behind_slot::scan_unread_slot_mut); +} +""", + "crates/perry-runtime/src/behind_slot.rs": """ +static COVERED_VIA_HOOK: RefCell> = RefCell::new(Vec::new()); +static UNCOVERED_UNREAD_HOOK: RefCell> = RefCell::new(Vec::new()); +pub fn scan_behind_slot_mut(v: &mut V) { + for p in COVERED_VIA_HOOK.borrow_mut().iter_mut() { v.visit(p); } +} +pub fn scan_unread_slot_mut(v: &mut V) { + for p in UNCOVERED_UNREAD_HOOK.borrow_mut().iter_mut() { v.visit(p); } +} """, "crates/perry-runtime/src/thing.rs": """ static COVERED_DIRECT: RefCell> = RefCell::new(Vec::new()); @@ -1586,6 +1646,18 @@ def expect_absent(rel: str, name: str, why: str) -> None: True, "crate::perry_thread_local! declaration with a type opaque to rules A/B", ) + expect( + "crates/perry-runtime/src/behind_slot.rs", + "COVERED_VIA_HOOK", + True, + "reached through a Hook slot the registered forwarder reads", + ) + expect( + "crates/perry-runtime/src/behind_slot.rs", + "UNCOVERED_UNREAD_HOOK", + False, + "stored into a Hook slot no registered scanner reads", + ) expect( "crates/perry-runtime/src/leak.rs", "UNCOVERED_TYPED", From a5d933aa3198e24e1d9ea24284201fdd4f33cf93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 28 Sep 2026 10:38:47 +0000 Subject: [PATCH 05/13] perf(size): build release runtime archives without the GC/diagnostic instruments perry-runtime's `diagnostics`, `gc-instruments` and `hot-diag` features are default features, so the prebuilt `libperry_runtime.a` an installed perry links carried every census, verifier, trace and hot-path diagnostic into every program. Gate that code on build-script cfgs (perry_diagnostics, perry_gc_instruments, perry_hot_diag) that build.rs derives from the features unless PERRY_RELEASE_STRIP_INSTRUMENTS=1, and set that in the release-packages workflow (both Linux docker builds included). An env var rather than a feature change keeps one feature union across the shipped archives (#6303/#7358). Workspace builds, cargo test and auto-optimized instrument builds are unchanged. A stripped binary refuses an instrument knob at startup, as a build without the features always has; the compiler now also notes that a prebuilt link cannot honor an instrument request and names PERRY_WORKSPACE_ROOT. Installed-mode backend 15.05 -> 14.71 MB, fs-only 8.76 -> 8.39 MB. --- .github/workflows/release-packages.yml | 10 ++++ changelog.d/release-runtime-no-instruments.md | 1 + crates/perry-runtime/build.rs | 28 ++++++++++ .../perry-runtime/src/arena/alloc_sample.rs | 10 ++-- crates/perry-runtime/src/arena/mod.rs | 2 +- crates/perry-runtime/src/arena/quarantine.rs | 6 +- crates/perry-runtime/src/arena/walk.rs | 2 +- .../perry-runtime/src/bun_compat/cli_utils.rs | 4 +- .../perry-runtime/src/error_stack_frames.rs | 4 +- crates/perry-runtime/src/gc/barrier_arming.rs | 2 +- crates/perry-runtime/src/gc/census.rs | 14 ++--- crates/perry-runtime/src/gc/cycle.rs | 2 +- crates/perry-runtime/src/gc/fromspace_scan.rs | 6 +- crates/perry-runtime/src/gc/instruments.rs | 8 +-- crates/perry-runtime/src/gc/malloc.rs | 4 +- crates/perry-runtime/src/gc/mod.rs | 8 +-- crates/perry-runtime/src/gc/oldgen.rs | 2 +- crates/perry-runtime/src/gc/pin.rs | 2 +- crates/perry-runtime/src/gc/policy.rs | 12 ++-- .../perry-runtime/src/gc/roots/scan_mode.rs | 2 +- crates/perry-runtime/src/gc/schedule.rs | 6 +- crates/perry-runtime/src/gc/telemetry.rs | 56 +++++++++---------- crates/perry-runtime/src/gc/trace.rs | 6 +- crates/perry-runtime/src/gc/types.rs | 2 +- crates/perry-runtime/src/hot_diag.rs | 28 +++++----- .../src/hot_diag/receiver_repr.rs | 6 +- crates/perry-runtime/src/node_v8.rs | 8 +-- crates/perry-runtime/src/process/report.rs | 6 +- crates/perry-runtime/src/typed_feedback.rs | 10 ++-- .../perry-runtime/src/typed_feedback/trace.rs | 10 ++-- .../commands/compile/optimized_libs/driver.rs | 17 ++++++ scripts/gc_runtime_root_holders.json | 10 ++-- 32 files changed, 175 insertions(+), 119 deletions(-) create mode 100644 changelog.d/release-runtime-no-instruments.md diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index d5d9aa3645..01183f480a 100644 --- a/.github/workflows/release-packages.yml +++ b/.github/workflows/release-packages.yml @@ -50,6 +50,14 @@ concurrency: group: release-packages-${{ github.event.release.tag_name || inputs.existing_tag || (inputs.cut_release && inputs.candidate_sha) || github.run_id }} cancel-in-progress: false +# Shipped runtime archives carry no GC/diagnostic instruments: perry-runtime's +# build script compiles them out when this is set (see its +# `emit_instrument_cfgs`). Every cargo build in these jobs sees it, including +# the per-ext-crate invocations that rebuild the runtime, so all shipped +# archives share one feature set (#6303). The container builds pass it through. +env: + PERRY_RELEASE_STRIP_INSTRUMENTS: "1" + jobs: # --------------------------------------------------------------------------- # Resolve the run mode + release tag ONCE, up front. Every downstream job @@ -764,6 +772,7 @@ jobs: --env CARGO_TARGET_DIR=/work/target-glibc231 \ --env PERRY_ABORT_TARGET_DIR=/work/target-glibc231-abort \ --env PERRY_CLI_UPDATE_PUBLIC_KEYS \ + --env PERRY_RELEASE_STRIP_INSTRUMENTS \ --volume "$HOME/.cargo:/tmp/cargo-home" \ --volume "$HOME/.rustup:/tmp/rustup-home" \ --volume "$GITHUB_WORKSPACE:/work" \ @@ -810,6 +819,7 @@ jobs: --env CARGO_TARGET_DIR=/work/target-musl \ --env PERRY_ABORT_TARGET_DIR=/work/target-musl-abort \ --env PERRY_CLI_UPDATE_PUBLIC_KEYS \ + --env PERRY_RELEASE_STRIP_INSTRUMENTS \ --volume "$HOME/.cargo:/tmp/cargo-home" \ --volume "$HOME/.rustup:/tmp/rustup-home" \ --volume "$GITHUB_WORKSPACE:/work" \ diff --git a/changelog.d/release-runtime-no-instruments.md b/changelog.d/release-runtime-no-instruments.md new file mode 100644 index 0000000000..5997811f8e --- /dev/null +++ b/changelog.d/release-runtime-no-instruments.md @@ -0,0 +1 @@ +- **perf(size): release packages build their runtime archives without the GC/diagnostic instruments.** Follow-up to #11605. perry-runtime's `diagnostics`, `gc-instruments` and `hot-diag` features are in its `default` set, so the prebuilt full-feature `libperry_runtime.a` that an installed perry links carried every census, verifier, trace and hot-path diagnostic into every program. Their code is now gated on build-script cfgs (`perry_diagnostics`, `perry_gc_instruments`, `perry_hot_diag`) that `crates/perry-runtime/build.rs` sets from the matching features unless `PERRY_RELEASE_STRIP_INSTRUMENTS=1`; `.github/workflows/release-packages.yml` sets it for every package build (including both Linux docker builds). An env var rather than a feature change keeps one feature union across every shipped archive (#6303/#7358: the ext crates and the stdlib bundle their own perry-runtime copies), and a workspace build, `cargo test` and auto-optimized links (`PERRY_GC_INSTRUMENTS=1`, or an instrument knob set while compiling) are unchanged. A stripped binary behaves as a build without the features always has: an instrument knob refuses at startup with the existing "built without the GC instruments" message. A new compile-time note explains that a prebuilt link cannot honor an instrument request and names `PERRY_WORKSPACE_ROOT`. Installed-mode sizes (Linux x86_64, release recipe, on top of #11605): the `node:net` + `fetch` + TLS backend 15.05 → 14.71 MB, a runtime-only fs program 8.76 → 8.39 MB, `node:net` 11.23 → 10.87 MB, `node:sqlite` 11.00 → 10.64 MB, fetch 12.66 → 12.29 MB; the prebuilt core runtime (already built without the instruments) and auto-optimized links unchanged. diff --git a/crates/perry-runtime/build.rs b/crates/perry-runtime/build.rs index 2e39107a8b..262dd53078 100644 --- a/crates/perry-runtime/build.rs +++ b/crates/perry-runtime/build.rs @@ -543,7 +543,35 @@ fn generate_single_byte_encodings(out_dir: &str) { .expect("write single_byte_encodings.rs"); } +/// The GC/diagnostic instruments (`diagnostics`, `gc-instruments`, `hot-diag`) +/// compile under the `perry_diagnostics` / `perry_gc_instruments` / +/// `perry_hot_diag` cfgs, set here when the feature is on — unless +/// `PERRY_RELEASE_STRIP_INSTRUMENTS=1`. The release workflow sets that for +/// every build in its jobs, so the shipped archives carry no instruments while +/// every other build (dev, CI, auto-optimize) is unchanged. An env var rather +/// than a feature: a feature can only be added by Cargo's unification, and the +/// release co-builds each ext crate with the runtime (#6303/#7358), whose +/// `default` would switch the instruments back on; the build script sees the +/// variable in every one of those invocations alike. A shipped runtime asked +/// for an instrument knob refuses it with the existing +/// "instruments not compiled in" diagnostic. +fn emit_instrument_cfgs() { + println!("cargo:rerun-if-env-changed=PERRY_RELEASE_STRIP_INSTRUMENTS"); + let strip = std::env::var("PERRY_RELEASE_STRIP_INSTRUMENTS").is_ok_and(|v| v == "1"); + for (feature_env, cfg) in [ + ("CARGO_FEATURE_DIAGNOSTICS", "perry_diagnostics"), + ("CARGO_FEATURE_GC_INSTRUMENTS", "perry_gc_instruments"), + ("CARGO_FEATURE_HOT_DIAG", "perry_hot_diag"), + ] { + println!("cargo:rustc-check-cfg=cfg({cfg})"); + if std::env::var_os(feature_env).is_some() && !strip { + println!("cargo:rustc-cfg={cfg}"); + } + } +} + fn main() { + emit_instrument_cfgs(); println!("cargo:rerun-if-changed=src/ffi/perry_memory_profile.c"); if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") && std::env::var("CARGO_CFG_TARGET_POINTER_WIDTH").as_deref() == Ok("64") diff --git a/crates/perry-runtime/src/arena/alloc_sample.rs b/crates/perry-runtime/src/arena/alloc_sample.rs index 904ee31173..5fa4ed48f3 100644 --- a/crates/perry-runtime/src/arena/alloc_sample.rs +++ b/crates/perry-runtime/src/arena/alloc_sample.rs @@ -38,7 +38,7 @@ // constant "off" (inlined, so every caller's guarded branch folds away and the // instrument links nothing); the rest of the module stays compiled so it // cannot rot, hence the allow. -#![cfg_attr(not(feature = "gc-instruments"), allow(dead_code, unused_imports))] +#![cfg_attr(not(perry_gc_instruments), allow(dead_code, unused_imports))] use std::cell::{Cell, RefCell}; use std::collections::HashMap; @@ -77,22 +77,22 @@ crate::perry_thread_local! { /// unparsable value selects the default interval. /// The sampling interval, or 0 when off. A constant 0 without the /// `gc-instruments` feature, so the allocation fast paths drop the check. -#[cfg(feature = "gc-instruments")] +#[cfg(perry_gc_instruments)] #[inline(always)] fn current_interval() -> usize { INTERVAL.load(Ordering::Relaxed) } -#[cfg(not(feature = "gc-instruments"))] +#[cfg(not(perry_gc_instruments))] #[inline(always)] fn current_interval() -> usize { 0 } -#[cfg(not(feature = "gc-instruments"))] +#[cfg(not(perry_gc_instruments))] pub(crate) fn init_from_env() {} -#[cfg(feature = "gc-instruments")] +#[cfg(perry_gc_instruments)] pub(crate) fn init_from_env() { let raw = std::env::var("PERRY_ALLOC_SITE_SAMPLE").ok(); let interval = parse_interval(raw.as_deref()); diff --git a/crates/perry-runtime/src/arena/mod.rs b/crates/perry-runtime/src/arena/mod.rs index ee726797b1..2c01776ccd 100644 --- a/crates/perry-runtime/src/arena/mod.rs +++ b/crates/perry-runtime/src/arena/mod.rs @@ -96,7 +96,7 @@ pub(crate) use allocators::{ }; // walk.rs -#[cfg(feature = "diagnostics")] +#[cfg(perry_diagnostics)] pub(crate) use walk::ArenaRegionTelemetry; pub use walk::{ arena_block_count, arena_in_use_bytes, arena_total_bytes, arena_walk_objects, diff --git a/crates/perry-runtime/src/arena/quarantine.rs b/crates/perry-runtime/src/arena/quarantine.rs index 6683e03e17..e2601301ee 100644 --- a/crates/perry-runtime/src/arena/quarantine.rs +++ b/crates/perry-runtime/src/arena/quarantine.rs @@ -101,7 +101,7 @@ // constant "off" (inlined, so every caller's guarded branch folds away and the // instrument links nothing); the rest of the module stays compiled so it // cannot rot, hence the allow. -#![cfg_attr(not(feature = "gc-instruments"), allow(dead_code, unused_imports))] +#![cfg_attr(not(perry_gc_instruments), allow(dead_code, unused_imports))] use super::*; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering}; @@ -149,13 +149,13 @@ thread_local! { static MODE_OVERRIDE: Cell> = const { Cell::new(None) }; } -#[cfg(not(feature = "gc-instruments"))] +#[cfg(not(perry_gc_instruments))] #[inline(always)] pub(crate) fn fromspace_protection_mode() -> FromSpaceProtection { FromSpaceProtection::Off } -#[cfg(feature = "gc-instruments")] +#[cfg(perry_gc_instruments)] pub(crate) fn fromspace_protection_mode() -> FromSpaceProtection { #[cfg(test)] if let Some(mode) = MODE_OVERRIDE.with(Cell::get) { diff --git a/crates/perry-runtime/src/arena/walk.rs b/crates/perry-runtime/src/arena/walk.rs index e76028f262..b5067ee329 100644 --- a/crates/perry-runtime/src/arena/walk.rs +++ b/crates/perry-runtime/src/arena/walk.rs @@ -421,7 +421,7 @@ pub(crate) struct ArenaRegionTelemetry { } #[derive(Clone, Copy, Default)] -#[cfg_attr(not(feature = "diagnostics"), allow(dead_code))] +#[cfg_attr(not(perry_diagnostics), allow(dead_code))] pub(crate) struct ArenaTelemetrySnapshot { pub(crate) arena: ArenaRegionTelemetry, pub(crate) survivor0: ArenaRegionTelemetry, diff --git a/crates/perry-runtime/src/bun_compat/cli_utils.rs b/crates/perry-runtime/src/bun_compat/cli_utils.rs index c004784c39..17a0836de8 100644 --- a/crates/perry-runtime/src/bun_compat/cli_utils.rs +++ b/crates/perry-runtime/src/bun_compat/cli_utils.rs @@ -599,7 +599,7 @@ pub extern "C" fn js_bun_generate_heap_snapshot(format: f64, encoding: f64) -> f if value_to_string(format) != "v8" { throw_type_error("Bun.generateHeapSnapshot format must be 'v8'"); } - #[cfg(feature = "diagnostics")] + #[cfg(perry_diagnostics)] { let json = crate::gc::gc_build_v8_heap_snapshot_json(); if !is_undefined_or_null(encoding) && value_to_string(encoding) == "arraybuffer" { @@ -610,7 +610,7 @@ pub extern "C" fn js_bun_generate_heap_snapshot(format: f64, encoding: f64) -> f } boxed_str(json.as_bytes()) } - #[cfg(not(feature = "diagnostics"))] + #[cfg(not(perry_diagnostics))] { let _ = encoding; throw_type_error("Heap snapshot diagnostics are not enabled in this Perry runtime") diff --git a/crates/perry-runtime/src/error_stack_frames.rs b/crates/perry-runtime/src/error_stack_frames.rs index 4fd23a1edf..a66af52113 100644 --- a/crates/perry-runtime/src/error_stack_frames.rs +++ b/crates/perry-runtime/src/error_stack_frames.rs @@ -412,7 +412,7 @@ fn dladdr_info(ip: usize) -> Option { /// `gc::instruments::INSTRUMENT_KNOBS`). #[cfg(unix)] fn stack_symbols_enabled() -> bool { - if !cfg!(feature = "gc-instruments") { + if !cfg!(perry_gc_instruments) { return false; } static ENABLED: OnceLock = OnceLock::new(); @@ -1007,7 +1007,7 @@ mod tests { /// `PERRY_STACK_SYMBOLS`; no in-process test mutates the cached flag. /// The table is served by `gc-instruments` (#11541): without the feature /// the knob aborts at startup, so there is no opted-in state to test. - #[cfg(all(unix, feature = "gc-instruments"))] + #[cfg(all(unix, perry_gc_instruments))] #[test] fn describe_ip_names_a_kept_runtime_symbol_when_nm_is_opted_in() { const CHILD_ENV: &str = "PERRY_TEST_STACK_SYMBOLS_NM_CHILD"; diff --git a/crates/perry-runtime/src/gc/barrier_arming.rs b/crates/perry-runtime/src/gc/barrier_arming.rs index 16dda2f84d..24f494743a 100644 --- a/crates/perry-runtime/src/gc/barrier_arming.rs +++ b/crates/perry-runtime/src/gc/barrier_arming.rs @@ -119,7 +119,7 @@ thread_local! { // which is itself `allow(dead_code)` without the `diagnostics` feature — so a // product build (`cargo check -p perry --bins`, `-D warnings`) sees this as // unused. Same `cfg_attr` the three sibling sites in `telemetry.rs` use. -#[cfg_attr(not(feature = "diagnostics"), allow(dead_code))] +#[cfg_attr(not(perry_diagnostics), allow(dead_code))] pub(super) fn remembered_reconstruct_census() -> RememberedReconstructCensus { RECONSTRUCT_CENSUS.with(Cell::get) } diff --git a/crates/perry-runtime/src/gc/census.rs b/crates/perry-runtime/src/gc/census.rs index fb128d0e99..3e43473d5c 100644 --- a/crates/perry-runtime/src/gc/census.rs +++ b/crates/perry-runtime/src/gc/census.rs @@ -38,7 +38,7 @@ // collection passes below compile to nothing; the walk, the classifier and // the JSON writer then have no caller. `allow` rather than a cascade of cfgs: // they stay compiled, so they cannot rot in the default build either. -#![cfg_attr(not(feature = "gc-instruments"), allow(dead_code, unused_imports))] +#![cfg_attr(not(perry_gc_instruments), allow(dead_code, unused_imports))] use super::*; use std::sync::atomic::{AtomicBool, Ordering}; @@ -86,9 +86,9 @@ pub(crate) fn census_path() -> Option<&'static str> { } // Built without the instruments: never enabled (`gc_init` aborted if the // knob was set). - #[cfg(not(feature = "gc-instruments"))] + #[cfg(not(perry_gc_instruments))] return None; - #[cfg(feature = "gc-instruments")] + #[cfg(perry_gc_instruments)] CENSUS_PATH .get_or_init(|| { std::env::var("PERRY_GC_CENSUS") @@ -211,11 +211,11 @@ fn header_is_marked(header: *const GcHeader) -> bool { /// the reachable set so the sweep-entry pass can tell reachability from /// block-persistence retention. No-op unless armed. pub(super) fn census_pass1_if_armed() { - #[cfg(feature = "gc-instruments")] + #[cfg(perry_gc_instruments)] census_pass1_if_armed_impl(); } -#[cfg(feature = "gc-instruments")] +#[cfg(perry_gc_instruments)] fn census_pass1_if_armed_impl() { if !ARMED.with(|c| c.get()) { return; @@ -240,11 +240,11 @@ fn census_pass1_if_armed_impl() { /// Pass 2: sweep entry of the same synchronous full cycle (all marks final, /// nothing swept, block persistence already applied). Consumes the arm. pub(super) fn census_take_if_armed_at_full_sweep_start() { - #[cfg(feature = "gc-instruments")] + #[cfg(perry_gc_instruments)] census_take_if_armed_at_full_sweep_start_impl(); } -#[cfg(feature = "gc-instruments")] +#[cfg(perry_gc_instruments)] fn census_take_if_armed_at_full_sweep_start_impl() { if !ARMED.with(|c| c.replace(false)) { return; diff --git a/crates/perry-runtime/src/gc/cycle.rs b/crates/perry-runtime/src/gc/cycle.rs index 07165d1d33..195b8164e5 100644 --- a/crates/perry-runtime/src/gc/cycle.rs +++ b/crates/perry-runtime/src/gc/cycle.rs @@ -17,7 +17,7 @@ pub(super) enum GcCyclePhase { } impl GcCyclePhase { - #[cfg(feature = "diagnostics")] + #[cfg(perry_diagnostics)] #[inline] pub(super) const fn as_str(self) -> &'static str { match self { diff --git a/crates/perry-runtime/src/gc/fromspace_scan.rs b/crates/perry-runtime/src/gc/fromspace_scan.rs index 89b0dd28ae..a4288778e6 100644 --- a/crates/perry-runtime/src/gc/fromspace_scan.rs +++ b/crates/perry-runtime/src/gc/fromspace_scan.rs @@ -49,7 +49,7 @@ // constant "off" (inlined, so every caller's guarded branch folds away and the // instrument links nothing); the rest of the module stays compiled so it // cannot rot, hence the allow. -#![cfg_attr(not(feature = "gc-instruments"), allow(dead_code, unused_imports))] +#![cfg_attr(not(perry_gc_instruments), allow(dead_code, unused_imports))] use super::*; @@ -142,13 +142,13 @@ pub(super) fn resolve_scan_knobs(scan: Option<&str>, abort: Option<&str>) -> (bo (truthy(scan) || abort, abort) } -#[cfg(not(feature = "gc-instruments"))] +#[cfg(not(perry_gc_instruments))] #[inline(always)] pub(super) fn fromspace_scan_enabled() -> bool { false } -#[cfg(feature = "gc-instruments")] +#[cfg(perry_gc_instruments)] pub(super) fn fromspace_scan_enabled() -> bool { use std::sync::OnceLock; static CACHED: OnceLock = OnceLock::new(); diff --git a/crates/perry-runtime/src/gc/instruments.rs b/crates/perry-runtime/src/gc/instruments.rs index 778579e39e..da6af4816d 100644 --- a/crates/perry-runtime/src/gc/instruments.rs +++ b/crates/perry-runtime/src/gc/instruments.rs @@ -496,7 +496,7 @@ impl Drop for FinalRemarkTimer { /// built on the knob pass having exercised nothing, so the process aborts at /// startup instead — see CLAUDE.md "Four ways a gate can be unable to fail". // Read only by the feature-off startup check below. -#[cfg_attr(feature = "gc-instruments", allow(dead_code))] +#[cfg_attr(perry_gc_instruments, allow(dead_code))] pub(crate) const INSTRUMENT_KNOBS: &[&str] = &[ "PERRY_GC_CENSUS", "PERRY_GC_PROTECT_FROMSPACE", @@ -510,7 +510,7 @@ pub(crate) const INSTRUMENT_KNOBS: &[&str] = &[ ]; /// The first instrument knob set (non-empty) in the environment, if any. -#[cfg_attr(feature = "gc-instruments", allow(dead_code))] +#[cfg_attr(perry_gc_instruments, allow(dead_code))] pub(crate) fn requested_instrument_knob() -> Option<&'static str> { INSTRUMENT_KNOBS .iter() @@ -519,14 +519,14 @@ pub(crate) fn requested_instrument_knob() -> Option<&'static str> { } /// Startup check for binaries built without the instruments (see above). -#[cfg(not(feature = "gc-instruments"))] +#[cfg(not(perry_gc_instruments))] pub(crate) fn refuse_instrument_knobs_without_instruments() { if let Some(knob) = requested_instrument_knob() { instruments_unavailable(knob); } } -#[cfg(not(feature = "gc-instruments"))] +#[cfg(not(perry_gc_instruments))] #[cold] #[inline(never)] fn instruments_unavailable(knob: &str) -> ! { diff --git a/crates/perry-runtime/src/gc/malloc.rs b/crates/perry-runtime/src/gc/malloc.rs index 890a37cea1..8230a69985 100644 --- a/crates/perry-runtime/src/gc/malloc.rs +++ b/crates/perry-runtime/src/gc/malloc.rs @@ -30,7 +30,7 @@ impl MallocKindTelemetry { } } - #[cfg(feature = "diagnostics")] + #[cfg(perry_diagnostics)] pub(super) fn reset_cycle_deltas(&mut self) { self.allocated_count = 0; self.allocated_bytes = 0; @@ -432,7 +432,7 @@ impl MallocState { counters.copied_minor_validation_lookups.saturating_add(1); } - #[cfg(feature = "diagnostics")] + #[cfg(perry_diagnostics)] pub(super) fn take_kind_telemetry( &mut self, ) -> [MallocKindTelemetry; MALLOC_KIND_BUCKET_COUNT] { diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index f044890b0c..ef45d1e968 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -282,12 +282,12 @@ pub use schedule::{ pub use verify::*; /// Env-gated heap census (`PERRY_GC_CENSUS`); off by default. pub(crate) mod census; -#[cfg(feature = "diagnostics")] +#[cfg(perry_diagnostics)] mod heap_snapshot; mod heap_stats; mod regex_census; pub use census::{census_poll_signal, gc_census_enabled}; -#[cfg(feature = "diagnostics")] +#[cfg(perry_diagnostics)] pub use heap_snapshot::gc_build_v8_heap_snapshot_json; pub(crate) use heap_stats::heap_stats; @@ -977,9 +977,9 @@ pub fn gc_init() { return; } crate::perf_hooks::init_time_origin(); - #[cfg(not(feature = "gc-instruments"))] + #[cfg(not(perry_gc_instruments))] instruments::refuse_instrument_knobs_without_instruments(); - #[cfg(not(feature = "hot-diag"))] + #[cfg(not(perry_hot_diag))] crate::hot_diag::refuse_knobs_without_hot_diag(); // `PERRY_GC_CENSUS`: remember the main thread and install the SIGUSR2 // trigger. No-op (one OnceLock read) when the env var is unset. diff --git a/crates/perry-runtime/src/gc/oldgen.rs b/crates/perry-runtime/src/gc/oldgen.rs index 15f84faf08..14a45a4aed 100644 --- a/crates/perry-runtime/src/gc/oldgen.rs +++ b/crates/perry-runtime/src/gc/oldgen.rs @@ -110,7 +110,7 @@ impl Default for EvacuationPolicyDecision { } #[derive(Clone, Copy, Default)] -#[cfg_attr(not(feature = "diagnostics"), allow(dead_code))] +#[cfg_attr(not(perry_diagnostics), allow(dead_code))] pub(super) struct SweepTraceStats { pub(super) dead_bytes: u64, // Compatibility alias for dead_bytes. diff --git a/crates/perry-runtime/src/gc/pin.rs b/crates/perry-runtime/src/gc/pin.rs index a64b53cc95..6d1e3d7578 100644 --- a/crates/perry-runtime/src/gc/pin.rs +++ b/crates/perry-runtime/src/gc/pin.rs @@ -596,7 +596,7 @@ pub(super) fn pinned_young_move_report( /// Human-readable name for a `GcHeader::obj_type`. /// -/// `types::gc_type_name` is `#[cfg(feature = "diagnostics")]`, and this abort +/// `types::gc_type_name` is `#[cfg(perry_diagnostics)]`, and this abort /// has to print the same text in every build — a fault report that degrades /// with the feature set is a fault report nobody can compare against. fn gc_type_label(obj_type: u8) -> &'static str { diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index ee6adf6909..7ec68d0e78 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -1125,7 +1125,7 @@ pub(super) enum GcCollectionKind { } impl GcCollectionKind { - #[cfg(feature = "diagnostics")] + #[cfg(perry_diagnostics)] #[inline] pub(super) fn as_str(self) -> &'static str { match self { @@ -1164,7 +1164,7 @@ pub(super) enum GcTriggerKind { } impl GcTriggerKind { - #[cfg(feature = "diagnostics")] + #[cfg(perry_diagnostics)] #[inline] pub(super) fn as_str(self) -> &'static str { match self { @@ -1243,7 +1243,7 @@ impl DeferredGcRequest { } #[derive(Clone, Copy)] -#[cfg_attr(not(feature = "diagnostics"), allow(dead_code))] +#[cfg_attr(not(perry_diagnostics), allow(dead_code))] pub(super) struct GcStepSnapshot { pub(super) arena_step_bytes: usize, pub(super) next_arena_trigger_bytes: usize, @@ -2325,7 +2325,7 @@ pub(super) fn note_copying_minor_young_survival(survival_permille: u64) { /// Whether the last copying minor measured a retaining heap. Trace/test /// observability — a gate that cannot see this cannot prove which arm paced a /// given run. -#[cfg(any(feature = "diagnostics", test))] +#[cfg(any(perry_diagnostics, test))] pub(super) fn major_pacing_retaining() -> bool { GC_MAJOR_PACING_RETAINING.with(|c| c.get()) } @@ -2576,7 +2576,7 @@ thread_local! { } /// Current arena-growth escalation backoff shift. -#[cfg(any(feature = "diagnostics", test))] +#[cfg(any(perry_diagnostics, test))] pub(super) fn major_pacing_backoff_shift() -> u32 { GC_MAJOR_PACING_BACKOFF_SHIFT.with(|shift| shift.get()) } @@ -2595,7 +2595,7 @@ pub(super) fn major_pacing_backoff_shift() -> u32 { // `test` as well as `diagnostics` (matching `major_pacing_backoff_shift`), so // the test that pins snapshot-vs-predicate agreement still builds under // `--no-default-features`, where the trace itself is compiled out. -#[cfg(any(feature = "diagnostics", test))] +#[cfg(any(perry_diagnostics, test))] pub(super) fn major_pacing_snapshot() -> (usize, u32, Option) { let baseline = GC_LAST_FULL_ARENA_IN_USE_BYTES.with(|bytes| bytes.get()); let shift = major_pacing_backoff_shift(); diff --git a/crates/perry-runtime/src/gc/roots/scan_mode.rs b/crates/perry-runtime/src/gc/roots/scan_mode.rs index 305a51dea6..ba17cf1584 100644 --- a/crates/perry-runtime/src/gc/roots/scan_mode.rs +++ b/crates/perry-runtime/src/gc/roots/scan_mode.rs @@ -32,7 +32,7 @@ pub(crate) enum ConservativeStackScanDecision { } impl ConservativeStackScanDecision { - #[cfg(feature = "diagnostics")] + #[cfg(perry_diagnostics)] #[inline] pub(crate) const fn as_str(self) -> &'static str { match self { diff --git a/crates/perry-runtime/src/gc/schedule.rs b/crates/perry-runtime/src/gc/schedule.rs index 1960135fdb..858c2d0c31 100644 --- a/crates/perry-runtime/src/gc/schedule.rs +++ b/crates/perry-runtime/src/gc/schedule.rs @@ -106,7 +106,7 @@ // constant "off" (inlined, so every caller's guarded branch folds away and the // instrument links nothing); the rest of the module stays compiled so it // cannot rot, hence the allow. -#![cfg_attr(not(feature = "gc-instruments"), allow(dead_code, unused_imports))] +#![cfg_attr(not(perry_gc_instruments), allow(dead_code, unused_imports))] use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -245,13 +245,13 @@ thread_local! { /// Resolved `(seed, threshold)`, or `None` when the mode is off. Cached: the /// environment is read exactly once per process. -#[cfg(not(feature = "gc-instruments"))] +#[cfg(not(perry_gc_instruments))] #[inline(always)] fn resolved() -> Option<(u64, u64)> { None } -#[cfg(feature = "gc-instruments")] +#[cfg(perry_gc_instruments)] fn resolved() -> Option<(u64, u64)> { #[cfg(test)] if let Some(over) = SCHEDULE_OVERRIDE.with(std::cell::Cell::get) { diff --git a/crates/perry-runtime/src/gc/telemetry.rs b/crates/perry-runtime/src/gc/telemetry.rs index aae95562ad..2347e4bd63 100644 --- a/crates/perry-runtime/src/gc/telemetry.rs +++ b/crates/perry-runtime/src/gc/telemetry.rs @@ -62,9 +62,9 @@ impl Drop for GcDiagTestGuard { /// constant `false`, so the mark verifiers behind it (~28 KiB) are not linked, /// and `gc_init` aborts if the knob is set (`instruments::INSTRUMENT_KNOBS`). pub(crate) fn gc_verify_mark_enabled() -> bool { - #[cfg(not(feature = "gc-instruments"))] + #[cfg(not(perry_gc_instruments))] return false; - #[cfg(feature = "gc-instruments")] + #[cfg(perry_gc_instruments)] { static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); *crate::once_init::get_or_init(&ENABLED, || env_flag_enabled("PERRY_GC_VERIFY_MARK")) @@ -123,7 +123,7 @@ thread_local! { } #[derive(Clone, Copy, Default)] -#[cfg_attr(not(feature = "diagnostics"), allow(dead_code))] +#[cfg_attr(not(perry_diagnostics), allow(dead_code))] pub(super) struct RememberedSetTraceStats { pub(super) entries_scanned: usize, pub(super) valid_roots: usize, @@ -251,7 +251,7 @@ pub(super) enum CopiedMinorFallbackReason { } impl CopiedMinorFallbackReason { - #[cfg(feature = "diagnostics")] + #[cfg(perry_diagnostics)] #[inline] pub(super) const fn as_str(self) -> &'static str { match self { @@ -433,7 +433,7 @@ impl RootSourceSlotTraceStats { } #[derive(Clone, Copy, Default)] -#[cfg_attr(not(feature = "diagnostics"), allow(dead_code))] +#[cfg_attr(not(perry_diagnostics), allow(dead_code))] pub(super) struct NativeStackFallbackTraceStats { pub(super) decision: ConservativeStackScanDecision, pub(super) scanned: bool, @@ -827,7 +827,7 @@ pub(super) struct GcPauseStepTrace { } #[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[cfg_attr(not(feature = "diagnostics"), allow(dead_code))] +#[cfg_attr(not(perry_diagnostics), allow(dead_code))] pub(super) enum AllocatorMaintenanceStatus { Skipped, Executed, @@ -835,7 +835,7 @@ pub(super) enum AllocatorMaintenanceStatus { } impl AllocatorMaintenanceStatus { - #[cfg(feature = "diagnostics")] + #[cfg(perry_diagnostics)] #[inline] pub(super) const fn as_str(self) -> &'static str { match self { @@ -847,7 +847,7 @@ impl AllocatorMaintenanceStatus { } #[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[cfg_attr(not(feature = "diagnostics"), allow(dead_code))] +#[cfg_attr(not(perry_diagnostics), allow(dead_code))] pub(super) enum AllocatorMaintenanceReason { OrdinaryBudgeted, NotSupported, @@ -860,7 +860,7 @@ pub(super) enum AllocatorMaintenanceReason { } impl AllocatorMaintenanceReason { - #[cfg(feature = "diagnostics")] + #[cfg(perry_diagnostics)] #[inline] pub(super) const fn as_str(self) -> &'static str { match self { @@ -874,7 +874,7 @@ impl AllocatorMaintenanceReason { } #[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[cfg_attr(not(feature = "diagnostics"), allow(dead_code))] +#[cfg_attr(not(perry_diagnostics), allow(dead_code))] pub(super) struct AllocatorMaintenanceEvent { pub(super) status: AllocatorMaintenanceStatus, pub(super) reason: AllocatorMaintenanceReason, @@ -882,7 +882,7 @@ pub(super) struct AllocatorMaintenanceEvent { } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -#[cfg_attr(not(feature = "diagnostics"), allow(dead_code))] +#[cfg_attr(not(perry_diagnostics), allow(dead_code))] pub(super) struct AllocatorMaintenanceTrace { pub(super) malloc_trim: Option, /// #9612: the mimalloc purge, which is the primitive that actually @@ -891,7 +891,7 @@ pub(super) struct AllocatorMaintenanceTrace { pub(super) allocator_purge: Option, } -#[cfg_attr(not(feature = "diagnostics"), allow(dead_code))] +#[cfg_attr(not(perry_diagnostics), allow(dead_code))] pub(super) struct GcCycleTrace { pub(super) collection_kind: GcCollectionKind, pub(super) trigger_kind: GcTriggerKind, @@ -1068,7 +1068,7 @@ impl GcCycleTrace { } } - #[cfg(feature = "diagnostics")] + #[cfg(perry_diagnostics)] pub(super) fn into_json(mut self, steps_after: GcStepSnapshot) -> serde_json::Value { self.capture_layout_scans(); self.debt.record(GcDebtSnapshot::current()); @@ -1374,7 +1374,7 @@ impl GcCycleTrace { }) } - #[cfg(feature = "diagnostics")] + #[cfg(perry_diagnostics)] pub(super) fn emit(self, steps_after: GcStepSnapshot) { let event = self.into_json(steps_after); #[cfg(test)] @@ -1384,7 +1384,7 @@ impl GcCycleTrace { } } - #[cfg(not(feature = "diagnostics"))] + #[cfg(not(perry_diagnostics))] pub(super) fn emit(self, _steps_after: GcStepSnapshot) { eprintln!( "[gc] cycle (diagnostics feature disabled — rebuild without --no-default-features for JSON trace)" @@ -1392,7 +1392,7 @@ impl GcCycleTrace { } } -#[cfg(feature = "diagnostics")] +#[cfg(perry_diagnostics)] pub(super) fn debt_snapshot_json(snapshot: GcDebtSnapshot) -> serde_json::Value { serde_json::json!({ "arena_debt_bytes": snapshot.arena_debt_bytes, @@ -1401,7 +1401,7 @@ pub(super) fn debt_snapshot_json(snapshot: GcDebtSnapshot) -> serde_json::Value }) } -#[cfg(feature = "diagnostics")] +#[cfg(perry_diagnostics)] pub(super) fn pause_budget_json( progress_kind: GcProgressKind, progress_budget: GcPauseBudget, @@ -1419,7 +1419,7 @@ pub(super) fn pause_budget_json( }) } -#[cfg(feature = "diagnostics")] +#[cfg(perry_diagnostics)] pub(super) fn pause_step_json(step: GcPauseStepTrace) -> serde_json::Value { let progress_budget = gc_progress_contract().budget_for(step.progress_kind); let within_soft_pause_target = progress_budget @@ -1447,7 +1447,7 @@ pub(super) fn pause_step_json(step: GcPauseStepTrace) -> serde_json::Value { }) } -#[cfg(feature = "diagnostics")] +#[cfg(perry_diagnostics)] pub(super) fn allocator_maintenance_json( trace: AllocatorMaintenanceTrace, progress_kind: GcProgressKind, @@ -1477,7 +1477,7 @@ pub(super) fn allocator_maintenance_json( }) } -#[cfg(feature = "diagnostics")] +#[cfg(perry_diagnostics)] fn default_malloc_trim_maintenance(progress_kind: GcProgressKind) -> AllocatorMaintenanceEvent { if progress_kind.is_budgeted() { return AllocatorMaintenanceEvent { @@ -1573,7 +1573,7 @@ pub(super) fn malloc_object_count() -> usize { MALLOC_STATE.with(|s| s.borrow().objects.len()) } -#[cfg(feature = "diagnostics")] +#[cfg(perry_diagnostics)] pub(super) fn malloc_kind_telemetry_row( obj_type: u8, counters: MallocKindTelemetry, @@ -1594,7 +1594,7 @@ pub(super) fn malloc_kind_telemetry_row( }) } -#[cfg(feature = "diagnostics")] +#[cfg(perry_diagnostics)] pub(super) fn root_source_slot_json(stats: RootSourceSlotTraceStats) -> serde_json::Value { serde_json::json!({ "registered_scanners": stats.registered_scanners, @@ -1605,7 +1605,7 @@ pub(super) fn root_source_slot_json(stats: RootSourceSlotTraceStats) -> serde_js }) } -#[cfg(feature = "diagnostics")] +#[cfg(perry_diagnostics)] pub(super) fn root_sources_json(stats: RootSourcesTraceStats) -> serde_json::Value { serde_json::json!({ "compiled_shadow": root_source_slot_json(stats.compiled_shadow), @@ -1634,7 +1634,7 @@ pub(super) fn root_sources_json(stats: RootSourcesTraceStats) -> serde_json::Val }) } -#[cfg(feature = "diagnostics")] +#[cfg(perry_diagnostics)] pub(super) fn malloc_kind_telemetry_json_from_snapshot( snapshot: [MallocKindTelemetry; MALLOC_KIND_BUCKET_COUNT], ) -> serde_json::Value { @@ -1653,13 +1653,13 @@ pub(super) fn malloc_kind_telemetry_json_from_snapshot( serde_json::Value::Array(rows) } -#[cfg(feature = "diagnostics")] +#[cfg(perry_diagnostics)] pub(super) fn take_malloc_kind_telemetry_json() -> serde_json::Value { let snapshot = MALLOC_STATE.with(|s| s.borrow_mut().take_kind_telemetry()); malloc_kind_telemetry_json_from_snapshot(snapshot) } -#[cfg(feature = "diagnostics")] +#[cfg(perry_diagnostics)] pub(super) fn arena_region_json(region: crate::arena::ArenaRegionTelemetry) -> serde_json::Value { serde_json::json!({ "in_use_bytes": region.in_use_bytes, @@ -1668,7 +1668,7 @@ pub(super) fn arena_region_json(region: crate::arena::ArenaRegionTelemetry) -> s }) } -#[cfg(feature = "diagnostics")] +#[cfg(perry_diagnostics)] pub(super) fn arena_snapshot_json( snapshot: crate::arena::ArenaTelemetrySnapshot, ) -> serde_json::Value { @@ -1685,7 +1685,7 @@ pub(super) fn arena_snapshot_json( }) } -#[cfg(feature = "diagnostics")] +#[cfg(perry_diagnostics)] pub(super) fn steps_json(before: GcStepSnapshot, after: GcStepSnapshot) -> serde_json::Value { serde_json::json!({ "arena_step_bytes": { diff --git a/crates/perry-runtime/src/gc/trace.rs b/crates/perry-runtime/src/gc/trace.rs index f968db8bcf..1849e09e93 100644 --- a/crates/perry-runtime/src/gc/trace.rs +++ b/crates/perry-runtime/src/gc/trace.rs @@ -14,7 +14,7 @@ crate::perry_thread_local! { /// unclassifiable in that synthetic state, so the differential verifier /// must stand down for the rest of the thread's test. Read only by the /// verifier, which `gc-instruments` serves. - #[cfg_attr(not(feature = "gc-instruments"), allow(dead_code))] + #[cfg_attr(not(perry_gc_instruments), allow(dead_code))] pub(crate) static CLASSIFIER_VERIFY_SUPPRESSED: std::cell::Cell = const { std::cell::Cell::new(false) }; } @@ -89,11 +89,11 @@ pub(super) fn classifier_valid_object_start(addr: usize) -> bool { /// #6179: differential-verification mode for the page-metadata classifier. /// A `gc-instruments` knob (#10572): constant `false` without the feature. pub(super) fn classifier_verify_enabled() -> bool { - #[cfg(not(feature = "gc-instruments"))] + #[cfg(not(perry_gc_instruments))] return false; // The cached process-wide switch first: this runs on every census hit, and // the suppression flag is a thread-local (#10182). - #[cfg(feature = "gc-instruments")] + #[cfg(perry_gc_instruments)] { static CACHED: std::sync::OnceLock = std::sync::OnceLock::new(); *crate::once_init::get_or_init(&CACHED, || { diff --git a/crates/perry-runtime/src/gc/types.rs b/crates/perry-runtime/src/gc/types.rs index 378a0893de..954ccdc232 100644 --- a/crates/perry-runtime/src/gc/types.rs +++ b/crates/perry-runtime/src/gc/types.rs @@ -1022,7 +1022,7 @@ pub(crate) unsafe fn gc_type_finalize_unmarked_payload(obj_type: u8, user_ptr: * } } -#[cfg(feature = "diagnostics")] +#[cfg(perry_diagnostics)] #[inline] pub(super) fn gc_type_name(obj_type: u8) -> &'static str { gc_type_info(obj_type).map_or("unknown", |info| info.name) diff --git a/crates/perry-runtime/src/hot_diag.rs b/crates/perry-runtime/src/hot_diag.rs index 3aba7c05f9..dada7ee65b 100644 --- a/crates/perry-runtime/src/hot_diag.rs +++ b/crates/perry-runtime/src/hot_diag.rs @@ -24,7 +24,7 @@ // Without `hot-diag` the probes below have no armed caller. `allow` rather // than a cascade of cfgs keeps them compiled, so they cannot rot unbuilt. -#![cfg_attr(not(feature = "hot-diag"), allow(dead_code, unused_imports))] +#![cfg_attr(not(perry_hot_diag), allow(dead_code, unused_imports))] use std::cell::RefCell; use std::collections::HashMap; @@ -53,7 +53,7 @@ pub(crate) fn sink_from_env(name: &str) -> Option { /// `HOT_DIAG_KNOBS` in the compiler's `optimized_libs/freshness.rs` (pinned by /// `hot_diag_knobs_match_the_runtime`). // Read only by the feature-off startup check below. -#[cfg_attr(feature = "hot-diag", allow(dead_code))] +#[cfg_attr(perry_hot_diag, allow(dead_code))] pub(crate) const HOT_DIAG_KNOBS: &[&str] = &[ "PERRY_REGEX_DIAG", "PERRY_IC_DIAG", @@ -65,7 +65,7 @@ pub(crate) const HOT_DIAG_KNOBS: &[&str] = &[ /// Startup check for binaries built without the instruments: a knob that /// would arm one (same spelling rules as [`sink_from_env`]) aborts. -#[cfg(not(feature = "hot-diag"))] +#[cfg(not(perry_hot_diag))] pub(crate) fn refuse_knobs_without_hot_diag() { if let Some(knob) = HOT_DIAG_KNOBS .iter() @@ -76,7 +76,7 @@ pub(crate) fn refuse_knobs_without_hot_diag() { } } -#[cfg(not(feature = "hot-diag"))] +#[cfg(not(perry_hot_diag))] #[cold] #[inline(never)] fn hot_diag_unavailable(knob: &str) -> ! { @@ -147,9 +147,9 @@ fn regex_sink() -> &'static Option { /// Is the regex instrument armed? One relaxed load once initialised. #[inline] pub fn regex_on() -> bool { - #[cfg(not(feature = "hot-diag"))] + #[cfg(not(perry_hot_diag))] return false; - #[cfg(feature = "hot-diag")] + #[cfg(perry_hot_diag)] { if REGEX_SINK.get().is_none() { regex_sink(); @@ -566,9 +566,9 @@ pub fn layout_on() -> bool { if let Some(armed) = LAYOUT_TEST_ARMED.with(std::cell::Cell::get) { return armed; } - #[cfg(not(feature = "hot-diag"))] + #[cfg(not(perry_hot_diag))] return false; - #[cfg(feature = "hot-diag")] + #[cfg(perry_hot_diag)] { if LAYOUT_SINK.get().is_none() { layout_sink(); @@ -826,9 +826,9 @@ impl Drop for LayoutDiagTestGuard { /// Is the IC-miss instrument armed? One relaxed load once initialised. #[inline] pub fn ic_on() -> bool { - #[cfg(not(feature = "hot-diag"))] + #[cfg(not(perry_hot_diag))] return false; - #[cfg(feature = "hot-diag")] + #[cfg(perry_hot_diag)] { if IC_SINK.get().is_none() { ic_sink(); @@ -1249,9 +1249,9 @@ fn enum_sink() -> &'static Option { /// Is the enumeration/concat execution counter armed? #[inline] pub fn enum_on() -> bool { - #[cfg(not(feature = "hot-diag"))] + #[cfg(not(perry_hot_diag))] return false; - #[cfg(feature = "hot-diag")] + #[cfg(perry_hot_diag)] { if ENUM_SINK.get().is_none() { enum_sink(); @@ -1421,9 +1421,9 @@ fn buffer_sink() -> &'static Option { /// Is the buffer-probe instrument armed? One relaxed load once initialised. #[inline] pub fn buffer_on() -> bool { - #[cfg(not(feature = "hot-diag"))] + #[cfg(not(perry_hot_diag))] return false; - #[cfg(feature = "hot-diag")] + #[cfg(perry_hot_diag)] { if BUFFER_SINK.get().is_none() { buffer_sink(); diff --git a/crates/perry-runtime/src/hot_diag/receiver_repr.rs b/crates/perry-runtime/src/hot_diag/receiver_repr.rs index 9a22ac5f0e..b70b5bb7f3 100644 --- a/crates/perry-runtime/src/hot_diag/receiver_repr.rs +++ b/crates/perry-runtime/src/hot_diag/receiver_repr.rs @@ -5,7 +5,7 @@ //! relaxed load and enters none of the range, registry, or ownership probes. // See the parent module: without `hot-diag` nothing arms these probes. -#![cfg_attr(not(feature = "hot-diag"), allow(dead_code, unused_imports))] +#![cfg_attr(not(perry_hot_diag), allow(dead_code, unused_imports))] use super::{sink_from_env, write_sink, Sink}; use std::fmt::Write as _; @@ -122,9 +122,9 @@ pub fn receiver_repr_on() -> bool { if TEST_FORCE_ON.load(Ordering::Relaxed) { return true; } - #[cfg(not(feature = "hot-diag"))] + #[cfg(not(perry_hot_diag))] return false; - #[cfg(feature = "hot-diag")] + #[cfg(perry_hot_diag)] { if RECEIVER_REPR_SINK.get().is_none() { receiver_repr_sink(); diff --git a/crates/perry-runtime/src/node_v8.rs b/crates/perry-runtime/src/node_v8.rs index 3c651066de..7ae5f12862 100644 --- a/crates/perry-runtime/src/node_v8.rs +++ b/crates/perry-runtime/src/node_v8.rs @@ -360,11 +360,11 @@ pub extern "C" fn js_v8_cached_data_version_tag() -> f64 { #[no_mangle] pub extern "C" fn js_v8_get_heap_snapshot(options: f64) -> f64 { validate_heap_snapshot_options(options); - #[cfg(feature = "diagnostics")] + #[cfg(perry_diagnostics)] let json = crate::gc::gc_build_v8_heap_snapshot_json(); // OFF stub: the compiler enables `diagnostics` whenever a program uses the // v8 heap-snapshot APIs, so this branch is unreachable in practice. - #[cfg(not(feature = "diagnostics"))] + #[cfg(not(perry_diagnostics))] let json = String::from("{}"); snapshot_readable_stream(&json) } @@ -383,11 +383,11 @@ pub extern "C" fn js_v8_write_heap_snapshot(filename: f64, options: f64) -> f64 } }; validate_heap_snapshot_options(options); - #[cfg(feature = "diagnostics")] + #[cfg(perry_diagnostics)] let json = crate::gc::gc_build_v8_heap_snapshot_json(); // OFF stub: unreachable in practice (compiler enables `diagnostics` when a // program uses the v8 heap-snapshot APIs). - #[cfg(not(feature = "diagnostics"))] + #[cfg(not(perry_diagnostics))] let json = String::from("{}"); match std::fs::write(&path, json.as_bytes()) { Ok(()) => string_value(&path), diff --git a/crates/perry-runtime/src/process/report.rs b/crates/perry-runtime/src/process/report.rs index e0e995b59c..61c402211f 100644 --- a/crates/perry-runtime/src/process/report.rs +++ b/crates/perry-runtime/src/process/report.rs @@ -65,9 +65,9 @@ extern "C" fn process_report_function_write_report( .unwrap_or_else(process_report_default_filename); // OFF stub: unreachable in practice (the compiler enables `diagnostics` // whenever a program references `process.report`). - #[cfg(feature = "diagnostics")] + #[cfg(perry_diagnostics)] let report_json = process_report_json_string("API", Some(&filename)); - #[cfg(not(feature = "diagnostics"))] + #[cfg(not(perry_diagnostics))] let report_json = String::from("{}"); if let Err(err) = std::fs::write(&filename, report_json) { crate::fs::validate::throw_type_error_with_code( @@ -342,7 +342,7 @@ fn process_report_unix_time_ms() -> f64 { .unwrap_or(0.0) } -#[cfg(feature = "diagnostics")] +#[cfg(perry_diagnostics)] fn process_report_json_string(trigger: &str, filename: Option<&str>) -> String { let args: Vec = super::process_args_lossy().collect(); let command_line = if args.is_empty() { diff --git a/crates/perry-runtime/src/typed_feedback.rs b/crates/perry-runtime/src/typed_feedback.rs index 112131e90b..a593ebe1dc 100644 --- a/crates/perry-runtime/src/typed_feedback.rs +++ b/crates/perry-runtime/src/typed_feedback.rs @@ -5,9 +5,9 @@ //! has actually seen at runtime. use std::collections::{BTreeMap, HashMap}; -#[cfg(any(feature = "diagnostics", test))] +#[cfg(any(perry_diagnostics, test))] use std::sync::atomic::AtomicBool; -#[cfg(any(feature = "diagnostics", test))] +#[cfg(any(perry_diagnostics, test))] use std::sync::atomic::Ordering; use std::sync::{LazyLock, Mutex}; @@ -22,7 +22,7 @@ const POLYMORPHIC_CAP: usize = 4; static REGISTRY: LazyLock> = LazyLock::new(|| Mutex::new(TypedFeedbackRegistry::default())); -#[cfg(any(feature = "diagnostics", test))] +#[cfg(any(perry_diagnostics, test))] static TRACE_DUMPED: AtomicBool = AtomicBool::new(false); #[cfg(not(test))] @@ -420,7 +420,7 @@ fn registry() -> crate::gc::GcRootRegistryGuard<'static, TypedFeedbackRegistry> /// already compile-gated. Now it produces nothing, which is the same amount of /// information and looks far more like success. The trace dump uses this to say /// so out loud rather than writing an empty file. -#[cfg(feature = "diagnostics")] +#[cfg(perry_diagnostics)] pub(crate) fn no_sites_were_instrumented() -> bool { registry().sites.is_empty() } @@ -1169,7 +1169,7 @@ pub use guards::{ #[path = "typed_feedback/trace.rs"] mod trace; pub use trace::typed_feedback_snapshot; -#[cfg(feature = "diagnostics")] +#[cfg(perry_diagnostics)] pub use trace::{js_typed_feedback_maybe_dump_trace, typed_feedback_trace_json}; fn hash_bytes(bytes: &[u8]) -> u64 { diff --git a/crates/perry-runtime/src/typed_feedback/trace.rs b/crates/perry-runtime/src/typed_feedback/trace.rs index c6b5019ce2..1b3baba6f3 100644 --- a/crates/perry-runtime/src/typed_feedback/trace.rs +++ b/crates/perry-runtime/src/typed_feedback/trace.rs @@ -1,4 +1,4 @@ -#[cfg(feature = "diagnostics")] +#[cfg(perry_diagnostics)] use std::path::{Path, PathBuf}; use super::*; @@ -226,7 +226,7 @@ pub fn typed_feedback_snapshot() -> TypedFeedbackSnapshot { snapshot } -#[cfg(feature = "diagnostics")] +#[cfg(perry_diagnostics)] pub fn typed_feedback_trace_json() -> serde_json::Value { let snapshot = typed_feedback_snapshot(); serde_json::json!({ @@ -285,7 +285,7 @@ pub fn typed_feedback_trace_json() -> serde_json::Value { }) } -#[cfg(feature = "diagnostics")] +#[cfg(perry_diagnostics)] fn typed_feedback_trace_path_from_env() -> Option { let value = std::env::var("PERRY_TYPED_FEEDBACK_TRACE").ok()?; if value.is_empty() || value == "0" { @@ -298,7 +298,7 @@ fn typed_feedback_trace_path_from_env() -> Option { } } -#[cfg(feature = "diagnostics")] +#[cfg(perry_diagnostics)] fn ensure_parent_dir(path: &Path) -> std::io::Result<()> { if let Some(parent) = path.parent() { if !parent.as_os_str().is_empty() { @@ -317,7 +317,7 @@ fn ensure_parent_dir(path: &Path) -> std::io::Result<()> { // binaries). #[no_mangle] pub extern "C" fn js_typed_feedback_maybe_dump_trace() { - #[cfg(feature = "diagnostics")] + #[cfg(perry_diagnostics)] { let Some(path) = typed_feedback_trace_path_from_env() else { return; diff --git a/crates/perry/src/commands/compile/optimized_libs/driver.rs b/crates/perry/src/commands/compile/optimized_libs/driver.rs index 13d5512541..c49193068a 100644 --- a/crates/perry/src/commands/compile/optimized_libs/driver.rs +++ b/crates/perry/src/commands/compile/optimized_libs/driver.rs @@ -567,6 +567,23 @@ pub(crate) fn build_optimized_libs( let workspace_root = match find_perry_workspace_root() { Some(p) => p, None => { + // Release packages build their runtime archives with + // PERRY_RELEASE_STRIP_INSTRUMENTS=1, so a prebuilt link cannot + // honor an instrument request, and the knob then refuses at + // startup with advice to recompile. Say here why recompiling + // alone does not help. Not verbose-gated: it answers a request + // the user just made. + if matches!(format, OutputFormat::Text) + && (super::freshness::gc_instruments_requested() + || super::freshness::hot_diag_requested()) + { + eprintln!( + " note: GC instruments / hot-path diagnostics were requested, but \ + Perry workspace source was not found, so the prebuilt runtime is \ + linked; release builds of it carry no instruments. Set \ + PERRY_WORKSPACE_ROOT to a perry source checkout to build them in." + ); + } if super::prebuilt_core::eligible(ctx, cli_features) { if let Some(runtime) = super::super::library_search::find_runtime_core_library(target) diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index e7e19b371b..c48526b2fb 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -386,7 +386,7 @@ "file": "crates/perry-runtime/src/gc/census.rs", "name": "PASS1_MARKED", "verdict": "non_moving_snapshot", - "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete \u2192 sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs \u2014 it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase \u2014 after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged \u2014 `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` \u2014 and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` \u2192 `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only \u2014 no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound \u2014 the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses \u2014 no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects \u2014 and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module \u2014 all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete \u2192 sweep-entry window of a synchronous full \u2014 where PASS1_MARKED is populated and consumed within one `run_to_completion` \u2014 is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize \u2014 INSIDE the window \u2014 the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-13 for #10182's full-collection throughput follow-up, which touched `gc/cycle.rs` in one hunk, INSIDE the window: the `RememberedSetRebuild` subphase of a synchronous full now first asks `verify::full_remembered_rebuild_provably_empty` and, when it holds, installs `OldToYoungRememberedRebuildState::provably_empty()` (an empty sticky set, no walk) instead of the require-marked rebuild. The predicate reads `arena_block_snapshots()` (one `Vec` through the global allocator), the census's per-block reached/pre-marked facts and the malloc registry's length; the constructor bumps a `Cell` counter and prints one line under `PERRY_GC_DIAG`. None of it allocates a GC object, relocates anything, collects, or runs a JS callback, and both census boundaries stay where they were. Re-audited 2026-09-14 for #10182's pacing-full work, which touched `gc/cycle.rs`, `gc/mod.rs` and `gc/policy.rs`. `gc/cycle.rs`: `GcCycleState::new_full` no longer calls `materialize_all_promoted_page_runs`; that call ran in the constructor, before the census and far before `census_pass1_if_armed`, and removing it adds nothing to the window. `gc/mod.rs`: one `mod promoted_cohort;` declaration. `gc/policy.rs`: (a) `credit_promoted_bytes_to_old_baseline` also credits a `Cell` cohort counter (it runs after a copying minor completes); (b) `finish_full_old_reclaim_baseline` also records the verified old live bytes and resets that counter (Publish, after `step_sweep` consumed the snapshot); (c) `gc_safepoint_moving_minor` arms and disarms the promotion-census record around its nursery minor and calls `run_promoted_cohort_full_if_due`, which starts a synchronous full through the same `gc_collect_full_mark_sweep_with_trigger` entry and reads byte counters before and after it. All of it runs before a cycle starts or after it completes; none of it runs between mark completion and sweep entry, allocates a GC object, relocates anything, or calls into JS. The census the promoted-cohort full may adopt from the promotion walk is built in `BuildValidPointerSet`, before either boundary. Both boundaries are unchanged. Re-audited 2026-09-14 for the #10182 dead-stack scrub in `gc/cycle.rs`: `step_build_valid_pointer_set` now calls `scrub_dead_stack_below`, which zeroes a local array in its own frame (dead stack below the caller), right after the census finishes \u2014 in `BuildValidPointerSet`, before the root scan and far before `census_pass1_if_armed`. It writes no heap memory, allocates nothing, relocates nothing and calls no JS; both boundaries are unchanged. Re-audited 2026-09-14 for #10241 (cohort survival), which touched `gc/cycle.rs` and `gc/policy.rs`. `gc/cycle.rs`: one call, `promoted_cohort::survival::check_minor_view_at_full_sweep_start()`, in `step_sweep` immediately AFTER `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS, i.e. outside the window. It is a no-op unless a promoted-cohort full armed its survival probe; when armed it walks the old page index over the preceding minor's dirty pages (`old_arena_walk_objects_on_pages`, Rust-allocator Vecs), reads GC headers' mark flags and the slots of unmarked ones, and records one enum. It writes no heap memory, allocates no GC object, relocates nothing and calls no JS. `gc/policy.rs`: `run_promoted_cohort_full_if_due` arms the probe before `gc_collect_full_mark_sweep_with_trigger` and takes it after the full returns (feeding `note_full_measured_promotion_survival` and one diagnostic line); both run before a cycle starts or after it completes. Both boundaries are unchanged. Re-audited 2026-09-14 for #10241's in-place-only cohort: `gc/policy.rs` drops the `promoted_cohort::note_promoted` call from `credit_promoted_bytes_to_old_baseline` (the copying minor now calls `promoted_cohort::note_minor_promotion` itself, after the credit). Both run at the end of a copying minor, outside any full cycle; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-14 for the parse-boundary side-allocation band (medium-parse pacing), which touched `gc/policy.rs`. Three hunks: (a) a `Cell` thread-local (`GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES`, a byte COUNT, no pointer) plus three pure predicates over it and `external_side_live_bytes()`; (b) that predicate added as a third disjunct of `tiny_parse_generational_collection_due`, which is read only from the three JSON.parse mutator-side boundaries (`gc_bump_malloc_trigger_inner`, `gc_collect_pending_suppressed_parse_slow`, `gc_schedule_parse_boundary_collection_if_pressure`), none of them reachable from `step_mark_propagation` or `step_sweep`; and (c) one extra `Cell` store in `note_collection_finished_arena_occupancy` plus two extra reads in the `PERRY_GC_DIAG` tiny-parse line. `note_collection_finished_arena_occupancy` runs from `publish_reclaim_outcome` in the Publish subphase, i.e. AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local, exactly as #9831's store on the same line does. Nothing added allocates a GC object, relocates anything, or runs a JS callback, and neither census boundary moved. Re-audited 2026-09-14 for the drained-bytes counterweight to that band, which touched `gc/policy.rs` again. Four hunks: a second `Cell` thread-local (`GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL`, a byte COUNT); one increment of it inside `gc_note_external_side_free`; a pure read (`external_side_old_reclaim_pressure_bytes`) substituted for `external_side_live_bytes()` at the four old-reclaim pressure sites; and one `Cell` store at the top of `finish_full_old_reclaim_baseline`. None of it can run between the census boundaries. `gc_note_external_side_free` is also reached by mutator-side tape materialization, regex scratch teardown, native-addon adjustments and buffer replacement. Its added operation is only a saturating increment of a scalar Cell, with no GC allocation, relocation, collection or JS callback, so this wider caller set does not invalidate the census window. `finish_full_old_reclaim_baseline` runs from `publish_reclaim_outcome` in the Publish subphase, the same place #9831's store already sits. The pressure reads happen at trigger decisions, before a cycle starts. No allocation, relocation, collection or JS callback is added to the mark-complete -> sweep-entry window, and neither boundary moved. Re-audited 2026-09-15 for turnloop P0, which touched `gc/mod.rs` with one added call: `crate::event_pump::shutdown_wait_driver()` inside `js_gc_release_current_thread_collection_side_allocations`, the process-exit funnel. That function runs once no more JavaScript can run on the thread, never from inside a collection cycle; the added call drops the thread's turnloop wait loop (closing its kqueue/epoll descriptor) and may print a diagnostic line. It allocates no GC object, relocates nothing, starts no collection and runs no JS callback. The census boundaries and the mark-complete -> sweep-entry window are untouched.. Re-audited 2026-09-16 for the copying minor's per-parent weak-holder fact: `gc/mod.rs` gains exactly one line, `mod copying_parent_facts;`, a module declaration. The module it declares holds `weak_holder_fact` (a read of the parent's `obj_type`/`class_id` via `weakref::is_weak_holder_header`) and the copying minor's `visit_slot_with_parent`, moved verbatim out of `gc/copying.rs` for the 2000-line lint. Both run only inside a COPYING MINOR, which skips both census boundaries (`census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` are synchronous-full only). Nothing was added to any full-cycle phase, and the declaration itself executes no code. Neither boundary moved and the synchronous mark-complete to sweep-entry window gains no allocation, relocation, collection or JS callback. Re-audited 2026-09-18 for the #10532 follow-up argument-list rooting fix, which touched `gc/mod.rs`. The only change there is `mod collection_points;` plus a `pub(crate) use collection_points::collection_point;` re-export (and, under `#[cfg(test)]`, `arm_collection_point`). `collection_point` is an inline no-op outside `cfg(test)`; under test it only runs a copying minor when called from ordinary MUTATOR code (`proxy.rs`'s `Reflect.apply` rebind path and `registry.rs`'s rest-array bundler), never from inside `step_mark_propagation` or `step_sweep`. Neither `census_pass1_if_armed` nor `census_take_if_armed_at_full_sweep_start` is reachable from it, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-18 (same PR, round 2) for the added `arm_collection_point_after` re-export in `gc/mod.rs`: another pure re-export line, same as the `collection_point`/`arm_collection_point` one already covered above. `arm_collection_point_after` only changes test-only arming state in `collection_points.rs` (which named site fires and on which hit); it still runs no mark/sweep control flow. Re-audited 2026-09-19 for #10735 (require.main threading): gc/mod.rs gains exactly one line, `reg_scanner!(crate::module_require::scan_cjs_main_module_root_mut);`, registering the new CJS_MAIN_MODULE thread-local's mutable-root scanner beside the existing `scan_module_path_roots_mut` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it runs during root scanning, before mark propagation completes, and does not execute between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start`. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-20 for #10834 (inherited-property read cache). `gc/mod.rs` gains exactly one line: `reg_scanner!(crate::object::inherited_read_cache::scan_inherited_read_cache_roots_mut);` in `gc_init()`. A scanner registration adds a root SOURCE for the mutable-root walks. The walk runs inside `RootScanCycleState::step_current_subphase`, i.e. entirely within the RootScan phase: `step_root_scan` only sets `self.phase = GcCyclePhase::MarkPropagation` once that loop reports done (`gc/cycle.rs:958-961`), and `census_pass1_if_armed()` fires at the END of `step_mark_propagation` (`gc/cycle.rs:982`). The scanner therefore runs strictly BEFORE the window opens and can never execute between the boundaries. Its body is a bounded walk of a fixed 512-entry thread-local array calling `visit_tagged_usize_slot` / `visit_usize_slot`; it allocates nothing, relocates nothing and runs no JS callback. Same shape as #9769, #9976/#9977, #10054, #10055 and #10735, all previously cleared. The PR also adds an `INHERITED_READ_CACHE` entry to `DEAD_KEY_PRUNES` in `gc/dead_owner.rs` (not a pinned source). That registry is consumed by `IncrementalSweepState::with_dead_collection_finalize` at `gc/cycle.rs:1548`, which is AFTER `census_take_if_armed_at_full_sweep_start` at `gc/cycle.rs:1505` has already `take()`n the snapshot out of the thread-local -- the same argument that cleared #9845's `collect_dead_registered_regexps_post_trace`. The prune reads addresses and zeroes entries; no GC allocation, relocation or callback. Both additions sit outside the window, on opposite sides of it. Neither boundary moved and the synchronous mark-complete to sweep-entry interval is unchanged. Re-audited 2026-09-22 for #10399 (per-thread module init), which touched `gc/mod.rs`. Two hunks, both init-time: a new free function `raise_default_thread_stack_floor()` and one call to it at the top of `js_gc_init`, before `enter_current_thread_image`'s successor statements. The function reads `RUST_MIN_STACK` from the environment and, only when it is unset, sets it to 32 MiB so a thread spawned against a multi-megabyte static TLS block still has usable stack (glibc carves static TLS out of the thread's stack mapping). It touches no heap object, allocates no GC object, relocates nothing and runs no JS callback. `js_gc_init` is the first runtime call of a compiled `main`, so it runs once before any cycle exists, and it is not reachable from `step_mark_propagation` or `step_sweep`. Same shape as the 2026-09-11 startup-memory-profile re-audit, which cleared the pre-main allocator-policy constructor in the same function. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-23 (size/runtime-decouple, #11135) after the binary-size branch touched `gc/census.rs`, `gc/mod.rs` and `gc/policy.rs`. census.rs: `census_pass1_if_armed` / `census_take_if_armed_at_full_sweep_start` keep their bodies verbatim, moved into `_impl` functions compiled only with the new `gc-instruments` feature (without it both are empty and `census_path()` is `None`, so nothing is ever armed); the take still empties PASS1_MARKED before `take_census`. gc/mod.rs: `gc_init` gains a startup env check that aborts when an instrument knob is set without the feature, before any cycle exists. gc/policy.rs: env-knob OnceLock caches now initialize through `crate::once_init::get_or_init` (same closures, same values). No mark/sweep control flow between the two census boundaries changed; the window is unchanged. Re-audited for Fetch handle reclamation: cycle.rs only redirects the incomplete-cycle Drop cancellation hook to also cancel the Fetch trace. The full-trace finish hook removes native records and cached slots without allocating GC objects or invoking JS; it cannot relocate the census addresses before sweep entry. Re-audited 2026-09-22 for #10928 (one proportional old-reclaim rule), which touched `gc/policy.rs`. Six hunks. (a) Two new thread-locals, `GC_OLD_RECLAIM_PRE_IN_USE_BYTES` (`Cell`) and `GC_OLD_RECLAIM_BACKOFF_SHIFT` (`Cell`): both are byte/shift COUNTS, neither holds a pointer. (b) `gc_old_reclaim_growth_band_bytes` gains a `Cell` read and a left shift -- pure arithmetic over byte counts. (c) `old_reclaim_pressure_due` loses the #7937 absolute first-crossing arm, splits its pure form out as `old_reclaim_pressure_due_inner`, and calls `note_old_reclaim_cycle_started()` when the answer is true. That predicate is read at TRIGGER decisions only -- the allocation-point `gc_check_trigger` and `gc_budgeted_due_trigger` at safepoints -- i.e. before a cycle starts, never between the boundaries; an allocation inside the window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before this code, the same argument the 2026-09-14 due-check fast-path re-audit made for the same function. Even if it did run there it would be sound: `note_old_reclaim_cycle_started` stores one scalar `Cell` from `pacing_arena_in_use_bytes()` (a read of `arena_live_allocated_bytes`), which allocates no GC object, relocates nothing and runs no JS callback -- the window's contract. (d) `update_old_reclaim_backoff` is called only from `finish_full_old_reclaim_baseline`, which runs from `publish_reclaim_outcome` in the Publish subphase, AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local -- exactly where #9831's store and the medium-parse pacing store already sit. (e) `gc_old_reclaim_debt_bytes` drops the absolute arm it mirrored; it remains pure arithmetic read at debt/trigger decisions. (f) `#[cfg(test)]` seams, absent from production builds. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback between them; the change alters only WHEN a collection is scheduled, never what runs inside one. Neither boundary moved and the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-24 for #10960 (growth-aware old-reclaim backoff), which touched `gc/policy.rs` again. One new thread-local, `GC_OLD_RECLAIM_LAST_POST_IN_USE_BYTES` (`Cell`), a byte COUNT that holds no pointer. It is written only by `update_old_reclaim_backoff`, which runs from `finish_full_old_reclaim_baseline` in the Publish subphase, after `step_sweep` has already taken the snapshot out of the thread-local; the change there is pure integer arithmetic deciding whether to widen the band. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback, and neither window boundary moved. Re-audited 2026-09-24 after the class-capture environment added one `reg_scanner!` registration (`scan_class_env_roots_mut`, visiting each guarded class environment's owner class object) to `gc/mod.rs`: a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-25 after the inherited-access lane touched `gc/mod.rs`: the change is one `reg_scanner!` registration for `object::chain_store::scan_chain_store_roots_mut`, a root scanner that visits store-site chain verdicts (one interned key pointer each) during root scanning. It runs at the start of a cycle, never between mark completion and sweep entry, relocates nothing and runs no JS callback. The synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-25 for #10698 (allocation-point trigger watermark), which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains `pub(crate) mod trigger_watermark;` and a `pub(crate) use` re-export -- no control flow. In `gc/policy.rs`: (a) `gc_check_trigger` becomes an out-of-line wrapper over an inlined fast path that reads the watermark cell, the malloc registry's length through an unguarded shared borrow and the inline allocator's offset, then either returns or runs the previous body unchanged (`gc_check_trigger_evaluate`). An allocation inside the window reaches it with `GC_FLAG_IN_ALLOC` set: the fast path returns without acting, which is the outcome of the `GC_FLAG_IN_ALLOC` early return it would otherwise reach, and the slow path still takes that early return. (b) The due-trigger evaluation also returns a watermark -- integer arithmetic over values it already read -- published only on the no-action return past every entry guard. (c) The ladder's thresholds and flags are retyped `Cell` -> `TriggerInput`, whose writes add one store retiring the watermark; heap-generation advances and `Arena::set_current` retire it too. (d) `#[cfg(test)]` seams. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback; `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-25 for #10498 (class-accessor cache), which adds one `reg_scanner!` registration (`scan_class_accessor_cache_roots_mut`, marking and rewriting the cache's key strings) to `gc/mod.rs`: again a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-26 for #10572: `gc/mod.rs` gains one `#[cfg(not(feature = \"hot-diag\"))]` call to `hot_diag::refuse_knobs_without_hot_diag()` in `gc_init`, a startup-only knob check that runs before any cycle and alters no mark/sweep control flow; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-27 for #11416: `gc/mod.rs` loses the `reg_scanner!` registration for `class_accessor_cache::scan_class_accessor_cache_roots_mut` (the cache is deleted); removing a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-27 for the method-calls lane, which adds one `reg_scanner!` registration (`scan_method_site_roots_mut`, marking and rewriting the method closures inherited method-site entries hold) to `gc/mod.rs`: a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-28 for #11605 (link-time runtime feature installs): `gc/mod.rs` registers the interpreter root scanner through its always-present forwarder (`dyn_eval_hooks::scan_dyn_eval_roots_mut`, which calls the real scanner through a slot the `dyn-eval` install fills) instead of a feature-gated direct registration, and `js_gc_init` ends by running the program\u2019s feature installer, which only stores fn pointers into `Hook` slots at startup, before any user code or collection. Neither alters mark/sweep control flow or runs inside the mark-complete to sweep-entry window.", + "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete \u2192 sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs \u2014 it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase \u2014 after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged \u2014 `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` \u2014 and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` \u2192 `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only \u2014 no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound \u2014 the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses \u2014 no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects \u2014 and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module \u2014 all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete \u2192 sweep-entry window of a synchronous full \u2014 where PASS1_MARKED is populated and consumed within one `run_to_completion` \u2014 is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize \u2014 INSIDE the window \u2014 the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-13 for #10182's full-collection throughput follow-up, which touched `gc/cycle.rs` in one hunk, INSIDE the window: the `RememberedSetRebuild` subphase of a synchronous full now first asks `verify::full_remembered_rebuild_provably_empty` and, when it holds, installs `OldToYoungRememberedRebuildState::provably_empty()` (an empty sticky set, no walk) instead of the require-marked rebuild. The predicate reads `arena_block_snapshots()` (one `Vec` through the global allocator), the census's per-block reached/pre-marked facts and the malloc registry's length; the constructor bumps a `Cell` counter and prints one line under `PERRY_GC_DIAG`. None of it allocates a GC object, relocates anything, collects, or runs a JS callback, and both census boundaries stay where they were. Re-audited 2026-09-14 for #10182's pacing-full work, which touched `gc/cycle.rs`, `gc/mod.rs` and `gc/policy.rs`. `gc/cycle.rs`: `GcCycleState::new_full` no longer calls `materialize_all_promoted_page_runs`; that call ran in the constructor, before the census and far before `census_pass1_if_armed`, and removing it adds nothing to the window. `gc/mod.rs`: one `mod promoted_cohort;` declaration. `gc/policy.rs`: (a) `credit_promoted_bytes_to_old_baseline` also credits a `Cell` cohort counter (it runs after a copying minor completes); (b) `finish_full_old_reclaim_baseline` also records the verified old live bytes and resets that counter (Publish, after `step_sweep` consumed the snapshot); (c) `gc_safepoint_moving_minor` arms and disarms the promotion-census record around its nursery minor and calls `run_promoted_cohort_full_if_due`, which starts a synchronous full through the same `gc_collect_full_mark_sweep_with_trigger` entry and reads byte counters before and after it. All of it runs before a cycle starts or after it completes; none of it runs between mark completion and sweep entry, allocates a GC object, relocates anything, or calls into JS. The census the promoted-cohort full may adopt from the promotion walk is built in `BuildValidPointerSet`, before either boundary. Both boundaries are unchanged. Re-audited 2026-09-14 for the #10182 dead-stack scrub in `gc/cycle.rs`: `step_build_valid_pointer_set` now calls `scrub_dead_stack_below`, which zeroes a local array in its own frame (dead stack below the caller), right after the census finishes \u2014 in `BuildValidPointerSet`, before the root scan and far before `census_pass1_if_armed`. It writes no heap memory, allocates nothing, relocates nothing and calls no JS; both boundaries are unchanged. Re-audited 2026-09-14 for #10241 (cohort survival), which touched `gc/cycle.rs` and `gc/policy.rs`. `gc/cycle.rs`: one call, `promoted_cohort::survival::check_minor_view_at_full_sweep_start()`, in `step_sweep` immediately AFTER `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS, i.e. outside the window. It is a no-op unless a promoted-cohort full armed its survival probe; when armed it walks the old page index over the preceding minor's dirty pages (`old_arena_walk_objects_on_pages`, Rust-allocator Vecs), reads GC headers' mark flags and the slots of unmarked ones, and records one enum. It writes no heap memory, allocates no GC object, relocates nothing and calls no JS. `gc/policy.rs`: `run_promoted_cohort_full_if_due` arms the probe before `gc_collect_full_mark_sweep_with_trigger` and takes it after the full returns (feeding `note_full_measured_promotion_survival` and one diagnostic line); both run before a cycle starts or after it completes. Both boundaries are unchanged. Re-audited 2026-09-14 for #10241's in-place-only cohort: `gc/policy.rs` drops the `promoted_cohort::note_promoted` call from `credit_promoted_bytes_to_old_baseline` (the copying minor now calls `promoted_cohort::note_minor_promotion` itself, after the credit). Both run at the end of a copying minor, outside any full cycle; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-14 for the parse-boundary side-allocation band (medium-parse pacing), which touched `gc/policy.rs`. Three hunks: (a) a `Cell` thread-local (`GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES`, a byte COUNT, no pointer) plus three pure predicates over it and `external_side_live_bytes()`; (b) that predicate added as a third disjunct of `tiny_parse_generational_collection_due`, which is read only from the three JSON.parse mutator-side boundaries (`gc_bump_malloc_trigger_inner`, `gc_collect_pending_suppressed_parse_slow`, `gc_schedule_parse_boundary_collection_if_pressure`), none of them reachable from `step_mark_propagation` or `step_sweep`; and (c) one extra `Cell` store in `note_collection_finished_arena_occupancy` plus two extra reads in the `PERRY_GC_DIAG` tiny-parse line. `note_collection_finished_arena_occupancy` runs from `publish_reclaim_outcome` in the Publish subphase, i.e. AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local, exactly as #9831's store on the same line does. Nothing added allocates a GC object, relocates anything, or runs a JS callback, and neither census boundary moved. Re-audited 2026-09-14 for the drained-bytes counterweight to that band, which touched `gc/policy.rs` again. Four hunks: a second `Cell` thread-local (`GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL`, a byte COUNT); one increment of it inside `gc_note_external_side_free`; a pure read (`external_side_old_reclaim_pressure_bytes`) substituted for `external_side_live_bytes()` at the four old-reclaim pressure sites; and one `Cell` store at the top of `finish_full_old_reclaim_baseline`. None of it can run between the census boundaries. `gc_note_external_side_free` is also reached by mutator-side tape materialization, regex scratch teardown, native-addon adjustments and buffer replacement. Its added operation is only a saturating increment of a scalar Cell, with no GC allocation, relocation, collection or JS callback, so this wider caller set does not invalidate the census window. `finish_full_old_reclaim_baseline` runs from `publish_reclaim_outcome` in the Publish subphase, the same place #9831's store already sits. The pressure reads happen at trigger decisions, before a cycle starts. No allocation, relocation, collection or JS callback is added to the mark-complete -> sweep-entry window, and neither boundary moved. Re-audited 2026-09-15 for turnloop P0, which touched `gc/mod.rs` with one added call: `crate::event_pump::shutdown_wait_driver()` inside `js_gc_release_current_thread_collection_side_allocations`, the process-exit funnel. That function runs once no more JavaScript can run on the thread, never from inside a collection cycle; the added call drops the thread's turnloop wait loop (closing its kqueue/epoll descriptor) and may print a diagnostic line. It allocates no GC object, relocates nothing, starts no collection and runs no JS callback. The census boundaries and the mark-complete -> sweep-entry window are untouched.. Re-audited 2026-09-16 for the copying minor's per-parent weak-holder fact: `gc/mod.rs` gains exactly one line, `mod copying_parent_facts;`, a module declaration. The module it declares holds `weak_holder_fact` (a read of the parent's `obj_type`/`class_id` via `weakref::is_weak_holder_header`) and the copying minor's `visit_slot_with_parent`, moved verbatim out of `gc/copying.rs` for the 2000-line lint. Both run only inside a COPYING MINOR, which skips both census boundaries (`census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` are synchronous-full only). Nothing was added to any full-cycle phase, and the declaration itself executes no code. Neither boundary moved and the synchronous mark-complete to sweep-entry window gains no allocation, relocation, collection or JS callback. Re-audited 2026-09-18 for the #10532 follow-up argument-list rooting fix, which touched `gc/mod.rs`. The only change there is `mod collection_points;` plus a `pub(crate) use collection_points::collection_point;` re-export (and, under `#[cfg(test)]`, `arm_collection_point`). `collection_point` is an inline no-op outside `cfg(test)`; under test it only runs a copying minor when called from ordinary MUTATOR code (`proxy.rs`'s `Reflect.apply` rebind path and `registry.rs`'s rest-array bundler), never from inside `step_mark_propagation` or `step_sweep`. Neither `census_pass1_if_armed` nor `census_take_if_armed_at_full_sweep_start` is reachable from it, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-18 (same PR, round 2) for the added `arm_collection_point_after` re-export in `gc/mod.rs`: another pure re-export line, same as the `collection_point`/`arm_collection_point` one already covered above. `arm_collection_point_after` only changes test-only arming state in `collection_points.rs` (which named site fires and on which hit); it still runs no mark/sweep control flow. Re-audited 2026-09-19 for #10735 (require.main threading): gc/mod.rs gains exactly one line, `reg_scanner!(crate::module_require::scan_cjs_main_module_root_mut);`, registering the new CJS_MAIN_MODULE thread-local's mutable-root scanner beside the existing `scan_module_path_roots_mut` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it runs during root scanning, before mark propagation completes, and does not execute between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start`. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-20 for #10834 (inherited-property read cache). `gc/mod.rs` gains exactly one line: `reg_scanner!(crate::object::inherited_read_cache::scan_inherited_read_cache_roots_mut);` in `gc_init()`. A scanner registration adds a root SOURCE for the mutable-root walks. The walk runs inside `RootScanCycleState::step_current_subphase`, i.e. entirely within the RootScan phase: `step_root_scan` only sets `self.phase = GcCyclePhase::MarkPropagation` once that loop reports done (`gc/cycle.rs:958-961`), and `census_pass1_if_armed()` fires at the END of `step_mark_propagation` (`gc/cycle.rs:982`). The scanner therefore runs strictly BEFORE the window opens and can never execute between the boundaries. Its body is a bounded walk of a fixed 512-entry thread-local array calling `visit_tagged_usize_slot` / `visit_usize_slot`; it allocates nothing, relocates nothing and runs no JS callback. Same shape as #9769, #9976/#9977, #10054, #10055 and #10735, all previously cleared. The PR also adds an `INHERITED_READ_CACHE` entry to `DEAD_KEY_PRUNES` in `gc/dead_owner.rs` (not a pinned source). That registry is consumed by `IncrementalSweepState::with_dead_collection_finalize` at `gc/cycle.rs:1548`, which is AFTER `census_take_if_armed_at_full_sweep_start` at `gc/cycle.rs:1505` has already `take()`n the snapshot out of the thread-local -- the same argument that cleared #9845's `collect_dead_registered_regexps_post_trace`. The prune reads addresses and zeroes entries; no GC allocation, relocation or callback. Both additions sit outside the window, on opposite sides of it. Neither boundary moved and the synchronous mark-complete to sweep-entry interval is unchanged. Re-audited 2026-09-22 for #10399 (per-thread module init), which touched `gc/mod.rs`. Two hunks, both init-time: a new free function `raise_default_thread_stack_floor()` and one call to it at the top of `js_gc_init`, before `enter_current_thread_image`'s successor statements. The function reads `RUST_MIN_STACK` from the environment and, only when it is unset, sets it to 32 MiB so a thread spawned against a multi-megabyte static TLS block still has usable stack (glibc carves static TLS out of the thread's stack mapping). It touches no heap object, allocates no GC object, relocates nothing and runs no JS callback. `js_gc_init` is the first runtime call of a compiled `main`, so it runs once before any cycle exists, and it is not reachable from `step_mark_propagation` or `step_sweep`. Same shape as the 2026-09-11 startup-memory-profile re-audit, which cleared the pre-main allocator-policy constructor in the same function. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-23 (size/runtime-decouple, #11135) after the binary-size branch touched `gc/census.rs`, `gc/mod.rs` and `gc/policy.rs`. census.rs: `census_pass1_if_armed` / `census_take_if_armed_at_full_sweep_start` keep their bodies verbatim, moved into `_impl` functions compiled only with the new `gc-instruments` feature (without it both are empty and `census_path()` is `None`, so nothing is ever armed); the take still empties PASS1_MARKED before `take_census`. gc/mod.rs: `gc_init` gains a startup env check that aborts when an instrument knob is set without the feature, before any cycle exists. gc/policy.rs: env-knob OnceLock caches now initialize through `crate::once_init::get_or_init` (same closures, same values). No mark/sweep control flow between the two census boundaries changed; the window is unchanged. Re-audited for Fetch handle reclamation: cycle.rs only redirects the incomplete-cycle Drop cancellation hook to also cancel the Fetch trace. The full-trace finish hook removes native records and cached slots without allocating GC objects or invoking JS; it cannot relocate the census addresses before sweep entry. Re-audited 2026-09-22 for #10928 (one proportional old-reclaim rule), which touched `gc/policy.rs`. Six hunks. (a) Two new thread-locals, `GC_OLD_RECLAIM_PRE_IN_USE_BYTES` (`Cell`) and `GC_OLD_RECLAIM_BACKOFF_SHIFT` (`Cell`): both are byte/shift COUNTS, neither holds a pointer. (b) `gc_old_reclaim_growth_band_bytes` gains a `Cell` read and a left shift -- pure arithmetic over byte counts. (c) `old_reclaim_pressure_due` loses the #7937 absolute first-crossing arm, splits its pure form out as `old_reclaim_pressure_due_inner`, and calls `note_old_reclaim_cycle_started()` when the answer is true. That predicate is read at TRIGGER decisions only -- the allocation-point `gc_check_trigger` and `gc_budgeted_due_trigger` at safepoints -- i.e. before a cycle starts, never between the boundaries; an allocation inside the window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before this code, the same argument the 2026-09-14 due-check fast-path re-audit made for the same function. Even if it did run there it would be sound: `note_old_reclaim_cycle_started` stores one scalar `Cell` from `pacing_arena_in_use_bytes()` (a read of `arena_live_allocated_bytes`), which allocates no GC object, relocates nothing and runs no JS callback -- the window's contract. (d) `update_old_reclaim_backoff` is called only from `finish_full_old_reclaim_baseline`, which runs from `publish_reclaim_outcome` in the Publish subphase, AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local -- exactly where #9831's store and the medium-parse pacing store already sit. (e) `gc_old_reclaim_debt_bytes` drops the absolute arm it mirrored; it remains pure arithmetic read at debt/trigger decisions. (f) `#[cfg(test)]` seams, absent from production builds. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback between them; the change alters only WHEN a collection is scheduled, never what runs inside one. Neither boundary moved and the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-24 for #10960 (growth-aware old-reclaim backoff), which touched `gc/policy.rs` again. One new thread-local, `GC_OLD_RECLAIM_LAST_POST_IN_USE_BYTES` (`Cell`), a byte COUNT that holds no pointer. It is written only by `update_old_reclaim_backoff`, which runs from `finish_full_old_reclaim_baseline` in the Publish subphase, after `step_sweep` has already taken the snapshot out of the thread-local; the change there is pure integer arithmetic deciding whether to widen the band. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback, and neither window boundary moved. Re-audited 2026-09-24 after the class-capture environment added one `reg_scanner!` registration (`scan_class_env_roots_mut`, visiting each guarded class environment's owner class object) to `gc/mod.rs`: a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-25 after the inherited-access lane touched `gc/mod.rs`: the change is one `reg_scanner!` registration for `object::chain_store::scan_chain_store_roots_mut`, a root scanner that visits store-site chain verdicts (one interned key pointer each) during root scanning. It runs at the start of a cycle, never between mark completion and sweep entry, relocates nothing and runs no JS callback. The synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-25 for #10698 (allocation-point trigger watermark), which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains `pub(crate) mod trigger_watermark;` and a `pub(crate) use` re-export -- no control flow. In `gc/policy.rs`: (a) `gc_check_trigger` becomes an out-of-line wrapper over an inlined fast path that reads the watermark cell, the malloc registry's length through an unguarded shared borrow and the inline allocator's offset, then either returns or runs the previous body unchanged (`gc_check_trigger_evaluate`). An allocation inside the window reaches it with `GC_FLAG_IN_ALLOC` set: the fast path returns without acting, which is the outcome of the `GC_FLAG_IN_ALLOC` early return it would otherwise reach, and the slow path still takes that early return. (b) The due-trigger evaluation also returns a watermark -- integer arithmetic over values it already read -- published only on the no-action return past every entry guard. (c) The ladder's thresholds and flags are retyped `Cell` -> `TriggerInput`, whose writes add one store retiring the watermark; heap-generation advances and `Arena::set_current` retire it too. (d) `#[cfg(test)]` seams. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback; `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-25 for #10498 (class-accessor cache), which adds one `reg_scanner!` registration (`scan_class_accessor_cache_roots_mut`, marking and rewriting the cache's key strings) to `gc/mod.rs`: again a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-26 for #10572: `gc/mod.rs` gains one `#[cfg(not(feature = \"hot-diag\"))]` call to `hot_diag::refuse_knobs_without_hot_diag()` in `gc_init`, a startup-only knob check that runs before any cycle and alters no mark/sweep control flow; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-27 for #11416: `gc/mod.rs` loses the `reg_scanner!` registration for `class_accessor_cache::scan_class_accessor_cache_roots_mut` (the cache is deleted); removing a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-27 for the method-calls lane, which adds one `reg_scanner!` registration (`scan_method_site_roots_mut`, marking and rewriting the method closures inherited method-site entries hold) to `gc/mod.rs`: a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-28 for #11605 (link-time runtime feature installs): `gc/mod.rs` registers the interpreter root scanner through its always-present forwarder (`dyn_eval_hooks::scan_dyn_eval_roots_mut`, which calls the real scanner through a slot the `dyn-eval` install fills) instead of a feature-gated direct registration, and `js_gc_init` ends by running the program\u2019s feature installer, which only stores fn pointers into `Hook` slots at startup, before any user code or collection. Neither alters mark/sweep control flow or runs inside the mark-complete to sweep-entry window. Re-audited 2026-09-28 for the release-runtime instrument strip: every changed line in `gc/census.rs`, `gc/cycle.rs`, `gc/mod.rs` and `gc/policy.rs` renames a `feature = \"diagnostics\" | \"gc-instruments\" | \"hot-diag\"` gate to the build-script cfg `perry_diagnostics` / `perry_gc_instruments` / `perry_hot_diag`, which `perry-runtime/build.rs` sets exactly when the feature is on unless PERRY_RELEASE_STRIP_INSTRUMENTS=1. With the cfg set the compiled code is unchanged; with it unset (release packages) the census that fills this snapshot is not compiled, so the window never opens. No mark/sweep control flow changes.", "window": { "start": { "file": "crates/perry-runtime/src/gc/census.rs", @@ -401,10 +401,10 @@ "function": "run_to_completion" }, "sources": { - "crates/perry-runtime/src/gc/census.rs": "25601f25ac70aa998f8cb5c1939d11e7c43a96235d4edf39a78e261b68709471", - "crates/perry-runtime/src/gc/cycle.rs": "4744196ba5e9c5ac40912154cf5b45b4a618d81ddc776ab1095fbc585f27c878", - "crates/perry-runtime/src/gc/mod.rs": "7152d3897fe23c32012d51218a613ca993cc40c7042d2735bf1c1a501b63c19e", - "crates/perry-runtime/src/gc/policy.rs": "7e2304ec822d26877df4eaf7aefb55c485c66425b8946b56a9b271a0abcc635b", + "crates/perry-runtime/src/gc/census.rs": "a3a01475e07a59538b0d31f367db685d8909b38eaac222351ef11c0eec378bc9", + "crates/perry-runtime/src/gc/cycle.rs": "b4ed24a21098d8b3e0e830b41b5d6c68439c537808493940142a6932cfb2a18b", + "crates/perry-runtime/src/gc/mod.rs": "6732b25a9acb93d8183964f5155901ece9f91afeb3987695bc13edce038f1e04", + "crates/perry-runtime/src/gc/policy.rs": "06156fa80feb755fdc72e155d3139c44cc3753c0c0d548ef84627a79fc5d0937", "crates/perry-runtime/src/gc/progress.rs": "a5ad3971bbe4047229ca57325234780daa85921dbc778e1c08dff4ad07ccfb96" } } From cb875328df89be7614c0e7344e2f073f502222ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 28 Sep 2026 12:31:09 +0000 Subject: [PATCH 06/13] changelog: key the release-runtime instrument strip fragment to PR 11629 --- ...-no-instruments.md => 11629-release-runtime-no-instruments.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{release-runtime-no-instruments.md => 11629-release-runtime-no-instruments.md} (100%) diff --git a/changelog.d/release-runtime-no-instruments.md b/changelog.d/11629-release-runtime-no-instruments.md similarity index 100% rename from changelog.d/release-runtime-no-instruments.md rename to changelog.d/11629-release-runtime-no-instruments.md From 590f04abac43d37b7a0720abc8549ace8d1b9d8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 28 Sep 2026 01:43:32 +0000 Subject: [PATCH 07/13] perf(size): install optional runtime features from the link step so prebuilt links drop the rest Follow-up to the stdlib change. An installed perry links the prebuilt full-feature libperry_runtime.a, and always-live runtime code named optional subsystems directly: globalThis population (eval -> the interpreter and the parser, Intl members, Temporal), the generic operators and property / instanceof / construct paths (Temporal, Intl subclassing), the native-module member lookup (bun YAML/TOML/semver/JSONL), Date/Number/BigInt toLocale* with options (ICU), Date time-zone offsets (IANA db), the RegExp matchAll iterator (regex engine), URL host canonicalization and IDNA, and the interpreter's GC/exception integration. Each now goes through a feature_hooks::Hook slot filled by a js_runtime_install_ entry point; an empty slot answers exactly what the #[cfg(not(feature))] branch answered, and always-live tables keep their shape (the savepoint field, prune entry and root scanner stay and forward). The generated installer object also registers a runtime installer, run by js_gc_init before user code: the program's runtime features on a prebuilt link, js_runtime_install_compiled otherwise. stdlib_installs.rs gains the runtime trigger table with drift tests; perry-stdlib reuses the runtime Hook. Installed-mode (release recipe): net+fetch+TLS backend 19.45 -> 15.05 MB, runtime-only fs program 13.72 -> 8.76 MB. Auto-optimized links unchanged. perry-runtime suite 4706/0/12 on base and branch. --- changelog.d/runtime-link-time-features.md | 1 + crates/perry-codegen/src/stubs.rs | 65 +++--- .../perry-runtime/src/builtins/arithmetic.rs | 3 +- .../perry-runtime/src/builtins/formatting.rs | 8 +- .../perry-runtime/src/bun_compat/cli_utils.rs | 14 +- .../src/bun_compat/cli_utils_hooks.rs | 62 ++++++ .../src/bun_compat/cli_utils_stub.rs | 24 --- crates/perry-runtime/src/bun_compat/mod.rs | 9 + crates/perry-runtime/src/date.rs | 18 +- crates/perry-runtime/src/dyn_eval_hooks.rs | 87 ++++++++ .../perry-runtime/src/exception/savepoints.rs | 9 +- crates/perry-runtime/src/feature_hooks.rs | 186 ++++++++++++++++++ crates/perry-runtime/src/gc/dead_owner.rs | 7 +- crates/perry-runtime/src/gc/mod.rs | 8 +- crates/perry-runtime/src/gc/types.rs | 3 +- crates/perry-runtime/src/intl.rs | 25 ++- .../perry-runtime/src/intl/duration_format.rs | 3 +- crates/perry-runtime/src/intl/hooked.rs | 74 +++++++ crates/perry-runtime/src/json/stringify.rs | 6 +- crates/perry-runtime/src/lib.rs | 2 + crates/perry-runtime/src/module_require.rs | 5 +- .../src/module_require/data_import.rs | 9 +- .../src/object/class_constructors.rs | 13 +- .../src/object/date_proto_thunks.rs | 23 ++- .../perry-runtime/src/object/field_get_set.rs | 3 +- .../object/field_get_set/get_field_by_name.rs | 5 +- .../field_get_set/get_field_by_name_tail.rs | 7 +- .../perry-runtime/src/object/global_this.rs | 9 + .../src/object/global_this/builtin_thunks.rs | 25 ++- .../src/object/global_this/fetch_globals.rs | 66 ++++--- .../src/object/global_this/math_temporal.rs | 20 +- .../src/object/global_this/populate.rs | 9 +- .../src/object/instanceof/dynamic_dispatch.rs | 6 +- .../src/object/iterator_prototypes.rs | 6 +- crates/perry-runtime/src/object/mod.rs | 9 + .../src/object/native_call_method.rs | 5 +- .../native_call_method/collection_methods.rs | 10 +- .../object/native_call_method/object_proto.rs | 9 +- .../native_call_method/primitive_methods.rs | 3 +- .../src/object/object_ops/prototype.rs | 3 +- .../src/object/primitive_proto_thunks.rs | 22 +-- crates/perry-runtime/src/regex.rs | 37 ++++ crates/perry-runtime/src/symbol/iterator.rs | 3 +- crates/perry-runtime/src/temporal/hooked.rs | 137 +++++++++++++ crates/perry-runtime/src/temporal/mod.rs | 39 ++-- crates/perry-runtime/src/tls.rs | 9 +- crates/perry-runtime/src/url/mod.rs | 45 ++++- crates/perry-runtime/src/url/node_compat.rs | 8 +- crates/perry-runtime/src/url/url_class.rs | 14 +- crates/perry-runtime/src/value/dyn_index.rs | 1 - crates/perry-runtime/src/value/to_string.rs | 3 +- .../src/value/to_string_radix.rs | 3 +- .../perry-stdlib/src/common/feature_hooks.rs | 39 +--- .../src/commands/compile/optimized_libs.rs | 9 +- .../commands/compile/optimized_libs/driver.rs | 25 ++- .../src/commands/compile/run_pipeline.rs | 34 ++-- crates/perry/src/commands/stdlib_installs.rs | 115 ++++++++++- 57 files changed, 1090 insertions(+), 312 deletions(-) create mode 100644 changelog.d/runtime-link-time-features.md create mode 100644 crates/perry-runtime/src/bun_compat/cli_utils_hooks.rs create mode 100644 crates/perry-runtime/src/dyn_eval_hooks.rs create mode 100644 crates/perry-runtime/src/feature_hooks.rs create mode 100644 crates/perry-runtime/src/intl/hooked.rs create mode 100644 crates/perry-runtime/src/temporal/hooked.rs diff --git a/changelog.d/runtime-link-time-features.md b/changelog.d/runtime-link-time-features.md new file mode 100644 index 0000000000..08d2fc57e8 --- /dev/null +++ b/changelog.d/runtime-link-time-features.md @@ -0,0 +1 @@ +- **perf(size): the runtime's always-live hubs no longer pin optional runtime features into prebuilt links.** Follow-up to #11598 (which did this for perry-stdlib). An installed perry links the prebuilt full-feature `libperry_runtime.a`, and always-live runtime code named optional subsystems directly: `globalThis` population (`eval` → the script interpreter and the swc/perry-parser behind it, `Intl` namespace members, `Temporal`), the generic operators and property/`instanceof`/construct paths (Temporal's `==`/ToPrimitive/ToString/JSON/`valueOf` arms, Temporal and Intl subclassing), the native-module member lookup (`bun.YAML`/`TOML`/`semver`/`JSONL`), `Date.prototype.toLocale*String(locales, options)` (ICU date formatting), `Number`/`BigInt.prototype.toLocaleString(locales, options)`, Date time-zone offsets (the compiled IANA database), the RegExp `matchAll` iterator (the regex engine), URL host canonicalization and IDNA (`url`/`idna`), and the GC/exception integration of the script interpreter (root scanner, move hook, dead-owner prune, `try` savepoint). Each now goes through a `perry_runtime::feature_hooks::Hook` slot filled by a `js_runtime_install_` entry point (`dyn-eval`, `temporal`, `intl-namespace`, `intl-datetime`, `bun-cli-utils`, `regex-engine`, `url-engine`); an empty slot answers exactly what the `#[cfg(not(feature))]` branch answered, and every always-live table keeps its shape (the savepoint field, the prune entry and the root scanner stay registered and forward). The generated installer object from #11598 now also registers a runtime installer, which `js_gc_init` runs before any user code: the program's runtime features (from the same `auto_optimized_cross_features` analysis) on a prebuilt-archive link, `js_runtime_install_compiled` on an auto-optimized link or for programs with deferred dynamic code. `stdlib_installs.rs` gains the runtime trigger table, with tests recomputing it from `perry-runtime/Cargo.toml` and checking it against the defined install symbols. perry-stdlib now reuses the runtime's `Hook`. Installed-mode sizes (Linux x86_64, release recipe): the `node:net` + `fetch` + TLS backend 19.45 → 15.05 MB (24.10 MB before #11598), a runtime-only fs program 13.72 → 8.76 MB, `node:net` 16.18 → 11.23 MB, `node:sqlite` 15.96 → 11.00 MB, fetch 17.39 → 12.66 MB; auto-optimized links unchanged. Validation: perry-runtime's suite single-threaded 4706 passed / 0 failed / 12 ignored on base and branch; no new warnings across 10 runtime feature subsets; the Node builtin compatibility matrix and 259 stdlib + 108 runtime-feature `test-files/` programs identical between the #11598 and patched installed-mode packages (differences only in tests printing random bytes, `Date.now()` or per-build class names; four fixed-port `node:net` tests collided under the harness's parallelism and are identical run sequentially). Not changed: the GC/diagnostic instruments (`diagnostics`, `gc-instruments`, `hot-diag`) remain in the prebuilt archive — their probes sit on hot paths behind runtime env gates, so they are a packaging decision (build the prebuilt runtime without them) rather than a hook conversion. diff --git a/crates/perry-codegen/src/stubs.rs b/crates/perry-codegen/src/stubs.rs index 92eed80603..d173942c4f 100644 --- a/crates/perry-codegen/src/stubs.rs +++ b/crates/perry-codegen/src/stubs.rs @@ -150,42 +150,55 @@ pub fn generate_stub_object_full( compile_ll_to_object(&ll, triple.as_deref()) } -/// Generate the object that tells perry-stdlib which optional features this -/// program installs. +/// Generate the object that tells the runtime (and perry-stdlib, when linked) +/// which optional features this program installs. /// -/// It defines an internal `perry_stdlib_feature_installer()` calling each of -/// `install_symbols` (perry-stdlib `js_stdlib_install_*` entry points, all -/// `void()`), and a static constructor that hands that function to -/// `js_stdlib_register_feature_installer` — one atomic store, safe before -/// `main`. `js_stdlib_init_dispatch` runs the registered installer, so it runs -/// on whichever path initializes the stdlib (the entry prologue, or the lazy +/// For each half it defines an internal installer calling that half's install +/// entry points (`js_runtime_install_*` / `js_stdlib_install_*`, all +/// `void()`), and one static constructor that registers them through +/// `js_runtime_register_feature_installer` / `js_stdlib_register_feature_installer` +/// — atomic stores, safe before `main`. The runtime runs its installer at the +/// end of `js_gc_init`; `js_stdlib_init_dispatch` runs the stdlib one, on +/// whichever path initializes the stdlib (the entry prologue, or the lazy /// `ensure_pump_registered` path of a program codegen did not mark as needing /// the stdlib). Naming only the program's features is what lets the linker -/// drop every other optional subsystem from a prebuilt full-feature archive; -/// see perry-stdlib's `common::feature_hooks`. -pub fn generate_stdlib_installer_object( - install_symbols: &[String], +/// drop every other optional subsystem from the prebuilt full-feature archives; +/// see perry-runtime `feature_hooks` and perry-stdlib `common::feature_hooks`. +pub fn generate_feature_installer_object( + runtime_installs: &[String], + stdlib_installs: Option<&[String]>, target: Option<&str>, ) -> Result> { let mut ll = String::new(); - ll.push_str("; Perry stdlib feature installer — generated by perry-codegen::stubs\n\n"); - ll.push_str("declare void @js_stdlib_register_feature_installer(ptr)\n"); - for symbol in install_symbols { - ll.push_str(&format!("declare void @{}()\n", symbol)); + ll.push_str("; Perry feature installer — generated by perry-codegen::stubs\n\n"); + let mut halves = vec![("runtime", runtime_installs)]; + if let Some(stdlib) = stdlib_installs { + halves.push(("stdlib", stdlib)); } - ll.push_str("\ndefine internal void @perry_stdlib_feature_installer() {\n"); - for symbol in install_symbols { - ll.push_str(&format!(" call void @{}()\n", symbol)); + let mut ctor = String::from("define internal void @perry_register_feature_installers() {\n"); + for (half, symbols) in &halves { + ll.push_str(&format!( + "declare void @js_{half}_register_feature_installer(ptr)\n" + )); + for symbol in symbols.iter() { + ll.push_str(&format!("declare void @{}()\n", symbol)); + } + ll.push_str(&format!( + "\ndefine internal void @perry_{half}_feature_installer() {{\n" + )); + for symbol in symbols.iter() { + ll.push_str(&format!(" call void @{}()\n", symbol)); + } + ll.push_str(" ret void\n}\n\n"); + ctor.push_str(&format!( + " call void @js_{half}_register_feature_installer(ptr @perry_{half}_feature_installer)\n" + )); } - ll.push_str(" ret void\n}\n\n"); - ll.push_str("define internal void @perry_stdlib_register_installer() {\n"); - ll.push_str( - " call void @js_stdlib_register_feature_installer(ptr @perry_stdlib_feature_installer)\n", - ); - ll.push_str(" ret void\n}\n\n"); + ctor.push_str(" ret void\n}\n\n"); + ll.push_str(&ctor); ll.push_str("@llvm.global_ctors = appending global [1 x { i32, ptr, ptr }] "); ll.push_str( - "[{ i32, ptr, ptr } { i32 65535, ptr @perry_stdlib_register_installer, ptr null }]\n", + "[{ i32, ptr, ptr } { i32 65535, ptr @perry_register_feature_installers, ptr null }]\n", ); let triple = target.and_then(crate::resolve_target_triple); compile_ll_to_object(&ll, triple.as_deref()) diff --git a/crates/perry-runtime/src/builtins/arithmetic.rs b/crates/perry-runtime/src/builtins/arithmetic.rs index 762c2f503d..5e3618f1f5 100644 --- a/crates/perry-runtime/src/builtins/arithmetic.rs +++ b/crates/perry-runtime/src/builtins/arithmetic.rs @@ -251,9 +251,8 @@ unsafe fn rel_to_primitive(value: f64) -> f64 { // `TypeError` for every `Temporal.*` value (the spec bans relational ordering // of Temporal values: `plainDate < plainDate` throws). Without this the cell // fell through to the `DefaultString` arm and compared ISO strings silently. - #[cfg(feature = "temporal")] if crate::temporal::is_temporal_value(value) { - return crate::temporal::dispatch::call_method(value, "valueOf", &[]); + return crate::temporal::hooked::call_method(value, "valueOf", &[]); } match crate::value::to_primitive_number(value) { crate::value::OrdinaryToPrimitiveOutcome::Primitive(p) => p, diff --git a/crates/perry-runtime/src/builtins/formatting.rs b/crates/perry-runtime/src/builtins/formatting.rs index 61dd2d08d6..8fff5cabad 100644 --- a/crates/perry-runtime/src/builtins/formatting.rs +++ b/crates/perry-runtime/src/builtins/formatting.rs @@ -523,11 +523,10 @@ unsafe fn date_inspect_string(value: f64) -> String { /// not a Temporal cell, so the caller's `else if let Some(..)` chain falls /// through. Cfg-paired: with the Temporal engine gated off no cell can exist, so /// the off twin is a constant `None` (and doesn't reference the gated module). -#[cfg(feature = "temporal")] fn temporal_inspect_arm(addr: usize, value: f64) -> Option { if crate::temporal::is_temporal_cell_addr(addr) { Some( - crate::temporal::temporal_inspect_string(value) + crate::temporal::hooked::inspect_string(value) .unwrap_or_else(|| "[object Object]".to_string()), ) } else { @@ -535,11 +534,6 @@ fn temporal_inspect_arm(addr: usize, value: f64) -> Option { } } -#[cfg(not(feature = "temporal"))] -fn temporal_inspect_arm(_addr: usize, _value: f64) -> Option { - None -} - /// Print multiple values from an array (console.log with spread support) /// Takes a pointer to an ArrayHeader containing f64 values /// Helper function to format a JSValue as a string (for spread arrays) diff --git a/crates/perry-runtime/src/bun_compat/cli_utils.rs b/crates/perry-runtime/src/bun_compat/cli_utils.rs index 1a4f1c9a48..c004784c39 100644 --- a/crates/perry-runtime/src/bun_compat/cli_utils.rs +++ b/crates/perry-runtime/src/bun_compat/cli_utils.rs @@ -131,7 +131,7 @@ extern "C" fn yaml_stringify_closure( yaml_stringify(input, replacer, space) } -pub fn js_bun_yaml() -> f64 { +pub(crate) fn js_bun_yaml_impl() -> f64 { namespace_object(&[ (b"parse", closure1("parse", yaml_parse_closure)), ( @@ -143,7 +143,7 @@ pub fn js_bun_yaml() -> f64 { extern "C" fn toml_parse_closure(_closure: *const ClosureHeader, input: f64) -> f64 { let source = value_to_string(input); - match toml_parse_result(&source) { + match toml_parse_result_impl(&source) { Ok(value) => value, Err(error) => crate::exception::js_throw(error), } @@ -151,7 +151,7 @@ extern "C" fn toml_parse_closure(_closure: *const ClosureHeader, input: f64) -> /// Shared by Bun.TOML.parse and the runtime import loader. Returning errors /// lets import() reject its promise without throwing through Rust I/O frames. -pub(crate) fn toml_parse_result(source: &str) -> Result { +pub(crate) fn toml_parse_result_impl(source: &str) -> Result { // `Value::from_str` in toml 1.x parses a single TOML value expression; // Bun.TOML.parse consumes a complete document, whose root is a table. let parsed = match toml::from_str::(source) { @@ -177,7 +177,7 @@ pub(crate) fn toml_parse_result(source: &str) -> Result { } } -pub fn js_bun_toml() -> f64 { +pub(crate) fn js_bun_toml_impl() -> f64 { namespace_object(&[(b"parse", closure1("parse", toml_parse_closure))]) } @@ -233,7 +233,7 @@ extern "C" fn semver_satisfies_closure( bool_value(satisfied) } -pub fn js_bun_semver() -> f64 { +pub(crate) fn js_bun_semver_impl() -> f64 { namespace_object(&[ (b"order", closure2("order", semver_order_closure, 2)), ( @@ -252,7 +252,7 @@ extern "C" fn jsonl_parse_chunk_closure( jsonl_parse_chunk(input, start, end) } -pub fn js_bun_jsonl() -> f64 { +pub(crate) fn js_bun_jsonl_impl() -> f64 { namespace_object(&[( b"parseChunk", closure3("parseChunk", jsonl_parse_chunk_closure, 1), @@ -574,7 +574,7 @@ extern "C" fn xxhash64_closure(_closure: *const ClosureHeader, input: f64, seed: crate::value::js_nanbox_bigint(bigint as i64) } -pub fn decorate_bun_hash(value: f64) -> f64 { +pub(crate) fn decorate_bun_hash_impl(value: f64) -> f64 { let scope = RuntimeHandleScope::new(); let hash = scope.root_nanbox_f64(value); let xxhash = scope.root_nanbox_f64(closure2("xxHash64", xxhash64_closure, 1)); diff --git a/crates/perry-runtime/src/bun_compat/cli_utils_hooks.rs b/crates/perry-runtime/src/bun_compat/cli_utils_hooks.rs new file mode 100644 index 0000000000..8231478526 --- /dev/null +++ b/crates/perry-runtime/src/bun_compat/cli_utils_hooks.rs @@ -0,0 +1,62 @@ +//! The Bun CLI utility surface the always-live runtime reaches. +//! +//! `native_module_property_by_name` answers `bun.YAML` / `.TOML` / `.semver` / +//! `.JSONL` and decorates every `bun.hash` value, and the data-URL/`import +//! with { type: "toml" }` loader parses TOML. Those callers are live in every +//! program, so they must not name `cli_utils` (YAML, TOML, semver, serde_json, +//! zstd) directly or the prebuilt full-feature runtime keeps it in all of them. +//! They call these forwarders instead, which reach `cli_utils` only through +//! slots its `bun-cli-utils` install fills (see `crate::feature_hooks`). An +//! empty slot answers exactly what `cli_utils_stub` did in a build without the +//! feature. + +use crate::feature_hooks::Hook; + +static YAML: Hook f64> = Hook::empty(); +static TOML: Hook f64> = Hook::empty(); +static SEMVER: Hook f64> = Hook::empty(); +static JSONL: Hook f64> = Hook::empty(); +static DECORATE_HASH: Hook f64> = Hook::empty(); +static TOML_PARSE: Hook Result> = Hook::empty(); + +fn undefined() -> f64 { + f64::from_bits(crate::value::TAG_UNDEFINED) +} + +pub fn js_bun_yaml() -> f64 { + YAML.get().map_or_else(undefined, |f| f()) +} + +pub fn js_bun_toml() -> f64 { + TOML.get().map_or_else(undefined, |f| f()) +} + +pub fn js_bun_semver() -> f64 { + SEMVER.get().map_or_else(undefined, |f| f()) +} + +pub fn js_bun_jsonl() -> f64 { + JSONL.get().map_or_else(undefined, |f| f()) +} + +pub fn decorate_bun_hash(value: f64) -> f64 { + DECORATE_HASH.get().map_or(value, |f| f(value)) +} + +/// `None` when TOML support is not installed: the loader then takes its +/// deferred-error path, as a build without `bun-cli-utils` always did. +pub(crate) fn toml_parse_result(source: &str) -> Option> { + TOML_PARSE.get().map(|f| f(source)) +} + +/// The `bun-cli-utils` install: fill every slot above from the real backends. +#[cfg(feature = "bun-cli-utils")] +pub(crate) fn install() { + use super::cli_utils; + YAML.set(cli_utils::js_bun_yaml_impl); + TOML.set(cli_utils::js_bun_toml_impl); + SEMVER.set(cli_utils::js_bun_semver_impl); + JSONL.set(cli_utils::js_bun_jsonl_impl); + DECORATE_HASH.set(cli_utils::decorate_bun_hash_impl); + TOML_PARSE.set(cli_utils::toml_parse_result_impl); +} diff --git a/crates/perry-runtime/src/bun_compat/cli_utils_stub.rs b/crates/perry-runtime/src/bun_compat/cli_utils_stub.rs index 8cebf7a233..68722dcccb 100644 --- a/crates/perry-runtime/src/bun_compat/cli_utils_stub.rs +++ b/crates/perry-runtime/src/bun_compat/cli_utils_stub.rs @@ -5,10 +5,6 @@ use crate::string::js_string_from_bytes; use crate::value::JSValue; -fn undefined() -> f64 { - f64::from_bits(crate::value::TAG_UNDEFINED) -} - fn feature_disabled() -> ! { let message = b"Bun CLI utilities are not enabled in this optimized Perry runtime"; let message = js_string_from_bytes(message.as_ptr(), message.len() as u32); @@ -16,26 +12,6 @@ fn feature_disabled() -> ! { crate::exception::js_throw(f64::from_bits(JSValue::pointer(error as *const u8).bits())) } -pub fn js_bun_yaml() -> f64 { - undefined() -} - -pub fn js_bun_toml() -> f64 { - undefined() -} - -pub fn js_bun_semver() -> f64 { - undefined() -} - -pub fn js_bun_jsonl() -> f64 { - undefined() -} - -pub fn decorate_bun_hash(value: f64) -> f64 { - value -} - #[no_mangle] pub extern "C" fn js_bun_deep_equals(_left: f64, _right: f64, _strict: f64) -> f64 { feature_disabled() diff --git a/crates/perry-runtime/src/bun_compat/mod.rs b/crates/perry-runtime/src/bun_compat/mod.rs index 1e21a017f2..0ff7a19962 100644 --- a/crates/perry-runtime/src/bun_compat/mod.rs +++ b/crates/perry-runtime/src/bun_compat/mod.rs @@ -23,6 +23,7 @@ mod ant; #[cfg(feature = "bun-cli-utils")] mod cli_utils; +mod cli_utils_hooks; #[cfg(not(feature = "bun-cli-utils"))] mod cli_utils_stub; mod glob; @@ -49,6 +50,14 @@ use std::io::{Read, Write}; pub use ant::{js_bun_ant_get_peer_pid, js_bun_ant_get_peer_uid, js_bun_ant_memory_pressure_level}; #[cfg(feature = "bun-cli-utils")] pub use cli_utils::*; +// The members the always-live runtime reaches go through slots the +// `bun-cli-utils` install fills; see `cli_utils_hooks`. +#[cfg(feature = "bun-cli-utils")] +pub(crate) use cli_utils_hooks::install as install_cli_utils; +pub(crate) use cli_utils_hooks::toml_parse_result; +pub use cli_utils_hooks::{ + decorate_bun_hash, js_bun_jsonl, js_bun_semver, js_bun_toml, js_bun_yaml, +}; #[cfg(not(feature = "bun-cli-utils"))] pub use cli_utils_stub::*; pub use glob::js_bun_glob_new; diff --git a/crates/perry-runtime/src/date.rs b/crates/perry-runtime/src/date.rs index 362d912561..c1bb863b5a 100644 --- a/crates/perry-runtime/src/date.rs +++ b/crates/perry-runtime/src/date.rs @@ -428,13 +428,24 @@ pub fn zone_offset_seconds(tz: &str, secs: i64) -> i64 { // the correct (DST-aware) offset for `secs`. return timestamp_to_local_components(secs).6; } - #[cfg(feature = "intl-datetime")] - if let Some(offset) = compiled_zone_offset_seconds(tz, secs) { + // The compiled IANA database is reached through the `intl-datetime` + // install (see `crate::feature_hooks`); without it non-host named zones + // keep the UTC fallback, as a build without the feature does. + if let Some(offset) = COMPILED_ZONE_OFFSET.get().and_then(|f| f(tz, secs)) { return offset; } 0 } +static COMPILED_ZONE_OFFSET: crate::feature_hooks::Hook Option> = + crate::feature_hooks::Hook::empty(); + +/// The `intl-datetime` install's Date half. +#[cfg(feature = "intl-datetime")] +pub(crate) fn install_compiled_tzdb() { + COMPILED_ZONE_OFFSET.set(compiled_zone_offset_seconds); +} + /// Get current timestamp in milliseconds (Date.now()) #[no_mangle] pub extern "C" fn js_date_now() -> f64 { @@ -1255,9 +1266,8 @@ pub extern "C" fn js_date_value_of(timestamp: f64) -> f64 { // hard `TypeError` (the spec bans implicit numeric coercion / ordering), so // route a Temporal receiver to its brand dispatch, which throws — rather // than returning the opaque cell as a pseudo-Date timestamp. - #[cfg(feature = "temporal")] if crate::temporal::is_temporal_value(timestamp) { - return crate::temporal::dispatch::call_method(timestamp, "valueOf", &[]); + return crate::temporal::hooked::call_method(timestamp, "valueOf", &[]); } if let Some((_, payload)) = crate::builtins::boxed_primitive_payload(timestamp) { return payload; diff --git a/crates/perry-runtime/src/dyn_eval_hooks.rs b/crates/perry-runtime/src/dyn_eval_hooks.rs new file mode 100644 index 0000000000..0939c5580d --- /dev/null +++ b/crates/perry-runtime/src/dyn_eval_hooks.rs @@ -0,0 +1,87 @@ +//! The script evaluator's (`crate::dyn_eval`) touch points in always-live code. +//! +//! The `Function` constructor, dynamic `import()` of a JavaScript `data:` URL, +//! the GC (a root scanner, the move hook, the dead-owner prune) and the +//! exception savepoints all reach the interpreter. Those callers are live in +//! every program, so they must not name `crate::dyn_eval` directly, or the +//! prebuilt full-feature runtime keeps the interpreter and the JS parser behind +//! it in every binary. They call these forwarders, which reach it only through +//! slots the `dyn-eval` install fills (see `crate::feature_hooks`). +//! +//! The install runs from `js_gc_init`, before any user code, so the +//! interpreter never runs with a slot empty. An empty slot answers what a build +//! without `dyn-eval` answers: no function, no scan, no move bookkeeping, an +//! idle savepoint. + +use crate::feature_hooks::Hook; + +static FUNCTION_FROM_STRINGS: Hook f64> = Hook::empty(); +static SCAN_ROOTS: Hook)> = Hook::empty(); +static OWNER_MOVED: Hook = Hook::empty(); +static PRUNE_DEAD_OWNERS: Hook bool)> = Hook::empty(); +static PRUNE_DEAD_OWNERS_YOUNG: Hook bool)> = Hook::empty(); +static INTERP_SAVEPOINT: Hook u64> = Hook::empty(); +static INTERP_RESTORE: Hook = Hook::empty(); +static DATA_URL_IMPORT: Hook Option> = Hook::empty(); + +/// `new Function(...params, body)` from strings; `None` without the evaluator. +pub(crate) fn function_from_strings(args: &[String]) -> Option { + FUNCTION_FROM_STRINGS.get().map(|f| f(args)) +} + +/// Root scanner for the interpreter's rooted value stack (#6559). Registered +/// unconditionally in `gc_init`; scans only once the evaluator is installed. +pub fn scan_dyn_eval_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { + if let Some(scan) = SCAN_ROOTS.get() { + scan(visitor); + } +} + +pub(crate) fn function_owner_moved(old: usize, new: usize) { + if let Some(moved) = OWNER_MOVED.get() { + moved(old, new); + } +} + +pub(crate) fn prune_dead_function_owners(is_dead: &dyn Fn(usize) -> bool) { + if let Some(prune) = PRUNE_DEAD_OWNERS.get() { + prune(is_dead); + } +} + +pub(crate) fn prune_dead_function_owners_young(is_dead: &dyn Fn(usize) -> bool) { + if let Some(prune) = PRUNE_DEAD_OWNERS_YOUNG.get() { + prune(is_dead); + } +} + +/// The interpreter's `try` savepoint; `0` (the catch table's idle value) +/// without the evaluator. +pub(crate) fn interp_savepoint() -> u64 { + INTERP_SAVEPOINT.get().map_or(0, |f| f()) +} + +pub(crate) fn interp_restore(savepoint: u64) { + if let Some(restore) = INTERP_RESTORE.get() { + restore(savepoint); + } +} + +/// Dynamic `import()` of a JavaScript `data:` URL; `None` without the +/// evaluator, which leaves the loader's "cannot find module" path. +pub(crate) fn dynamic_import_data_url(specifier: &str) -> Option { + DATA_URL_IMPORT.get().and_then(|f| f(specifier)) +} + +/// The `dyn-eval` install. +#[cfg(feature = "dyn-eval")] +pub(crate) fn install() { + FUNCTION_FROM_STRINGS.set(crate::dyn_eval::dyn_function_from_strings); + SCAN_ROOTS.set(crate::dyn_eval::scan_dyn_eval_roots_mut); + OWNER_MOVED.set(crate::dyn_eval::function_owner_moved); + PRUNE_DEAD_OWNERS.set(crate::dyn_eval::prune_dead_function_owners); + PRUNE_DEAD_OWNERS_YOUNG.set(crate::dyn_eval::prune_dead_function_owners_young); + INTERP_SAVEPOINT.set(crate::dyn_eval::interp_savepoint); + INTERP_RESTORE.set(crate::dyn_eval::interp_restore); + DATA_URL_IMPORT.set(crate::module_require::dynamic_import_javascript_data_url); +} diff --git a/crates/perry-runtime/src/exception/savepoints.rs b/crates/perry-runtime/src/exception/savepoints.rs index a4f6a9e7c4..a6f4bf3b8a 100644 --- a/crates/perry-runtime/src/exception/savepoints.rs +++ b/crates/perry-runtime/src/exception/savepoints.rs @@ -36,7 +36,6 @@ pub(crate) mod catch_subsystem { // way, or a build without the feature fails `-D warnings` as dead code. #[cfg(feature = "regex-engine")] pub(crate) const REGEX_FACTORY: u32 = 1 << 9; - #[cfg(feature = "dyn-eval")] pub(crate) const DYN_EVAL: u32 = 1 << 10; pub(crate) const NAMESPACE_OVERRIDE: u32 = 1 << 11; } @@ -254,10 +253,12 @@ catch_savepoints! { restore: crate::regex::site_test::active_factory_stack_restore, latch: catch_subsystem::REGEX_FACTORY, idle: 0; // #6559: rooted interpreter values AND the packed call depth. - #[cfg(feature = "dyn-eval")] + // Always present: the capture/restore forward to the interpreter once + // `dyn-eval` is installed, and capture answers the idle value otherwise + // (see `crate::dyn_eval_hooks`). dyn_eval: u64, - capture: crate::dyn_eval::interp_savepoint, - restore: crate::dyn_eval::interp_restore, + capture: crate::dyn_eval_hooks::interp_savepoint, + restore: crate::dyn_eval_hooks::interp_restore, latch: catch_subsystem::DYN_EVAL, idle: 0; } diff --git a/crates/perry-runtime/src/feature_hooks.rs b/crates/perry-runtime/src/feature_hooks.rs new file mode 100644 index 0000000000..bfde1db5b5 --- /dev/null +++ b/crates/perry-runtime/src/feature_hooks.rs @@ -0,0 +1,186 @@ +//! Link-time feature installation for the runtime. +//! +//! The runtime's always-live hubs — the `globalThis` builder, the native-module +//! member lookup, the generic operators — must not name an optional subsystem +//! (the `eval` parser, Intl/ICU, Temporal, the Bun CLI utilities, …) directly. +//! A direct reference keeps that subsystem in every program linked against the +//! prebuilt full-feature `libperry_runtime.a`, which is what an installed perry +//! links when it has no workspace to rebuild from. +//! +//! Each optional feature instead fills [`Hook`] slots from its own +//! `js_runtime_install_` entry point, and a hub calls through the slot; +//! an empty slot takes the path the hub's `#[cfg(not(feature))]` branch takes +//! in a build without the feature. perry's link step generates an object whose +//! static constructor registers an installer through +//! [`js_runtime_register_feature_installer`]; [`crate::gc::js_gc_init`] runs it +//! before any user code. The installer names the program's runtime features on +//! a prebuilt-archive link and [`js_runtime_install_compiled`] otherwise (an +//! auto-optimized archive already holds exactly the needed features). +//! +//! perry-stdlib uses the same [`Hook`] for its own hubs +//! (`perry_stdlib::common::feature_hooks`). + +use std::marker::PhantomData; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// One function-pointer slot. `F` must be a plain `fn` pointer type. +pub struct Hook { + bits: AtomicUsize, + _f: PhantomData, +} + +impl Hook { + pub const fn empty() -> Self { + Self { + bits: AtomicUsize::new(0), + _f: PhantomData, + } + } + + #[inline] + pub fn set(&self, f: F) { + const { assert!(std::mem::size_of::() == std::mem::size_of::()) }; + // SAFETY: `F` is a fn pointer of pointer size (asserted above). + let bits: usize = unsafe { std::mem::transmute_copy(&f) }; + self.bits.store(bits, Ordering::Release); + } + + #[inline] + pub fn get(&self) -> Option { + #[cfg_attr(not(test), allow(unused_mut))] + let mut bits = self.bits.load(Ordering::Acquire); + // Unit tests reach hubs without the perry link step or `js_gc_init`, + // so an empty slot installs everything compiled (the pre-hook + // behavior) and is read again. Test builds only: a shipped archive + // must not reference `js_runtime_install_compiled` from here. + #[cfg(test)] + if bits == 0 { + install_compiled_for_tests(); + bits = self.bits.load(Ordering::Acquire); + } + if bits == 0 { + None + } else { + // SAFETY: only `set` stores non-zero bits, and it stores an `F`. + Some(unsafe { std::mem::transmute_copy(&bits) }) + } + } +} + +#[cfg(test)] +fn install_compiled_for_tests() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| js_runtime_install_compiled()); +} + +/// Run `$body` once per process, so installs are idempotent. +#[macro_export] +#[doc(hidden)] +macro_rules! perry_install_once { + ($body:block) => {{ + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| $body); + }}; +} + +/// The installer the program's generated object registered. +static FEATURE_INSTALLER: Hook = Hook::empty(); + +/// Called from a static constructor in the object perry's link step generates. +/// Only stores the pointer: it runs before `main`; installing happens from +/// [`crate::gc::js_gc_init`]. +#[no_mangle] +pub extern "C" fn js_runtime_register_feature_installer(installer: extern "C" fn()) { + FEATURE_INSTALLER.set(installer); +} + +/// Run the registered installer; called at the end of `js_gc_init`. +/// +/// With no registration nothing optional is installed. This must not name +/// [`js_runtime_install_compiled`] outside tests: `js_gc_init` is live in every +/// program, so a fallback reference here would pin every feature again. The +/// crate's own unit tests keep the pre-hook behavior. +pub(crate) fn run_feature_installer() { + if let Some(installer) = FEATURE_INSTALLER.get() { + installer(); + return; + } + #[cfg(test)] + js_runtime_install_compiled(); +} + +/// Install every optional runtime feature this archive was compiled with. +#[no_mangle] +pub extern "C" fn js_runtime_install_compiled() { + #[cfg(feature = "dyn-eval")] + js_runtime_install_dyn_eval(); + #[cfg(feature = "bun-cli-utils")] + js_runtime_install_bun_cli_utils(); + #[cfg(feature = "intl-namespace")] + js_runtime_install_intl_namespace(); + #[cfg(feature = "temporal")] + js_runtime_install_temporal(); + #[cfg(feature = "intl-datetime")] + js_runtime_install_intl_datetime(); + #[cfg(feature = "regex-engine")] + js_runtime_install_regex_engine(); + #[cfg(feature = "url-engine")] + js_runtime_install_url_engine(); +} + +#[cfg(feature = "dyn-eval")] +#[no_mangle] +pub extern "C" fn js_runtime_install_dyn_eval() { + perry_install_once!({ + crate::object::install_dyn_eval(); + crate::dyn_eval_hooks::install(); + }); +} + +#[cfg(feature = "bun-cli-utils")] +#[no_mangle] +pub extern "C" fn js_runtime_install_bun_cli_utils() { + perry_install_once!({ + crate::bun_compat::install_cli_utils(); + }); +} + +#[cfg(feature = "intl-namespace")] +#[no_mangle] +pub extern "C" fn js_runtime_install_intl_namespace() { + perry_install_once!({ + crate::intl::install_intl_namespace_feature(); + }); +} + +#[cfg(feature = "temporal")] +#[no_mangle] +pub extern "C" fn js_runtime_install_temporal() { + perry_install_once!({ + crate::temporal::hooked::install(); + }); +} + +#[cfg(feature = "intl-datetime")] +#[no_mangle] +pub extern "C" fn js_runtime_install_intl_datetime() { + perry_install_once!({ + crate::date::install_compiled_tzdb(); + }); +} + +#[cfg(feature = "regex-engine")] +#[no_mangle] +pub extern "C" fn js_runtime_install_regex_engine() { + perry_install_once!({ + crate::regex::install_iterator_hooks(); + }); +} + +#[cfg(feature = "url-engine")] +#[no_mangle] +pub extern "C" fn js_runtime_install_url_engine() { + perry_install_once!({ + crate::url::install_engine(); + }); +} diff --git a/crates/perry-runtime/src/gc/dead_owner.rs b/crates/perry-runtime/src/gc/dead_owner.rs index 32e7d37c6e..eef5bcbfcd 100644 --- a/crates/perry-runtime/src/gc/dead_owner.rs +++ b/crates/perry-runtime/src/gc/dead_owner.rs @@ -464,12 +464,13 @@ pub(super) const DEAD_KEY_PRUNES: &[DeadKeyPrune] = &[ prune: crate::closure::prune_dead_closure_side_table_owners, young_prune: Some(crate::closure::prune_dead_closure_side_table_owners_young), }, - #[cfg(feature = "dyn-eval")] + // Always listed; the prunes forward to the interpreter once `dyn-eval` is + // installed (see `crate::dyn_eval_hooks`). DeadKeyPrune { table: "dyn_eval::LIFETIME.owners + FN_REGISTRY", owner: DeadKeyOwner::Closure, - prune: crate::dyn_eval::prune_dead_function_owners, - young_prune: Some(crate::dyn_eval::prune_dead_function_owners_young), + prune: crate::dyn_eval_hooks::prune_dead_function_owners, + young_prune: Some(crate::dyn_eval_hooks::prune_dead_function_owners_young), }, DeadKeyPrune { table: "BUILTIN_CLOSURE_LENGTH + BUILTIN_CLOSURE_NON_CONSTRUCTABLE", diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 757fa71150..f044890b0c 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -1230,8 +1230,9 @@ pub fn gc_init() { // temporaries, arguments of in-flight interpreted frames). Mark + // REWRITE — interpreter state must survive moving collections triggered // from inside interpreted code. - #[cfg(feature = "dyn-eval")] - reg_scanner!(crate::dyn_eval::scan_dyn_eval_roots_mut); + // Registered unconditionally; it scans once the `dyn-eval` install has + // connected the interpreter (see `crate::dyn_eval_hooks`). + reg_scanner!(crate::dyn_eval_hooks::scan_dyn_eval_roots_mut); reg_scanner!(crate::tls::scan_tls_roots_mut); reg_scanner!(crate::process::scan_process_finalization_roots_mut); reg_scanner!(crate::process::scan_process_module_loader_roots_mut); @@ -1397,6 +1398,9 @@ pub extern "C" fn js_gc_init() { crate::object::disable_class_field_inline_guard(); } gc_init(); + // Optional runtime features install from the program's generated + // installer (see `crate::feature_hooks`), before any user code runs. + crate::feature_hooks::run_feature_installer(); } /// Release external Map/Set/JSON-tape storage owned by the current thread. diff --git a/crates/perry-runtime/src/gc/types.rs b/crates/perry-runtime/src/gc/types.rs index 57940b8638..378a0893de 100644 --- a/crates/perry-runtime/src/gc/types.rs +++ b/crates/perry-runtime/src/gc/types.rs @@ -915,8 +915,7 @@ pub(crate) fn gc_type_after_payload_move(obj_type: u8, old_user: usize, new_user GcMoveHookKind::ClosureDynamicProps => { crate::closure::closure_dynamic_props_owner_moved(old_user, new_user); crate::closure::closure_box_captures_owner_moved(old_user, new_user); - #[cfg(feature = "dyn-eval")] - crate::dyn_eval::function_owner_moved(old_user, new_user); + crate::dyn_eval_hooks::function_owner_moved(old_user, new_user); } GcMoveHookKind::MapForeachStack => { crate::map::map_header_moved_for_gc(old_user, new_user); diff --git a/crates/perry-runtime/src/intl.rs b/crates/perry-runtime/src/intl.rs index 7e195ee4ec..649c69f017 100644 --- a/crates/perry-runtime/src/intl.rs +++ b/crates/perry-runtime/src/intl.rs @@ -26,6 +26,7 @@ use crate::value::{js_jsvalue_to_string, js_nanbox_pointer, JSValue}; use crate::StringHeader; mod ctor_guard; +pub(crate) mod hooked; use ctor_guard::{constructor_target_prototype, require_new_target}; mod display_names; mod duration_format; @@ -1785,11 +1786,29 @@ fn set_proto_to_string_tag(proto: *mut ObjectHeader, tag: &str) { /// reclaims the constructor/option/format machinery that nothing else /// reaches. `toLocale*` / `localeCompare` are unaffected — their entry points /// and helpers live outside this gate. -#[cfg(not(feature = "intl-namespace"))] -pub fn install_intl_namespace(_ns_obj: *mut ObjectHeader) {} +/// +/// `globalThis` population is live in every program, so it reaches the members +/// only through a slot the `intl-namespace` install fills (see +/// `crate::feature_hooks`); an empty slot leaves the namespace empty, exactly +/// as a build without the feature does. +pub fn install_intl_namespace(ns_obj: *mut ObjectHeader) { + if let Some(install) = INTL_NAMESPACE_MEMBERS.get() { + install(ns_obj); + } +} + +static INTL_NAMESPACE_MEMBERS: crate::feature_hooks::Hook = + crate::feature_hooks::Hook::empty(); +/// The `intl-namespace` install. #[cfg(feature = "intl-namespace")] -pub fn install_intl_namespace(ns_obj: *mut ObjectHeader) { +pub(crate) fn install_intl_namespace_feature() { + INTL_NAMESPACE_MEMBERS.set(install_intl_namespace_members); + hooked::install(); +} + +#[cfg(feature = "intl-namespace")] +fn install_intl_namespace_members(ns_obj: *mut ObjectHeader) { if ns_obj.is_null() { return; } diff --git a/crates/perry-runtime/src/intl/duration_format.rs b/crates/perry-runtime/src/intl/duration_format.rs index c55e3eefa8..c29379f955 100644 --- a/crates/perry-runtime/src/intl/duration_format.rs +++ b/crates/perry-runtime/src/intl/duration_format.rs @@ -395,8 +395,7 @@ fn to_duration_record(value: f64) -> Vec { // `ToDurationRecord` first branch: a `Temporal.Duration` (or subclass) copies // its internal slots directly — no prototype getters observed, no field-order // side effects. Only reachable when the Temporal engine is compiled in. - #[cfg(feature = "temporal")] - if let Some(vals) = crate::temporal::duration_unit_values(value) { + if let Some(vals) = crate::temporal::hooked::duration_unit_values(value) { let vals = vals.to_vec(); validate_duration(&vals); return vals; diff --git a/crates/perry-runtime/src/intl/hooked.rs b/crates/perry-runtime/src/intl/hooked.rs new file mode 100644 index 0000000000..a29ecd08f1 --- /dev/null +++ b/crates/perry-runtime/src/intl/hooked.rs @@ -0,0 +1,74 @@ +//! The Intl operations the always-live runtime reaches. +//! +//! `instanceof`, class construction (`class X extends Intl.`) and +//! `Number`/`BigInt.prototype.toLocaleString(locales, options)` have an Intl +//! arm. Those callers are live in every program, so they must not name the +//! ECMA-402 machinery directly, or the prebuilt full-feature runtime keeps it +//! in every binary. They call these forwarders, which reach it only through +//! slots the `intl-namespace` install fills (see `crate::feature_hooks`). An +//! empty slot answers what a build without the feature answers: no Intl +//! constructor value can exist, so the probes never match, and the locale +//! formatting falls back to the plain ECMA-262 rendering. + +use crate::feature_hooks::Hook; +use crate::string::StringHeader; + +static INSTANCEOF: Hook Option> = Hook::empty(); +static IS_CONSTRUCTOR_VALUE: Hook bool> = Hook::empty(); +static SUBCLASS_SUPER: Hook bool> = Hook::empty(); +static NUMBER_TO_LOCALE_STRING: Hook *mut StringHeader> = Hook::empty(); +static BIGINT_TO_LOCALE_STRING: Hook *mut StringHeader> = Hook::empty(); + +pub(crate) fn intl_instanceof(value: f64, type_ref: f64) -> Option { + INSTANCEOF.get().and_then(|f| f(value, type_ref)) +} + +pub(crate) fn is_intl_constructor_value(value: f64) -> bool { + IS_CONSTRUCTOR_VALUE.get().is_some_and(|f| f(value)) +} + +/// # Safety +/// As `crate::intl::intl_subclass_super`. +pub(crate) unsafe fn intl_subclass_super( + parent: f64, + this_box: f64, + args_ptr: *const f64, + args_len: usize, +) -> bool { + SUBCLASS_SUPER + .get() + .is_some_and(|f| f(parent, this_box, args_ptr, args_len)) +} + +/// `None` without the ECMA-402 formatter. +pub(crate) fn number_to_locale_string( + value: f64, + locales: f64, + options: f64, +) -> Option<*mut StringHeader> { + NUMBER_TO_LOCALE_STRING + .get() + .map(|f| f(value, locales, options)) +} + +/// `None` without the ECMA-402 formatter. +pub(crate) fn bigint_to_locale_string( + value: f64, + locales: f64, + options: f64, +) -> Option<*mut StringHeader> { + BIGINT_TO_LOCALE_STRING + .get() + .map(|f| f(value, locales, options)) +} + +/// The hub half of the `intl-namespace` install. +#[cfg(feature = "intl-namespace")] +pub(crate) fn install() { + INSTANCEOF.set(super::intl_instanceof); + IS_CONSTRUCTOR_VALUE.set(super::is_intl_constructor_value); + SUBCLASS_SUPER.set(super::intl_subclass_super); + NUMBER_TO_LOCALE_STRING.set(super::number_to_locale_string); + BIGINT_TO_LOCALE_STRING.set(super::bigint_to_locale_string); + crate::object::date_proto_thunks::install_date_to_locale_opts(); +} diff --git a/crates/perry-runtime/src/json/stringify.rs b/crates/perry-runtime/src/json/stringify.rs index c200a700a2..7218c86df3 100644 --- a/crates/perry-runtime/src/json/stringify.rs +++ b/crates/perry-runtime/src/json/stringify.rs @@ -533,9 +533,8 @@ pub(crate) unsafe fn stringify_value(value: f64, type_hint: u32, buf: &mut Strin // Temporal (#4686): `JSON.stringify(temporal)` calls `toJSON`, which // returns the canonical ISO string — emitted quoted. Detect before the // generic object path (the cell is not an enumerable ObjectHeader). - #[cfg(feature = "temporal")] if crate::temporal::is_temporal_cell_addr(ptr as usize) { - if let Some(s) = crate::temporal::temporal_iso_string(value) { + if let Some(s) = crate::temporal::hooked::iso_string(value) { write_escaped_string(buf, &s); } else { buf.push_str("null"); @@ -811,9 +810,8 @@ pub(crate) unsafe fn stringify_value_depth( } // Temporal (#4686): `toJSON` → quoted ISO string. See the matching // branch in `stringify_value`. - #[cfg(feature = "temporal")] if crate::temporal::is_temporal_cell_addr(ptr as usize) { - if let Some(s) = crate::temporal::temporal_iso_string(value) { + if let Some(s) = crate::temporal::hooked::iso_string(value) { write_escaped_string(buf, &s); } else { buf.push_str("null"); diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index 9a51e9ea40..727801b960 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -87,6 +87,8 @@ pub mod bigint; pub mod r#box; pub mod buffer; mod build_stamp; +pub(crate) mod dyn_eval_hooks; +pub mod feature_hooks; /// The layout facts generated code bakes in (`perry-abi`). pub use perry_abi as codegen_abi; pub(crate) mod cold_sort; diff --git a/crates/perry-runtime/src/module_require.rs b/crates/perry-runtime/src/module_require.rs index 10c918edf4..9b207934cc 100644 --- a/crates/perry-runtime/src/module_require.rs +++ b/crates/perry-runtime/src/module_require.rs @@ -1577,8 +1577,7 @@ fn dynamic_import_fallback_promise(spec: f64, options: f64, deferred_note: Optio let promise = crate::promise::js_promise_resolved(ns_handle.get_nanbox_f64()); return js_nanbox_pointer(promise as i64); } - #[cfg(feature = "dyn-eval")] - if let Some(namespace) = dynamic_import_javascript_data_url(&spec_str) { + if let Some(namespace) = crate::dyn_eval_hooks::dynamic_import_data_url(&spec_str) { let promise = crate::promise::js_promise_resolved(namespace); return js_nanbox_pointer(promise as i64); } @@ -1617,7 +1616,7 @@ fn dynamic_import_fallback_promise(spec: f64, options: f64, deferred_note: Optio } #[cfg(feature = "dyn-eval")] -fn dynamic_import_javascript_data_url(specifier: &str) -> Option { +pub(crate) fn dynamic_import_javascript_data_url(specifier: &str) -> Option { let encoded = specifier.strip_prefix("data:text/javascript,")?; let mut decoded = Vec::with_capacity(encoded.len()); let bytes = encoded.as_bytes(); diff --git a/crates/perry-runtime/src/module_require/data_import.rs b/crates/perry-runtime/src/module_require/data_import.rs index 376e9b7de5..8d3afb7460 100644 --- a/crates/perry-runtime/src/module_require/data_import.rs +++ b/crates/perry-runtime/src/module_require/data_import.rs @@ -79,8 +79,13 @@ pub(super) fn load(specifier: &str, options: f64) -> Result, f64> { unsafe { crate::json::js_json_parse_result(source) } .map(|value| f64::from_bits(value.bits()))? } - #[cfg(feature = "bun-cli-utils")] - "toml" => crate::bun_compat::toml_parse_result(&source)?, + // TOML is parsed only when `bun-cli-utils` is installed (see + // `bun_compat::cli_utils_hooks`); otherwise this takes the + // deferred-error path below, as a runtime without it always has. + "toml" => match crate::bun_compat::toml_parse_result(&source) { + Some(parsed) => parsed?, + None => return Ok(None), + }, // Optimized builds retain bun-cli-utils for sites with options. // A deliberately minimal runtime still uses the deferred error. _ => return Ok(None), diff --git a/crates/perry-runtime/src/object/class_constructors.rs b/crates/perry-runtime/src/object/class_constructors.rs index 2aef916811..f2533cc878 100644 --- a/crates/perry-runtime/src/object/class_constructors.rs +++ b/crates/perry-runtime/src/object/class_constructors.rs @@ -743,7 +743,6 @@ pub unsafe extern "C" fn js_super_construct_apply( // and stash the returned cell as the subclass instance's brand — the // `super(...spread)` counterpart of the `js_fetch_or_value_super` branch // that handles non-spread `super(a, b)`. (#5587) - #[cfg(feature = "temporal")] { let parent_val = crate::object::class_registry::js_get_dynamic_parent_value(child_cid); if crate::object::global_this::temporal_ctor_kind(parent_val).is_some() { @@ -757,7 +756,7 @@ pub unsafe extern "C" fn js_super_construct_apply( for i in 0..n { flat.push(crate::array::js_array_get_f64(arr, i as u32)); } - crate::object::global_this::temporal_subclass_super( + crate::temporal::hooked::subclass_super( parent_val, this_box, flat.as_ptr(), @@ -773,10 +772,9 @@ pub unsafe extern "C" fn js_super_construct_apply( // reason as the instanceof probe: with the feature off no Intl // constructor value exists, so the branch is unreachable, and skipping it // keeps this always-live path from pinning the Intl constructor web. - #[cfg(feature = "intl-namespace")] { let parent_val = crate::object::class_registry::js_get_dynamic_parent_value(child_cid); - if crate::intl::is_intl_constructor_value(parent_val) { + if crate::intl::hooked::is_intl_constructor_value(parent_val) { let this_box = crate::value::js_nanbox_pointer(this_raw); let n = if arr.is_null() { 0 @@ -787,7 +785,12 @@ pub unsafe extern "C" fn js_super_construct_apply( for i in 0..n { flat.push(crate::array::js_array_get_f64(arr, i as u32)); } - crate::intl::intl_subclass_super(parent_val, this_box, flat.as_ptr(), flat.len()); + crate::intl::hooked::intl_subclass_super( + parent_val, + this_box, + flat.as_ptr(), + flat.len(), + ); } } undef diff --git a/crates/perry-runtime/src/object/date_proto_thunks.rs b/crates/perry-runtime/src/object/date_proto_thunks.rs index 267e1c987d..5bdd9f4803 100644 --- a/crates/perry-runtime/src/object/date_proto_thunks.rs +++ b/crates/perry-runtime/src/object/date_proto_thunks.rs @@ -584,15 +584,32 @@ date_setter_thunk!(date_set_utc_milliseconds, 1, 6); /// the feature off no program in this binary can call these thunks, so the /// fallback simply defers to the non-locale formatter instead of statically /// pinning the Intl formatting web from the always-installed Date prototype. -#[cfg(not(feature = "intl-namespace"))] -fn date_to_locale_opts_impl(_rest: f64, _ctx: crate::intl::TemporalLocaleCtx) -> f64 { +/// +/// The always-installed Date prototype reaches the Intl formatter only through +/// the slot the `intl-namespace` install fills (see `crate::feature_hooks`); +/// without it this defers to the non-locale formatter, exactly as a build +/// without the feature does. +fn date_to_locale_opts_impl(rest: f64, ctx: crate::intl::TemporalLocaleCtx) -> f64 { + if let Some(format) = DATE_TO_LOCALE_OPTS.get() { + return format(rest, ctx); + } let this = require_date_this(); let s = crate::date::js_date_to_locale_string(this); crate::value::js_nanbox_string(s as i64) } +static DATE_TO_LOCALE_OPTS: crate::feature_hooks::Hook< + fn(f64, crate::intl::TemporalLocaleCtx) -> f64, +> = crate::feature_hooks::Hook::empty(); + +/// The `intl-namespace` install's Date-prototype half. +#[cfg(feature = "intl-namespace")] +pub(crate) fn install_date_to_locale_opts() { + DATE_TO_LOCALE_OPTS.set(date_to_locale_opts_intl); +} + #[cfg(feature = "intl-namespace")] -fn date_to_locale_opts_impl(rest: f64, ctx: crate::intl::TemporalLocaleCtx) -> f64 { +fn date_to_locale_opts_intl(rest: f64, ctx: crate::intl::TemporalLocaleCtx) -> f64 { let this = require_date_this(); let epoch_ms = crate::date::date_cell_timestamp(this); if epoch_ms.is_nan() { diff --git a/crates/perry-runtime/src/object/field_get_set.rs b/crates/perry-runtime/src/object/field_get_set.rs index eba8ac46d8..b3c4601ba6 100644 --- a/crates/perry-runtime/src/object/field_get_set.rs +++ b/crates/perry-runtime/src/object/field_get_set.rs @@ -125,7 +125,8 @@ pub extern "C" fn js_fetch_unwrap_handle(value: f64) -> f64 { /// instance (a plain heap object) can only reach its members through this /// stashed cell. Stored as a real pointer-valued field so GC keeps the cell /// alive and rewrites the slot on evacuation. (#5587) -#[cfg(feature = "temporal")] +// Ungated: always-live property lookup compares against it (see +// `crate::temporal::hooked`), and a constant keeps nothing alive. pub(crate) const TEMPORAL_SUBCLASS_CELL_FIELD: &[u8] = b"__perry_temporal_cell__"; /// Has any `class X extends Temporal.` instance EVER stashed a cell in diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs index caf9a0ea7b..17b459c502 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs @@ -1222,7 +1222,6 @@ fn get_field_by_name_past_data_probe( // bare value is rare; the `value.method()` call form is handled in // `js_native_call_method`). `obj` may be NaN-boxed (top16 0x7FFD) or a // raw-I64 pointer (top16 0). - #[cfg(feature = "temporal")] { let bits = obj as u64; let top16 = bits >> 48; @@ -1253,7 +1252,7 @@ fn get_field_by_name_past_data_probe( ) { return JSValue::from_bits(v.to_bits()); } - if let Some(v) = crate::temporal::dispatch::get_property(boxed, &name) { + if let Some(v) = crate::temporal::hooked::get_property(boxed, &name) { return JSValue::from_bits(v.to_bits()); } // A prototype METHOD read as a value (`d.abs`, not `d.abs()`): @@ -1262,7 +1261,7 @@ fn get_field_by_name_past_data_probe( // spread/dynamic call `d[m](...args)` to a property read + apply, // so the read must yield a callable. Only bind genuine method // names so an unknown property still reads as `undefined`. (#5587) - if crate::temporal::dispatch::has_method(boxed, &name) { + if crate::temporal::hooked::has_method(boxed, &name) { let heap_name = { let layout = std::alloc::Layout::from_size_align(key_bytes.len().max(1), 1) diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs index b78abaeaf2..a0453a0ff6 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs @@ -1834,14 +1834,13 @@ pub(crate) fn get_field_by_name_object_tail( // (cross-) trigger the other marker's reader, an infinite recursion that // stack-overflows. Methods read as fused `inst.m(...)` calls are handled // in `native_call_method.rs`. (#5587) - #[cfg(feature = "temporal")] if !key.is_null() && key_bytes != crate::object::TEMPORAL_SUBCLASS_CELL_FIELD && key_bytes != FETCH_SUBCLASS_HANDLE_FIELD { - if let Some(cell) = crate::object::temporal_subclass_cell(obj as usize) { + if let Some(cell) = crate::temporal::hooked::subclass_cell(obj as usize) { let name = String::from_utf8_lossy(key_bytes); - if let Some(v) = crate::temporal::dispatch::get_property(cell, &name) { + if let Some(v) = crate::temporal::hooked::get_property(cell, &name) { return JSValue::from_bits(v.to_bits()); } // A prototype METHOD read as a value (`sub.abs`, not `sub.abs()`): @@ -1849,7 +1848,7 @@ pub(crate) fn get_field_by_name_object_tail( // `js_native_call_method` (whose Temporal-subclass arm forwards to // the cell). Only bind genuine method names so an unknown property // still reads as `undefined`. Mirrors the fetch body-method bind. - if crate::temporal::dispatch::has_method(cell, &name) { + if crate::temporal::hooked::has_method(cell, &name) { let this_f64 = crate::value::js_nanbox_pointer(obj as i64); let heap_name = { let layout = diff --git a/crates/perry-runtime/src/object/global_this.rs b/crates/perry-runtime/src/object/global_this.rs index 79c061d9e3..cc3d82d5ef 100644 --- a/crates/perry-runtime/src/object/global_this.rs +++ b/crates/perry-runtime/src/object/global_this.rs @@ -42,6 +42,13 @@ mod bigint_promise; mod builtin_thunks; mod ctor_thunks; mod fetch_globals; + +/// `dyn-eval` install: every hub slot the script evaluator fills in this +/// module (see `crate::feature_hooks`). +#[cfg(feature = "dyn-eval")] +pub(crate) fn install_dyn_eval() { + fetch_globals::install_global_eval(); +} mod generator; mod install_static; mod math_temporal; @@ -172,6 +179,8 @@ pub(crate) use install_static::{ #[cfg(feature = "temporal")] pub(crate) use math_temporal::install_temporal_namespace; #[cfg(feature = "temporal")] +pub(crate) use math_temporal::temporal_ctor_kind_impl; +#[cfg(feature = "temporal")] pub(crate) use math_temporal::temporal_kind_prototype; pub(crate) use math_temporal::{install_math_namespace, temporal_ctor_kind}; pub(crate) use populate::{ diff --git a/crates/perry-runtime/src/object/global_this/builtin_thunks.rs b/crates/perry-runtime/src/object/global_this/builtin_thunks.rs index 0f5cfc3eac..68b3fde04a 100644 --- a/crates/perry-runtime/src/object/global_this/builtin_thunks.rs +++ b/crates/perry-runtime/src/object/global_this/builtin_thunks.rs @@ -520,19 +520,19 @@ fn js_function_ctor_from_strings_impl(args_ptr: *const f64, args_len: usize) -> // feature-probing libraries (zod's JIT probe, #6031) still get an honest // signal: the probe now SUCCEEDS and their generated code runs // interpreted. - #[cfg(feature = "dyn-eval")] - { - return crate::dyn_eval::dyn_function_from_strings(&args_vec); - } - // Without the `dyn-eval` feature (size-optimized builds that carry no - // dynamic-eval site), keep the historical clean throw: it lets - // feature-detecting libraries take their non-`Function` fallback. The - // eprintln names the offending library for diagnostics. - #[cfg(not(feature = "dyn-eval"))] - { - let body = args_vec.last().map(String::as_str).unwrap_or(""); - refuse_dynamic_function(args_len, body) + // The interpreter is reached through the slot the `dyn-eval` install + // fills (see `crate::dyn_eval_hooks`), so this always-live constructor does + // not keep it in programs that never build a function from source. + if let Some(function) = crate::dyn_eval_hooks::function_from_strings(&args_vec) { + return function; } + // Without the evaluator (a size-optimized build with no dynamic-eval site, + // or a prebuilt runtime whose program did not install it), keep the + // historical clean throw: it lets feature-detecting libraries take their + // non-`Function` fallback. The eprintln names the offending library for + // diagnostics. + let body = args_vec.last().map(String::as_str).unwrap_or(""); + refuse_dynamic_function(args_len, body) } /// #10423: the `Function` constructor called WITHOUT `new` through a value — @@ -566,7 +566,6 @@ fn function_call_thunk_impl(rest: f64) -> f64 { js_function_ctor_from_strings_impl(values.as_ptr(), values.len()) } -#[cfg(any(not(feature = "dyn-eval"), test))] fn refuse_dynamic_function(args_len: usize, body: &str) -> ! { let preview: String = body.chars().take(160).collect(); eprintln!( 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 488b43b269..e26c36b698 100644 --- a/crates/perry-runtime/src/object/global_this/fetch_globals.rs +++ b/crates/perry-runtime/src/object/global_this/fetch_globals.rs @@ -811,7 +811,6 @@ pub unsafe extern "C" fn js_fetch_or_value_super( // Request/Response branch below recovers via the decl-time stash. Mirror that: // when the immediate value isn't a Temporal ctor, fall back to the parent // value recorded against this instance's class id at declaration time. - #[cfg(feature = "temporal")] { let temporal_parent = if super::temporal_ctor_kind(parent_val).is_some() { parent_val @@ -821,7 +820,7 @@ pub unsafe extern "C" fn js_fetch_or_value_super( } else { parent_val }; - if temporal_subclass_super(temporal_parent, this_box, args_ptr, args_len) { + if crate::temporal::hooked::subclass_super(temporal_parent, this_box, args_ptr, args_len) { return undef; } } @@ -834,9 +833,8 @@ pub unsafe extern "C" fn js_fetch_or_value_super( // Behind `intl-namespace`: with the feature off no Intl constructor value // exists, so this probe can never match — and skipping it keeps this // always-live construct path from pinning the Intl web. - #[cfg(feature = "intl-namespace")] { - let intl_parent = if crate::intl::is_intl_constructor_value(parent_val) { + let intl_parent = if crate::intl::hooked::is_intl_constructor_value(parent_val) { parent_val } else if let Some(obj) = subclass_this_object_ptr(this_box) { let cid = crate::object::js_object_get_class_id(obj); @@ -844,7 +842,7 @@ pub unsafe extern "C" fn js_fetch_or_value_super( } else { parent_val }; - if crate::intl::intl_subclass_super(intl_parent, this_box, args_ptr, args_len) { + if crate::intl::hooked::intl_subclass_super(intl_parent, this_box, args_ptr, args_len) { return undef; } } @@ -1198,31 +1196,45 @@ pub(crate) extern "C" fn global_this_eval_thunk( let ptr = crate::string::js_string_from_bytes(s.as_ptr(), s.len() as u32); crate::value::js_nanbox_string(ptr as i64) } - _ => { - #[cfg(feature = "dyn-eval")] - { - let body = body.to_string(); - let scope = crate::gc::RuntimeHandleScope::new(); - let global = scope.root_nanbox_f64(js_get_global_this()); - let lexical = scope.root_nanbox_f64(crate::dyn_eval::script_environment( - global.get_nanbox_f64(), - &[], - )); - crate::dyn_eval::eval_script_in( - &body, - global.get_nanbox_f64(), - global.get_nanbox_f64(), - lexical.get_nanbox_f64(), - ) - } - #[cfg(not(feature = "dyn-eval"))] - { - f64::from_bits(crate::value::TAG_UNDEFINED) - } - } + // The script evaluator (the JS parser and everything behind it) is + // reached through a slot the `dyn-eval` install fills, so a program + // that never evaluates source does not link it (see + // `crate::feature_hooks`). Without it this answers `undefined`, as a + // build compiled without `dyn-eval` always has. + _ => match GLOBAL_EVAL.get() { + Some(eval) => eval(body), + None => f64::from_bits(crate::value::TAG_UNDEFINED), + }, } } +/// Indirect `globalThis.eval(source)` of a body the fast paths above did not +/// answer; filled by the `dyn-eval` install. +static GLOBAL_EVAL: crate::feature_hooks::Hook f64> = + crate::feature_hooks::Hook::empty(); + +#[cfg(feature = "dyn-eval")] +fn global_eval_script(body: &str) -> f64 { + let body = body.to_string(); + let scope = crate::gc::RuntimeHandleScope::new(); + let global = scope.root_nanbox_f64(js_get_global_this()); + let lexical = scope.root_nanbox_f64(crate::dyn_eval::script_environment( + global.get_nanbox_f64(), + &[], + )); + crate::dyn_eval::eval_script_in( + &body, + global.get_nanbox_f64(), + global.get_nanbox_f64(), + lexical.get_nanbox_f64(), + ) +} + +#[cfg(feature = "dyn-eval")] +pub(crate) fn install_global_eval() { + GLOBAL_EVAL.set(global_eval_script); +} + #[cfg(test)] mod dynamic_super_new_target_tests { use super::*; diff --git a/crates/perry-runtime/src/object/global_this/math_temporal.rs b/crates/perry-runtime/src/object/global_this/math_temporal.rs index 1ade09495d..f399ff833d 100644 --- a/crates/perry-runtime/src/object/global_this/math_temporal.rs +++ b/crates/perry-runtime/src/object/global_this/math_temporal.rs @@ -751,8 +751,18 @@ fn build_zoned_date_time_prototype() -> *mut ObjectHeader { /// same-named user closure never matches). Used by `instanceof` to make /// `zdt instanceof Temporal.ZonedDateTime` resolve to `true` even though /// Temporal values dispatch via brand arms, not a real prototype chain. -#[cfg(feature = "temporal")] +/// +/// `instanceof`, class construction and `Function.prototype` reads are live in +/// every program; comparing against the constructor closures would pin every +/// Temporal constructor, so they reach the comparison through the slot the +/// `temporal` install fills (see `crate::temporal::hooked`). Without it no +/// Temporal constructor exists and this answers `None`. pub(crate) fn temporal_ctor_kind(type_ref: f64) -> Option { + crate::temporal::hooked::ctor_kind(type_ref) +} + +#[cfg(feature = "temporal")] +pub(crate) fn temporal_ctor_kind_impl(type_ref: f64) -> Option { use crate::temporal::TemporalKind; let jv = JSValue::from_bits(type_ref.to_bits()); if !jv.is_pointer() { @@ -807,14 +817,6 @@ pub(crate) fn temporal_ctor_kind(type_ref: f64) -> Option Option { - None -} - /// Resolve `Temporal..prototype` for a Temporal value's `kind` by /// navigating the live `globalThis.Temporal..prototype` chain (the /// prototype object is stamped on each constructor closure's `prototype` diff --git a/crates/perry-runtime/src/object/global_this/populate.rs b/crates/perry-runtime/src/object/global_this/populate.rs index c64501e709..81104ff910 100644 --- a/crates/perry-runtime/src/object/global_this/populate.rs +++ b/crates/perry-runtime/src/object/global_this/populate.rs @@ -751,10 +751,13 @@ pub(crate) fn populate_global_this_builtins(singleton_at_entry: *mut ObjectHeade set_intrinsic_to_string_tag(ns_obj, "Atomics"); } "Intl" => crate::intl::install_intl_namespace(ns_obj), - #[cfg(feature = "temporal")] + // Members come from the `temporal` install (see + // `crate::temporal::hooked`); without it the namespace stays a + // plain empty object, as in a build without the feature. "Temporal" => { - install_temporal_namespace(ns_obj); - set_intrinsic_to_string_tag(ns_obj, "Temporal"); + if crate::temporal::hooked::install_namespace(ns_obj) { + set_intrinsic_to_string_tag(ns_obj, "Temporal"); + } } _ => {} } diff --git a/crates/perry-runtime/src/object/instanceof/dynamic_dispatch.rs b/crates/perry-runtime/src/object/instanceof/dynamic_dispatch.rs index af839762a9..c73bea93e9 100644 --- a/crates/perry-runtime/src/object/instanceof/dynamic_dispatch.rs +++ b/crates/perry-runtime/src/object/instanceof/dynamic_dispatch.rs @@ -44,7 +44,6 @@ pub extern "C" fn js_instanceof_dynamic(value: f64, type_ref: f64) -> f64 { // recover that cell and compare its kind. The receiver reaches here both // NaN-boxed (top16 == 0x7FFD) and as a raw-I64 heap pointer (top16 == 0, // how module-level object vars are stored) — accept both. (#5587) - #[cfg(feature = "temporal")] { let bits = value.to_bits(); let top16 = bits >> 48; @@ -56,7 +55,7 @@ pub extern "C" fn js_instanceof_dynamic(value: f64, type_ref: f64) -> f64 { 0 }; if raw != 0 { - if let Some(cell) = unsafe { crate::object::temporal_subclass_cell(raw) } { + if let Some(cell) = unsafe { crate::temporal::hooked::subclass_cell(raw) } { if crate::temporal::temporal_kind(cell) == Some(kind) { return f64::from_bits(crate::value::TAG_TRUE); } @@ -425,8 +424,7 @@ pub extern "C" fn js_instanceof_dynamic(value: f64, type_ref: f64) -> f64 { // Intl constructor value can exist (the namespace install is a no-op), so // the probe could never match — and skipping it keeps this always-live // dispatcher from statically pinning every Intl constructor thunk (~204 KB). - #[cfg(feature = "intl-namespace")] - if let Some(is_inst) = crate::intl::intl_instanceof(value, type_ref) { + if let Some(is_inst) = crate::intl::hooked::intl_instanceof(value, type_ref) { return if is_inst { f64::from_bits(crate::value::TAG_TRUE) } else { diff --git a/crates/perry-runtime/src/object/iterator_prototypes.rs b/crates/perry-runtime/src/object/iterator_prototypes.rs index 6c197b35a1..d3b4b1cc75 100644 --- a/crates/perry-runtime/src/object/iterator_prototypes.rs +++ b/crates/perry-runtime/src/object/iterator_prototypes.rs @@ -209,9 +209,11 @@ unsafe fn dispatch_on_implicit_this(method: &str) -> f64 { crate::string::STRING_ITERATOR_CLASS_ID => { crate::string::dispatch_string_iterator_method_builtin(obj, method) } - #[cfg(feature = "regex-engine")] crate::regex::REGEXP_STRING_ITERATOR_CLASS_ID => { - crate::regex::dispatch_regexp_string_iterator_method_builtin(obj, method) + match crate::regex::hooked_iterator_method_builtin(obj, method) { + Some(value) => value, + None => brand_type_error(method), + } } _ => brand_type_error(method), } diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index d761a5ed66..9552f41e12 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -127,6 +127,15 @@ pub(crate) use global_fetch::scan_pending_fetch_signal_root_mut; /// the GC contract. pub(crate) mod chain_store; mod global_this; +#[cfg(feature = "dyn-eval")] +pub(crate) use global_this::install_dyn_eval; +#[cfg(feature = "temporal")] +pub(crate) use global_this::{ + install_temporal_namespace as global_this_install_temporal_namespace, + temporal_ctor_kind_impl as global_this_temporal_ctor_kind, + temporal_kind_prototype as global_this_temporal_kind_prototype, + temporal_subclass_super as global_this_temporal_subclass_super, +}; pub mod handle_expando; pub(crate) mod inherited_read_cache; pub(crate) mod prop_plan; diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 055c52795a..82fa38ce3d 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -2681,12 +2681,11 @@ pub unsafe extern "C-unwind" fn js_native_call_method( // prototype walk) has missed by here, so a subclass override still wins; // only genuinely inherited Temporal methods reach this forward. Route them // to the stashed cell (`temporal_subclass_cell`). (#5587) - #[cfg(feature = "temporal")] if jsval().is_pointer() { let raw = crate::value::js_nanbox_get_pointer(object()) as usize; - if let Some(cell) = crate::object::temporal_subclass_cell(raw) { + if let Some(cell) = crate::temporal::hooked::subclass_cell(raw) { let args = refreshed_args(); - return crate::temporal::dispatch::call_method(cell, method_name, &args); + return crate::temporal::hooked::call_method(cell, method_name, &args); } } diff --git a/crates/perry-runtime/src/object/native_call_method/collection_methods.rs b/crates/perry-runtime/src/object/native_call_method/collection_methods.rs index fca8c802f2..92321f9064 100644 --- a/crates/perry-runtime/src/object/native_call_method/collection_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/collection_methods.rs @@ -419,14 +419,14 @@ pub(super) unsafe fn dispatch_raw_pointer( method_name, )); } - #[cfg(feature = "regex-engine")] if (*obj).class_id == crate::regex::REGEXP_STRING_ITERATOR_CLASS_ID && crate::collection_iter_object::is_intrinsic_iterator_method(method_name) { - return Some(crate::regex::dispatch_regexp_string_iterator_method( - obj as *mut ObjectHeader, - method_name, - )); + if let Some(value) = + crate::regex::hooked_iterator_method(obj as *mut ObjectHeader, method_name) + { + return Some(value); + } } // #2874: lazy iterator-helper objects, same as the NaN-boxed path. if (*obj).class_id == crate::iterator_helpers::ITERATOR_HELPER_CLASS_ID { diff --git a/crates/perry-runtime/src/object/native_call_method/object_proto.rs b/crates/perry-runtime/src/object/native_call_method/object_proto.rs index 8a88e9f98c..397a638863 100644 --- a/crates/perry-runtime/src/object/native_call_method/object_proto.rs +++ b/crates/perry-runtime/src/object/native_call_method/object_proto.rs @@ -167,9 +167,8 @@ pub(crate) unsafe fn js_object_default_to_locale_string(receiver: f64) -> f64 { // the Temporal dispatch via the generic method-call path (which preserves // args). Only the zero-arg form arrives here; dispatch it with an empty // slice so the Temporal method applies its type-appropriate defaults. - #[cfg(feature = "temporal")] if crate::temporal::is_temporal_value(receiver) { - return crate::temporal::dispatch::call_method(receiver, "toLocaleString", &[]); + return crate::temporal::hooked::call_method(receiver, "toLocaleString", &[]); } // #7428: a BigInt receiver must format through // `BigInt.prototype.toLocaleString`, i.e. with the DEFAULT locale's digit @@ -188,11 +187,11 @@ pub(crate) unsafe fn js_object_default_to_locale_string(receiver: f64) -> f64 { // `bigint_proto_to_locale_string_thunk`. That asymmetry is why the explicit // `toLocaleString(undefined)` was already correct while the bare call was // not — the two forms never met. - #[cfg(feature = "intl-namespace")] if jsval.is_bigint() { let undef = f64::from_bits(crate::value::TAG_UNDEFINED); - let s = crate::intl::bigint_to_locale_string(receiver, undef, undef); - return f64::from_bits(JSValue::string_ptr(s).bits()); + if let Some(s) = crate::intl::hooked::bigint_to_locale_string(receiver, undef, undef) { + return f64::from_bits(JSValue::string_ptr(s).bits()); + } } // Primitive receivers (including pointer-tagged Symbols) inherit the // Object method but resolve `toString` on their own prototype chain. diff --git a/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs b/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs index c66c4b9c01..9b3879d5b5 100644 --- a/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs @@ -24,10 +24,9 @@ pub(super) unsafe fn dispatch_primitive( // `Temporal.*` value is a NaN-boxed pointer to a custom cell with no // codegen fast-path, so every method call funnels through here. The router // throws `TypeError` for an unknown method name on a real Temporal receiver. - #[cfg(feature = "temporal")] if crate::temporal::is_temporal_value(object) { let args = refreshed_args(); - return Some(crate::temporal::dispatch::call_method( + return Some(crate::temporal::hooked::call_method( object, method_name, &args, diff --git a/crates/perry-runtime/src/object/object_ops/prototype.rs b/crates/perry-runtime/src/object/object_ops/prototype.rs index 653400db9a..33578632a6 100644 --- a/crates/perry-runtime/src/object/object_ops/prototype.rs +++ b/crates/perry-runtime/src/object/object_ops/prototype.rs @@ -204,10 +204,9 @@ fn get_prototype_of_resolved(obj_value: f64) -> f64 { // (the test262 subclassing-ignored shape) requires that object, not `null`. // Resolve it via the live namespace; fall back to `null` only if Temporal // isn't reachable. (#5587) - #[cfg(feature = "temporal")] if crate::temporal::is_temporal_value(obj_value) { if let Some(kind) = crate::temporal::temporal_kind(obj_value) { - let proto = crate::object::global_this::temporal_kind_prototype(kind); + let proto = crate::temporal::hooked::kind_prototype(kind); if crate::value::JSValue::from_bits(proto.to_bits()).is_pointer() { return proto; } diff --git a/crates/perry-runtime/src/object/primitive_proto_thunks.rs b/crates/perry-runtime/src/object/primitive_proto_thunks.rs index b7ffdddcc0..fdc7a199ef 100644 --- a/crates/perry-runtime/src/object/primitive_proto_thunks.rs +++ b/crates/perry-runtime/src/object/primitive_proto_thunks.rs @@ -360,7 +360,6 @@ pub(super) extern "C" fn number_proto_to_locale_string_thunk( rest: f64, ) -> f64 { let n = number_receiver_or_throw("toLocaleString"); - #[cfg(feature = "intl-namespace")] { let args = super::global_this::global_this_rest_array_values(rest); let undef = f64::from_bits(crate::value::TAG_UNDEFINED); @@ -368,12 +367,15 @@ pub(super) extern "C" fn number_proto_to_locale_string_thunk( let options = args.get(1).copied().unwrap_or(undef); let defaulted = crate::value::JSValue::from_bits(locales.to_bits()).is_undefined() && crate::value::JSValue::from_bits(options.to_bits()).is_undefined(); + // The ECMA-402 formatter is reached through the `intl-namespace` + // install (see `crate::intl::hooked`); without it this keeps the + // plain grouping helper, as a build without the feature does. if !defaulted { - return string_value(crate::intl::number_to_locale_string(n, locales, options)); + if let Some(s) = crate::intl::hooked::number_to_locale_string(n, locales, options) { + return string_value(s); + } } } - #[cfg(not(feature = "intl-namespace"))] - let _ = rest; string_value(crate::date::js_number_to_locale_string(n)) } @@ -436,11 +438,8 @@ pub(super) extern "C" fn bigint_proto_to_locale_string_thunk( let undef = f64::from_bits(crate::value::TAG_UNDEFINED); let _locales = args.first().copied().unwrap_or(undef); let _options = args.get(1).copied().unwrap_or(undef); - #[cfg(feature = "intl-namespace")] - { - string_value(crate::intl::bigint_to_locale_string( - value, _locales, _options, - )) + if let Some(s) = crate::intl::hooked::bigint_to_locale_string(value, _locales, _options) { + return string_value(s); } // Binary size: this thunk is the ONLY retainer of the ECMA-402 // number-formatting machinery in a program that never mentions @@ -453,10 +452,7 @@ pub(super) extern "C" fn bigint_proto_to_locale_string_thunk( // supplied `locales`/`options` either. ECMA-262 leaves the result // implementation-defined when ECMA-402 is absent, so render plain // decimal digits. - #[cfg(not(feature = "intl-namespace"))] - { - string_value(crate::value::js_jsvalue_to_string_radix(value, 10.0)) - } + string_value(crate::value::js_jsvalue_to_string_radix(value, 10.0)) } /// `String.prototype.toString()` — brand-checked: returns the underlying string diff --git a/crates/perry-runtime/src/regex.rs b/crates/perry-runtime/src/regex.rs index a7d2b185ba..79cf40271d 100644 --- a/crates/perry-runtime/src/regex.rs +++ b/crates/perry-runtime/src/regex.rs @@ -103,6 +103,43 @@ use utf16::{byte_index_to_utf16_index, utf16_index_to_byte}; /// always-linked iterator-prototype dispatch, so it stays ungated even when /// the regex engine (which produces these iterators) is compiled out. pub const REGEXP_STRING_ITERATOR_CLASS_ID: u32 = 0xFFFF_000A; + +/// `matchAll` iterator methods reached from the always-live generic dispatchers +/// (`js_native_call_method`, the iterator prototypes' `next`). They reach the +/// regex engine only through slots the `regex-engine` install fills (see +/// `crate::feature_hooks`); without it no RegExp string iterator exists and +/// these answer `None`, so the dispatchers take their no-regex path. +static ITERATOR_METHOD: crate::feature_hooks::Hook< + unsafe fn(*mut crate::ObjectHeader, &str) -> f64, +> = crate::feature_hooks::Hook::empty(); +static ITERATOR_METHOD_BUILTIN: crate::feature_hooks::Hook< + unsafe fn(*mut crate::ObjectHeader, &str) -> f64, +> = crate::feature_hooks::Hook::empty(); + +/// # Safety +/// `iter` must be a live RegExp string iterator object. +pub(crate) unsafe fn hooked_iterator_method( + iter: *mut crate::ObjectHeader, + method: &str, +) -> Option { + ITERATOR_METHOD.get().map(|f| f(iter, method)) +} + +/// # Safety +/// `iter` must be a live RegExp string iterator object. +pub(crate) unsafe fn hooked_iterator_method_builtin( + iter: *mut crate::ObjectHeader, + method: &str, +) -> Option { + ITERATOR_METHOD_BUILTIN.get().map(|f| f(iter, method)) +} + +/// The `regex-engine` install's hub half. +#[cfg(feature = "regex-engine")] +pub(crate) fn install_iterator_hooks() { + ITERATOR_METHOD.set(dispatch_regexp_string_iterator_method); + ITERATOR_METHOD_BUILTIN.set(dispatch_regexp_string_iterator_method_builtin); +} #[cfg(feature = "regex-engine")] pub use perex_replace_compat::*; #[cfg(not(feature = "regex-engine"))] diff --git a/crates/perry-runtime/src/symbol/iterator.rs b/crates/perry-runtime/src/symbol/iterator.rs index 0ca5f2d9a1..510c884d0f 100644 --- a/crates/perry-runtime/src/symbol/iterator.rs +++ b/crates/perry-runtime/src/symbol/iterator.rs @@ -560,12 +560,11 @@ pub unsafe extern "C" fn js_to_primitive(value: f64, hint: i32) -> f64 { // `"string"`/`"default"` — which is exactly what `"x" + plainDateTime` and // template interpolation need. (Direct `String(x)` already brand-checks; the // `+`/template coercion routed here did not.) - #[cfg(feature = "temporal")] if crate::temporal::is_temporal_value(value) { if hint == 1 { crate::object::throw_object_type_error(b"Cannot convert a Temporal value to a number"); } - if let Some(s) = crate::temporal::temporal_iso_string(value) { + if let Some(s) = crate::temporal::hooked::iso_string(value) { let p = js_string_from_bytes(s.as_ptr(), s.len() as u32); return crate::value::js_nanbox_string(p as i64); } diff --git a/crates/perry-runtime/src/temporal/hooked.rs b/crates/perry-runtime/src/temporal/hooked.rs new file mode 100644 index 0000000000..0b34ea93f5 --- /dev/null +++ b/crates/perry-runtime/src/temporal/hooked.rs @@ -0,0 +1,137 @@ +//! The Temporal operations the always-live runtime reaches. +//! +//! Generic operators (`==` / ToPrimitive / ToString / `JSON.stringify` / +//! `valueOf`), property lookup, `instanceof`, subclass construction, the GC +//! finalizer and `globalThis` population all have a Temporal arm. Those +//! callers are live in every program, so they must not name the +//! `temporal_rs`-backed implementation directly, or the prebuilt full-feature +//! runtime keeps Temporal (and the ICU calendar/time-zone data behind it) in +//! every binary. They keep their cheap `is_temporal_value` / +//! `is_temporal_cell_addr` guards and call these forwarders for the rest; the +//! forwarders reach the implementation only through slots the `temporal` +//! install fills (see `crate::feature_hooks`). +//! +//! A Temporal cell exists only after Temporal code ran, so an empty slot is +//! never reached behind a guard in practice; each forwarder still answers what +//! a build without the feature answers. + +use super::{TemporalCell, TemporalKind, TemporalValue}; +use crate::feature_hooks::Hook; +use crate::object::ObjectHeader; + +static CALL_METHOD: Hook f64> = Hook::empty(); +static ISO_STRING: Hook Option> = Hook::empty(); +static DURATION_UNIT_VALUES: Hook Option<[f64; 10]>> = Hook::empty(); +static GET_PROPERTY: Hook Option> = Hook::empty(); +static HAS_METHOD: Hook bool> = Hook::empty(); +static SUBCLASS_CELL: Hook Option> = Hook::empty(); +static SUBCLASS_SUPER: Hook bool> = Hook::empty(); +static KIND_PROTOTYPE: Hook f64> = Hook::empty(); +static INSTALL_NAMESPACE: Hook = Hook::empty(); +static FINALIZE_CELL: Hook = Hook::empty(); +static TO_EPOCH_MS: Hook Option> = Hook::empty(); +static CALENDAR_ID: Hook Option<&'static str>> = Hook::empty(); +static INSPECT_STRING: Hook Option> = Hook::empty(); +static CTOR_KIND: Hook Option> = Hook::empty(); + +pub fn call_method(recv: f64, name: &str, args: &[f64]) -> f64 { + CALL_METHOD + .get() + .map_or(f64::from_bits(crate::value::TAG_UNDEFINED), |f| { + f(recv, name, args) + }) +} + +pub fn iso_string(value: f64) -> Option { + ISO_STRING.get().and_then(|f| f(value)) +} + +pub fn duration_unit_values(value: f64) -> Option<[f64; 10]> { + DURATION_UNIT_VALUES.get().and_then(|f| f(value)) +} + +pub fn get_property(recv: f64, name: &str) -> Option { + GET_PROPERTY.get().and_then(|f| f(recv, name)) +} + +pub fn has_method(recv: f64, name: &str) -> bool { + HAS_METHOD.get().is_some_and(|f| f(recv, name)) +} + +/// # Safety +/// As `crate::object::temporal_subclass_cell`. +pub unsafe fn subclass_cell(obj: usize) -> Option { + SUBCLASS_CELL.get().and_then(|f| f(obj)) +} + +/// # Safety +/// As `crate::object::global_this::temporal_subclass_super`. +pub unsafe fn subclass_super( + parent: f64, + this_box: f64, + args_ptr: *const f64, + args_len: usize, +) -> bool { + SUBCLASS_SUPER + .get() + .is_some_and(|f| f(parent, this_box, args_ptr, args_len)) +} + +pub fn kind_prototype(kind: TemporalKind) -> f64 { + KIND_PROTOTYPE + .get() + .map_or(f64::from_bits(crate::value::TAG_UNDEFINED), |f| f(kind)) +} + +pub fn install_namespace(ns_obj: *mut ObjectHeader) -> bool { + match INSTALL_NAMESPACE.get() { + Some(install) => { + install(ns_obj); + true + } + None => false, + } +} + +/// # Safety +/// As `super::finalize_temporal_cell_for_gc`. +pub unsafe fn finalize_cell(cell: *mut TemporalCell) { + if let Some(f) = FINALIZE_CELL.get() { + f(cell); + } +} + +pub fn to_epoch_ms(tv: &TemporalValue) -> Option { + TO_EPOCH_MS.get().and_then(|f| f(tv)) +} + +pub fn calendar_id(value: f64) -> Option<&'static str> { + CALENDAR_ID.get().and_then(|f| f(value)) +} + +pub fn inspect_string(value: f64) -> Option { + INSPECT_STRING.get().and_then(|f| f(value)) +} + +pub fn ctor_kind(type_ref: f64) -> Option { + CTOR_KIND.get().and_then(|f| f(type_ref)) +} + +/// The `temporal` install. +#[cfg(feature = "temporal")] +pub(crate) fn install() { + CALL_METHOD.set(super::dispatch::call_method); + ISO_STRING.set(super::temporal_iso_string); + DURATION_UNIT_VALUES.set(super::duration_unit_values); + GET_PROPERTY.set(super::dispatch::get_property); + HAS_METHOD.set(super::dispatch::has_method); + SUBCLASS_CELL.set(crate::object::temporal_subclass_cell); + SUBCLASS_SUPER.set(crate::object::global_this_temporal_subclass_super); + KIND_PROTOTYPE.set(crate::object::global_this_temporal_kind_prototype); + INSTALL_NAMESPACE.set(crate::object::global_this_install_temporal_namespace); + FINALIZE_CELL.set(super::finalize_temporal_cell_impl); + TO_EPOCH_MS.set(super::temporal_to_epoch_ms_impl); + CALENDAR_ID.set(super::temporal_calendar_id_impl); + INSPECT_STRING.set(super::temporal_inspect_string); + CTOR_KIND.set(crate::object::global_this_temporal_ctor_kind); +} diff --git a/crates/perry-runtime/src/temporal/mod.rs b/crates/perry-runtime/src/temporal/mod.rs index 8743b3d4e8..78828bb52e 100644 --- a/crates/perry-runtime/src/temporal/mod.rs +++ b/crates/perry-runtime/src/temporal/mod.rs @@ -30,6 +30,7 @@ use crate::value::JSValue; pub mod dispatch; #[cfg(feature = "temporal")] pub mod duration; +pub mod hooked; #[cfg(feature = "temporal")] pub mod instant; #[cfg(feature = "temporal")] @@ -321,8 +322,12 @@ pub fn duration_unit_values(value: f64) -> Option<[f64; 10]> { /// (PlainDate, PlainDateTime, PlainYearMonth, PlainMonthDay, ZonedDateTime), /// or `None` for types without a calendar (Instant, PlainTime, Duration) or /// non-Temporal values. -#[cfg(feature = "temporal")] pub fn temporal_calendar_id(value: f64) -> Option<&'static str> { + hooked::calendar_id(value) +} + +#[cfg(feature = "temporal")] +pub(crate) fn temporal_calendar_id_impl(value: f64) -> Option<&'static str> { match temporal_value_ref(value)? { TemporalValue::PlainDate(d) => Some(d.calendar().identifier()), TemporalValue::PlainDateTime(dt) => Some(dt.calendar().identifier()), @@ -333,11 +338,6 @@ pub fn temporal_calendar_id(value: f64) -> Option<&'static str> { } } -#[cfg(not(feature = "temporal"))] -pub fn temporal_calendar_id(_value: f64) -> Option<&'static str> { - None -} - /// Drop the embedded `temporal_rs` value when a Temporal cell is swept, /// releasing any Rust-heap it owns (e.g. a `ZonedDateTime` timezone string). /// Registered as the `TemporalCleanup` finalize hook in `gc/types.rs`. @@ -345,20 +345,22 @@ pub fn temporal_calendar_id(_value: f64) -> Option<&'static str> { /// # Safety /// `cell` must point at a live, fully-initialized `TemporalCell` that the GC is /// about to reclaim; it is not read again afterwards. -#[cfg(feature = "temporal")] +/// +/// The GC sweeper is live in every program, so it reaches the drop only through +/// the slot the `temporal` install fills (see `hooked`); without Temporal no +/// cell is ever allocated and nothing is reached. pub unsafe fn finalize_temporal_cell_for_gc(cell: *mut TemporalCell) { + hooked::finalize_cell(cell); +} + +#[cfg(feature = "temporal")] +pub(crate) unsafe fn finalize_temporal_cell_impl(cell: *mut TemporalCell) { if cell.is_null() { return; } std::ptr::drop_in_place(cell); } -/// Temporal gated off: no Temporal cell is ever allocated, so the GC never -/// reaches this finalize hook. Kept as a no-op so `gc/types.rs`'s registration -/// resolves without the engine. -#[cfg(not(feature = "temporal"))] -pub unsafe fn finalize_temporal_cell_for_gc(_cell: *mut TemporalCell) {} - /// Convert a Temporal value to epoch milliseconds for Intl.DateTimeFormat. /// /// Each Temporal type maps its fields to a Unix timestamp (UTC): @@ -368,8 +370,12 @@ pub unsafe fn finalize_temporal_cell_for_gc(_cell: *mut TemporalCell) {} /// - `PlainYearMonth`: use day=1 for the epoch base /// - `PlainMonthDay`: use year=1970 for the epoch base /// - `Duration`: no epoch representation → `None` -#[cfg(feature = "temporal")] pub fn temporal_to_epoch_ms(tv: &TemporalValue) -> Option { + hooked::to_epoch_ms(tv) +} + +#[cfg(feature = "temporal")] +pub(crate) fn temporal_to_epoch_ms_impl(tv: &TemporalValue) -> Option { let secs: i64 = match tv { TemporalValue::Instant(i) => return Some(i.epoch_milliseconds() as f64), TemporalValue::ZonedDateTime(z) => return Some(z.epoch_milliseconds() as f64), @@ -413,11 +419,6 @@ pub fn temporal_to_epoch_ms(tv: &TemporalValue) -> Option { Some(secs as f64 * 1000.0) } -#[cfg(not(feature = "temporal"))] -pub fn temporal_to_epoch_ms(_tv: &TemporalValue) -> Option { - match *_tv {} -} - /// Render a Temporal value as its canonical ISO-8601 / IXDTF string — the form /// `toString` and `toJSON` use. Returns `None` only if `value` is not a /// Temporal cell. diff --git a/crates/perry-runtime/src/tls.rs b/crates/perry-runtime/src/tls.rs index 0afc743a77..49a7537b85 100644 --- a/crates/perry-runtime/src/tls.rs +++ b/crates/perry-runtime/src/tls.rs @@ -1342,15 +1342,14 @@ pub fn tls_domain_to_ascii(host: &str) -> String { return String::new(); } - #[cfg(feature = "url-engine")] - { - return idna::domain_to_ascii(&normalized) - .ok() + if let Some(ascii) = crate::url::idna_domain_to_ascii(&normalized) { + return ascii .and_then(|ascii| crate::url::whatwg_canonicalize_host(&ascii)) .unwrap_or_default(); } - #[cfg(not(feature = "url-engine"))] + // Without the URL engine installed (the reduced TLS runtime omits the + // URL/IDNA tables): { // The reduced TLS runtime deliberately omits the URL/IDNA tables. Keep // Node's important numeric-host coercion and reject non-ASCII input diff --git a/crates/perry-runtime/src/url/mod.rs b/crates/perry-runtime/src/url/mod.rs index 0682bb56e1..7f7c64aa74 100644 --- a/crates/perry-runtime/src/url/mod.rs +++ b/crates/perry-runtime/src/url/mod.rs @@ -190,16 +190,47 @@ pub(crate) fn object_prop_f64(obj: *mut ObjectHeader, key: &str) -> f64 { /// means for them (the hostname setter leaves the host unchanged; the /// `domainTo*` helpers return `""`), matching Node. pub(crate) fn whatwg_canonicalize_host(host: &str) -> Option { - #[cfg(feature = "url-engine")] - { + match CANONICALIZE_HOST.get() { + Some(canonicalize) => canonicalize(host), + // URL engine not installed: no WHATWG host parser, so pass the host + // through unchanged (the hand-rolled URL paths handle the common cases). + None => Some(host.to_string()), + } +} + +// The WHATWG host parser and IDNA (`url` / `idna`) are reached only through +// slots the `url-engine` install fills (see `crate::feature_hooks`): URL +// property setters, `domainTo*` and TLS servername handling are live in every +// program, and naming the crates directly would keep their tables in all of +// them. Each caller keeps the fallback a build without the feature has. +static CANONICALIZE_HOST: crate::feature_hooks::Hook Option> = + crate::feature_hooks::Hook::empty(); +static DOMAIN_TO_ASCII: crate::feature_hooks::Hook Option> = + crate::feature_hooks::Hook::empty(); +static DOMAIN_TO_UNICODE: crate::feature_hooks::Hook String> = + crate::feature_hooks::Hook::empty(); + +/// IDNA `domain_to_ascii`: `None` when the engine is not installed, +/// `Some(None)` when IDNA rejects the domain. +pub(crate) fn idna_domain_to_ascii(domain: &str) -> Option> { + DOMAIN_TO_ASCII.get().map(|f| f(domain)) +} + +/// IDNA `domain_to_unicode`; `None` when the engine is not installed. +pub(crate) fn idna_domain_to_unicode(domain: &str) -> Option { + DOMAIN_TO_UNICODE.get().map(|f| f(domain)) +} + +/// The `url-engine` install. +#[cfg(feature = "url-engine")] +pub(crate) fn install_engine() { + CANONICALIZE_HOST.set(|host| { url::Url::parse(&format!("http://{host}/")) .ok() .and_then(|u| u.host_str().map(str::to_string)) - } - // URL engine gated off: no WHATWG host parser, so pass the host through - // unchanged (the hand-rolled URL paths handle the common cases). - #[cfg(not(feature = "url-engine"))] - Some(host.to_string()) + }); + DOMAIN_TO_ASCII.set(|domain| idna::domain_to_ascii(domain).ok()); + DOMAIN_TO_UNICODE.set(|domain| idna::domain_to_unicode(domain).0); } /// True when `host` is a canonical dotted-quad IPv4 literal. Used by diff --git a/crates/perry-runtime/src/url/node_compat.rs b/crates/perry-runtime/src/url/node_compat.rs index 86e91319a7..471965d9a0 100644 --- a/crates/perry-runtime/src/url/node_compat.rs +++ b/crates/perry-runtime/src/url/node_compat.rs @@ -609,11 +609,9 @@ pub extern "C" fn js_url_domain_to_unicode(input_f64: f64) -> f64 { // CANONICALIZED host, not the raw input: `/`, `?`, `#` and `\` terminate // the host, so `domainToUnicode("a/b")` is `"a"`. Feeding the raw input to // `domain_to_unicode` skipped that truncation and echoed `"a/b"` back. - #[cfg(feature = "url-engine")] - Some(canon) => idna::domain_to_unicode(&canon).0, - // URL engine gated off: no IDNA, so return the canonical host unchanged. - #[cfg(not(feature = "url-engine"))] - Some(canon) => canon, + // Without the URL engine there is no IDNA: return the canonical host + // unchanged, as a build without `url-engine` does. + Some(canon) => super::idna_domain_to_unicode(&canon).unwrap_or(canon), }; create_string_f64(&out) } diff --git a/crates/perry-runtime/src/url/url_class.rs b/crates/perry-runtime/src/url/url_class.rs index fd2e0db9e2..a3056cd648 100644 --- a/crates/perry-runtime/src/url/url_class.rs +++ b/crates/perry-runtime/src/url/url_class.rs @@ -153,10 +153,9 @@ fn normalize_hostname_value(raw: &str) -> Option { { return None; } - #[cfg(feature = "url-engine")] - { - match idna::domain_to_ascii(raw) { - Ok(ascii) if !ascii.is_empty() => { + if let Some(ascii) = super::idna_domain_to_ascii(raw) { + return match ascii { + Some(ascii) if !ascii.is_empty() => { // #3056: apply the WHATWG numeric/IPv4-shorthand host parser as a // post-step. `idna::domain_to_ascii` only runs IDNA, so a numeric // host like `123` survives as `"123"` instead of canonicalizing to @@ -169,11 +168,10 @@ fn normalize_hostname_value(raw: &str) -> Option { super::whatwg_canonicalize_host(&ascii) } _ => None, - } + }; } - // URL engine gated off: no IDNA. Fall back to the hand-rolled host - // canonicalizer (which, also gated off, passes the host through unchanged). - #[cfg(not(feature = "url-engine"))] + // URL engine not installed: no IDNA. Fall back to the hand-rolled host + // canonicalizer (which, also without the engine, passes the host through). super::whatwg_canonicalize_host(raw) } diff --git a/crates/perry-runtime/src/value/dyn_index.rs b/crates/perry-runtime/src/value/dyn_index.rs index 41f1b53939..3a05f674af 100644 --- a/crates/perry-runtime/src/value/dyn_index.rs +++ b/crates/perry-runtime/src/value/dyn_index.rs @@ -637,7 +637,6 @@ pub extern "C" fn js_dyn_index_set_strict(obj: f64, index: f64, value: f64, stri } // A `Temporal.*` value is an opaque immutable cell — a dynamic property // write (`temporalValue[key] = v`) is a no-op, never an ObjectHeader write. - #[cfg(feature = "temporal")] if crate::temporal::is_temporal_value(obj) { return value; } diff --git a/crates/perry-runtime/src/value/to_string.rs b/crates/perry-runtime/src/value/to_string.rs index 7596e0c5be..68489afb98 100644 --- a/crates/perry-runtime/src/value/to_string.rs +++ b/crates/perry-runtime/src/value/to_string.rs @@ -270,9 +270,8 @@ pub(crate) fn js_jsvalue_to_string_impl( // `temporal.toString()` produce the value's canonical ISO-8601 / // IXDTF string, not "[object Object]". Detected here for the same // reason as Date — the cell is smaller than an ObjectHeader. - #[cfg(feature = "temporal")] if crate::temporal::is_temporal_cell_addr(ptr as usize) { - if let Some(s) = crate::temporal::temporal_iso_string(value) { + if let Some(s) = crate::temporal::hooked::iso_string(value) { return crate::string::js_string_from_bytes(s.as_ptr(), s.len() as u32); } } diff --git a/crates/perry-runtime/src/value/to_string_radix.rs b/crates/perry-runtime/src/value/to_string_radix.rs index 10f38cefaf..d699142b05 100644 --- a/crates/perry-runtime/src/value/to_string_radix.rs +++ b/crates/perry-runtime/src/value/to_string_radix.rs @@ -234,9 +234,8 @@ pub extern "C" fn js_jsvalue_to_string_radix( // the codegen routes any single-arg `.toString(x)` here. Dispatch back to // the Temporal method router so the options bag flows through, instead of // ToNumber-coercing it as a radix (which throws a spurious RangeError). - #[cfg(feature = "temporal")] if crate::temporal::is_temporal_value(value) { - let result = crate::temporal::dispatch::call_method(value, "toString", &[radix_value]); + let result = crate::temporal::hooked::call_method(value, "toString", &[radix_value]); let rv = JSValue::from_bits(result.to_bits()); if rv.is_string() { return rv.as_string_ptr() as *mut crate::string::StringHeader; diff --git a/crates/perry-stdlib/src/common/feature_hooks.rs b/crates/perry-stdlib/src/common/feature_hooks.rs index 4bff46090e..c65494eccf 100644 --- a/crates/perry-stdlib/src/common/feature_hooks.rs +++ b/crates/perry-stdlib/src/common/feature_hooks.rs @@ -29,42 +29,9 @@ //! spell out regardless of the order features are installed in, and every //! install is idempotent. -use std::marker::PhantomData; -use std::sync::atomic::{AtomicUsize, Ordering}; - -/// One function-pointer slot. `F` must be a plain `fn` pointer type. -pub(crate) struct Hook { - bits: AtomicUsize, - _f: PhantomData, -} - -impl Hook { - pub(crate) const fn empty() -> Self { - Self { - bits: AtomicUsize::new(0), - _f: PhantomData, - } - } - - #[inline] - pub(crate) fn set(&self, f: F) { - const { assert!(std::mem::size_of::() == std::mem::size_of::()) }; - // SAFETY: `F` is a fn pointer of pointer size (asserted above). - let bits: usize = unsafe { std::mem::transmute_copy(&f) }; - self.bits.store(bits, Ordering::Release); - } - - #[inline] - pub(crate) fn get(&self) -> Option { - let bits = self.bits.load(Ordering::Acquire); - if bits == 0 { - None - } else { - // SAFETY: only `set` stores non-zero bits, and it stores an `F`. - Some(unsafe { std::mem::transmute_copy(&bits) }) - } - } -} +/// The runtime's hook slot type, shared so both crates' hubs use one +/// implementation. +pub(crate) use perry_runtime::feature_hooks::Hook; /// A hub arm that may claim a method call on a native handle. pub(crate) type MethodArm = unsafe fn(i64, &str, &[f64]) -> Option; diff --git a/crates/perry/src/commands/compile/optimized_libs.rs b/crates/perry/src/commands/compile/optimized_libs.rs index 50650b8579..dceee1e111 100644 --- a/crates/perry/src/commands/compile/optimized_libs.rs +++ b/crates/perry/src/commands/compile/optimized_libs.rs @@ -66,7 +66,11 @@ pub struct OptimizedLibs { /// Which stdlib feature installs the generated /// `perry_stdlib_feature_installer` calls (see `stdlib_installs.rs`). /// `Compiled` unless the link uses the prebuilt full-feature stdlib. - pub stdlib_installs: crate::commands::stdlib_installs::StdlibInstalls, + pub stdlib_installs: crate::commands::stdlib_installs::FeatureInstalls, + /// Which runtime feature installs the generated installer calls; the + /// runtime counterpart of `stdlib_installs`. `Compiled` unless the link + /// uses a prebuilt full-feature runtime archive. + pub runtime_installs: crate::commands::stdlib_installs::FeatureInstalls, } impl OptimizedLibs { @@ -79,7 +83,8 @@ impl OptimizedLibs { extra_bc: Vec::new(), well_known_libs: Vec::new(), prefer_well_known_before_stdlib: false, - stdlib_installs: crate::commands::stdlib_installs::StdlibInstalls::Compiled, + stdlib_installs: crate::commands::stdlib_installs::FeatureInstalls::Compiled, + runtime_installs: crate::commands::stdlib_installs::FeatureInstalls::Compiled, } } } diff --git a/crates/perry/src/commands/compile/optimized_libs/driver.rs b/crates/perry/src/commands/compile/optimized_libs/driver.rs index 9731cd114f..13d5512541 100644 --- a/crates/perry/src/commands/compile/optimized_libs/driver.rs +++ b/crates/perry/src/commands/compile/optimized_libs/driver.rs @@ -649,12 +649,27 @@ pub(crate) fn build_optimized_libs( // A deferred dynamic-code site can reach a module by runtime // string, so such programs keep installing everything. stdlib_installs: if perry_hir::has_deferred_dynamic_code_sites() { - crate::commands::stdlib_installs::StdlibInstalls::Compiled + crate::commands::stdlib_installs::FeatureInstalls::Compiled } else { - crate::commands::stdlib_installs::StdlibInstalls::Selected( + crate::commands::stdlib_installs::FeatureInstalls::Selected( features.iter().map(|f| f.to_string()).collect(), ) }, + // Both runtime fallbacks here (the prebuilt `panic=abort` + // variant, or `None` = the prebuilt `libperry_runtime.a`) carry + // every runtime feature too: install the ones an auto-optimized + // rebuild would compile for this program. + runtime_installs: if perry_hir::has_deferred_dynamic_code_sites() { + crate::commands::stdlib_installs::FeatureInstalls::Compiled + } else { + crate::commands::stdlib_installs::FeatureInstalls::Selected( + auto_optimized_cross_features(ctx, &features, cli_features) + .iter() + .filter_map(|f| f.strip_prefix("perry-runtime/")) + .map(str::to_string) + .collect(), + ) + }, ..OptimizedLibs::empty() }; } @@ -816,7 +831,8 @@ pub(crate) fn build_optimized_libs( extra_bc: Vec::new(), prefer_well_known_before_stdlib: !well_known_libs.is_empty(), well_known_libs, - stdlib_installs: crate::commands::stdlib_installs::StdlibInstalls::Compiled, + stdlib_installs: crate::commands::stdlib_installs::FeatureInstalls::Compiled, + runtime_installs: crate::commands::stdlib_installs::FeatureInstalls::Compiled, }; } @@ -1309,6 +1325,7 @@ pub(crate) fn build_optimized_libs( extra_bc, prefer_well_known_before_stdlib: !well_known_libs.is_empty(), well_known_libs, - stdlib_installs: crate::commands::stdlib_installs::StdlibInstalls::Compiled, + stdlib_installs: crate::commands::stdlib_installs::FeatureInstalls::Compiled, + runtime_installs: crate::commands::stdlib_installs::FeatureInstalls::Compiled, } } diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index ae4f6b9c4e..688e3a24f1 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -6383,23 +6383,31 @@ pub fn run_with_parse_cache( .clone() .or_else(|| find_stdlib_library(target.as_deref())); - // perry-stdlib's optional features install through the installer this - // object registers (see `stdlib_installs.rs`): everything the archive was - // compiled with for an auto-optimized archive, only this program's - // features for the prebuilt full-feature one. Generated before the stub - // scan below so the scan sees its install references resolved by the - // stdlib archive. - if ctx.needs_stdlib && stdlib_lib_resolved.is_some() { - let install_symbols = - crate::commands::stdlib_installs::installer_callees(&optimized_libs.stdlib_installs); + // Optional runtime features — and perry-stdlib's, when the stdlib is + // linked — install through the installers this object registers (see + // `stdlib_installs.rs`): everything the archive was compiled with for an + // auto-optimized archive, only this program's features for a prebuilt + // full-feature one. Generated before the stub scan below so the scan sees + // its install references resolved by the runtime/stdlib archives. + { + let runtime_symbols = crate::commands::stdlib_installs::runtime_installer_callees( + &optimized_libs.runtime_installs, + ); + let stdlib_symbols = (ctx.needs_stdlib && stdlib_lib_resolved.is_some()).then(|| { + crate::commands::stdlib_installs::installer_callees(&optimized_libs.stdlib_installs) + }); if matches!(format, OutputFormat::Text) && verbose > 0 { - eprintln!(" stdlib installs: {}", install_symbols.join(", ")); + eprintln!(" runtime installs: {}", runtime_symbols.join(", ")); + if let Some(stdlib) = &stdlib_symbols { + eprintln!(" stdlib installs: {}", stdlib.join(", ")); + } } - let installer_bytes = perry_codegen::stubs::generate_stdlib_installer_object( - &install_symbols, + let installer_bytes = perry_codegen::stubs::generate_feature_installer_object( + &runtime_symbols, + stdlib_symbols.as_deref(), target.as_deref(), )?; - let installer_path = object_output_dir.join("_perry_stdlib_installs.o"); + let installer_path = object_output_dir.join("_perry_feature_installs.o"); fs::write(&installer_path, &installer_bytes)?; obj_cleanup_paths.push(installer_path.clone()); obj_paths.push(installer_path); diff --git a/crates/perry/src/commands/stdlib_installs.rs b/crates/perry/src/commands/stdlib_installs.rs index 0c37ab21bf..ec4a3eccf8 100644 --- a/crates/perry/src/commands/stdlib_installs.rs +++ b/crates/perry/src/commands/stdlib_installs.rs @@ -4,7 +4,7 @@ //! slots that each feature fills from its own `js_stdlib_install_*` entry point //! (perry-stdlib `common::feature_hooks`). Whenever the stdlib is linked, the //! link step generates an object (perry-codegen -//! `stubs::generate_stdlib_installer_object`) whose static constructor +//! `stubs::generate_feature_installer_object`) whose static constructor //! registers an installer calling the entry points chosen here; //! `js_stdlib_init_dispatch` runs it. //! @@ -23,7 +23,7 @@ pub const INSTALL_COMPILED_SYMBOL: &str = "js_stdlib_install_compiled"; /// What the generated installer should do. #[derive(Debug, Clone, PartialEq, Eq, Default)] -pub enum StdlibInstalls { +pub enum FeatureInstalls { /// Install every compiled feature (auto-optimized or opted-out links, and /// programs whose dynamic code the compiler cannot see through). #[default] @@ -135,10 +135,55 @@ pub fn install_symbols(features: &BTreeSet) -> Vec { } /// The install entry points the generated installer calls for `installs`. -pub fn installer_callees(installs: &StdlibInstalls) -> Vec { +pub fn installer_callees(installs: &FeatureInstalls) -> Vec { match installs { - StdlibInstalls::Compiled => vec![INSTALL_COMPILED_SYMBOL.to_string()], - StdlibInstalls::Selected(features) => install_symbols(features), + FeatureInstalls::Compiled => vec![INSTALL_COMPILED_SYMBOL.to_string()], + FeatureInstalls::Selected(features) => install_symbols(features), + } +} + +/// Installs every runtime feature the linked `libperry_runtime.a` was +/// compiled with. +pub const RUNTIME_INSTALL_COMPILED_SYMBOL: &str = "js_runtime_install_compiled"; + +/// perry-runtime's installable features, as [`INSTALLS`] is for perry-stdlib: +/// each entry point and the perry-runtime Cargo features whose `[features]` +/// closure contains it (checked against `crates/perry-runtime/Cargo.toml` by +/// `runtime_triggers_match_cargo_feature_closure`). The runtime's always-live +/// hubs reach these features only through slots the installs fill +/// (perry-runtime `feature_hooks`). +const RUNTIME_INSTALLS: &[(&str, &[&str])] = &[ + ("js_runtime_install_dyn_eval", &["default", "dyn-eval"]), + ( + "js_runtime_install_bun_cli_utils", + &["bun-cli-utils", "default"], + ), + ( + "js_runtime_install_intl_namespace", + &["default", "intl-namespace"], + ), + ("js_runtime_install_temporal", &["default", "temporal"]), + ( + "js_runtime_install_intl_datetime", + &["default", "intl-datetime"], + ), + ( + "js_runtime_install_regex_engine", + &["default", "regex-engine"], + ), + ("js_runtime_install_url_engine", &["default", "url-engine"]), +]; + +/// The runtime install entry points the generated installer calls for +/// `installs` (perry-runtime features, without the `perry-runtime/` prefix). +pub fn runtime_installer_callees(installs: &FeatureInstalls) -> Vec { + match installs { + FeatureInstalls::Compiled => vec![RUNTIME_INSTALL_COMPILED_SYMBOL.to_string()], + FeatureInstalls::Selected(features) => RUNTIME_INSTALLS + .iter() + .filter(|(_, triggers)| triggers.iter().any(|t| features.contains(*t))) + .map(|(symbol, _)| (*symbol).to_string()) + .collect(), } } @@ -151,8 +196,12 @@ mod tests { /// the workspace copy. Only same-crate entries (no `dep:` / `x/y`) matter /// for the closure. fn stdlib_feature_table() -> Option>> { + feature_table("perry-stdlib") + } + + fn feature_table(krate: &str) -> Option>> { let path = - std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../perry-stdlib/Cargo.toml"); + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("../{krate}/Cargo.toml")); let text = std::fs::read_to_string(path).ok()?; let doc: toml::Table = text.parse().ok()?; let features = doc.get("features")?.as_table()?; @@ -269,8 +318,60 @@ mod tests { ] ); assert_eq!( - installer_callees(&StdlibInstalls::Compiled), + installer_callees(&FeatureInstalls::Compiled), vec![INSTALL_COMPILED_SYMBOL.to_string()] ); } + + #[test] + fn runtime_triggers_match_cargo_feature_closure() { + let Some(table) = feature_table("perry-runtime") else { + return; + }; + for (symbol, triggers) in RUNTIME_INSTALLS { + let installed = symbol + .strip_prefix("js_runtime_install_") + .unwrap() + .replace('_', "-"); + let expected: BTreeSet = table + .keys() + .filter(|f| closure(&table, f).contains(&installed)) + .cloned() + .collect(); + let listed: BTreeSet = triggers.iter().map(|t| t.to_string()).collect(); + assert_eq!( + listed, expected, + "{symbol}: trigger list must equal every perry-runtime feature whose closure \ + contains `{installed}` (update RUNTIME_INSTALLS after changing Cargo.toml [features])" + ); + } + } + + #[test] + fn every_runtime_install_symbol_is_defined_by_perry_runtime() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../perry-runtime/src/feature_hooks.rs"); + let Ok(source) = std::fs::read_to_string(path) else { + return; + }; + for (symbol, _) in RUNTIME_INSTALLS { + assert!( + source.contains(&format!("pub extern \"C\" fn {symbol}()")), + "{symbol} is listed here but perry-runtime does not define it" + ); + } + for line in source.lines() { + if let Some(rest) = line + .trim() + .strip_prefix("pub extern \"C\" fn js_runtime_install_") + { + let symbol = format!("js_runtime_install_{}", rest.split('(').next().unwrap()); + assert!( + symbol == RUNTIME_INSTALL_COMPILED_SYMBOL + || RUNTIME_INSTALLS.iter().any(|(s, _)| *s == symbol), + "perry-runtime defines {symbol} but RUNTIME_INSTALLS does not list it" + ); + } + } + } } From dcea149f65cd73dcf678dcbeb395ac33576171e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 28 Sep 2026 01:44:35 +0000 Subject: [PATCH 08/13] changelog: key the runtime link-time features fragment to PR 11605 --- ...-link-time-features.md => 11605-runtime-link-time-features.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{runtime-link-time-features.md => 11605-runtime-link-time-features.md} (100%) diff --git a/changelog.d/runtime-link-time-features.md b/changelog.d/11605-runtime-link-time-features.md similarity index 100% rename from changelog.d/runtime-link-time-features.md rename to changelog.d/11605-runtime-link-time-features.md From c21cf8692a91654980e271a4e4390d1779472cf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 28 Sep 2026 09:32:50 +0000 Subject: [PATCH 09/13] lint: the feature Hook verdict names its new home in perry-runtime --- scripts/thread_exit_address_globals.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/thread_exit_address_globals.json b/scripts/thread_exit_address_globals.json index 9170d11a7b..5b300e236b 100644 --- a/scripts/thread_exit_address_globals.json +++ b/scripts/thread_exit_address_globals.json @@ -19,7 +19,7 @@ { "type": "Hook", "verdict": "no_heap_address", - "why": "perry-stdlib's link-time feature slot (common/feature_hooks.rs): an AtomicUsize holding only the bits of a plain `fn` pointer (set() asserts size_of::() == usize and stores a code address; 0 = uninstalled). It never holds a heap or arena address, so thread exit cannot leave it dangling." + "why": "the link-time feature slot (perry-runtime/src/feature_hooks.rs, also used by perry-stdlib): an AtomicUsize holding only the bits of a plain `fn` pointer (set() asserts size_of::() == usize and stores a code address; 0 = uninstalled). It never holds a heap or arena address, so thread exit cannot leave it dangling." } ], "entries": [ From de738483fadf01cb4b34cad046ca44eaaf7c9f04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 28 Sep 2026 11:04:18 +0000 Subject: [PATCH 10/13] gates: follow the interpreter root scanner through its feature slot; intl.rs under the size cap; wasm ABI table - gc_runtime_root_holders: a registered scanner that forwards through a feature Hook slot (SLOT.get() then a call) now seeds the SLOT.set(path) targets in the same file, so the interpreter scanner behind dyn_eval_hooks::scan_dyn_eval_roots_mut keeps covering the dyn_eval key caches; a planted self-test case covers it (and fails with the rule disabled). Coverage counts equal main exactly (693 reached, 446 classified). - PASS1_MARKED: re-audited the gc/mod.rs change (forwarder registration and the startup feature installer call; neither is in the mark-complete to sweep-entry window) and re-pinned. - intl.rs: the Intl namespace forwarder, its slot and the feature install move to intl/hooked.rs (2004 -> 1982 lines). - runtime_abi.tsv: the js_runtime_install_* / register symbols. --- .../perry-codegen/src/wasm32/runtime_abi.tsv | 9 +++ crates/perry-runtime/src/intl.rs | 30 +------ crates/perry-runtime/src/intl/hooked.rs | 25 +++++- scripts/gc_runtime_root_holders.json | 4 +- scripts/gc_runtime_root_holders.py | 78 ++++++++++++++++++- 5 files changed, 113 insertions(+), 33 deletions(-) diff --git a/crates/perry-codegen/src/wasm32/runtime_abi.tsv b/crates/perry-codegen/src/wasm32/runtime_abi.tsv index 059313f6b0..3b67eddd93 100644 --- a/crates/perry-codegen/src/wasm32/runtime_abi.tsv +++ b/crates/perry-codegen/src/wasm32/runtime_abi.tsv @@ -3358,6 +3358,15 @@ js_run_ext_pump void js_run_module_init_catching void i64 js_run_stdlib_pump void js_runtime_init void +js_runtime_install_bun_cli_utils void +js_runtime_install_compiled void +js_runtime_install_dyn_eval void +js_runtime_install_intl_datetime void +js_runtime_install_intl_namespace void +js_runtime_install_regex_engine void +js_runtime_install_temporal void +js_runtime_install_url_engine void +js_runtime_register_feature_installer void ptr js_runtime_validate_crypto_key_arg void f64,ptr,i32u js_runtime_validate_integer_arg void f64,ptr,i32u,f64,f64 js_runtime_validate_string_arg void f64,ptr,i32u diff --git a/crates/perry-runtime/src/intl.rs b/crates/perry-runtime/src/intl.rs index 649c69f017..f20d6e538f 100644 --- a/crates/perry-runtime/src/intl.rs +++ b/crates/perry-runtime/src/intl.rs @@ -1779,34 +1779,12 @@ fn set_proto_to_string_tag(proto: *mut ObjectHeader, tag: &str) { ); } -/// Install the `Intl.*` namespace members. Behind `intl-namespace` (default-on; -/// the compiler enables it whenever the program mentions `Intl` or any -/// locale-formatting API): when the feature is off this is a no-op, the -/// `Intl` global is still a real (empty) namespace object, and `-dead_strip` -/// reclaims the constructor/option/format machinery that nothing else -/// reaches. `toLocale*` / `localeCompare` are unaffected — their entry points -/// and helpers live outside this gate. -/// -/// `globalThis` population is live in every program, so it reaches the members -/// only through a slot the `intl-namespace` install fills (see -/// `crate::feature_hooks`); an empty slot leaves the namespace empty, exactly -/// as a build without the feature does. -pub fn install_intl_namespace(ns_obj: *mut ObjectHeader) { - if let Some(install) = INTL_NAMESPACE_MEMBERS.get() { - install(ns_obj); - } -} - -static INTL_NAMESPACE_MEMBERS: crate::feature_hooks::Hook = - crate::feature_hooks::Hook::empty(); - -/// The `intl-namespace` install. +pub use hooked::install_intl_namespace; #[cfg(feature = "intl-namespace")] -pub(crate) fn install_intl_namespace_feature() { - INTL_NAMESPACE_MEMBERS.set(install_intl_namespace_members); - hooked::install(); -} +pub(crate) use hooked::install_intl_namespace_feature; +/// The `Intl.*` members `install_intl_namespace` adds once the +/// `intl-namespace` install has filled its slot. #[cfg(feature = "intl-namespace")] fn install_intl_namespace_members(ns_obj: *mut ObjectHeader) { if ns_obj.is_null() { diff --git a/crates/perry-runtime/src/intl/hooked.rs b/crates/perry-runtime/src/intl/hooked.rs index a29ecd08f1..cbc94c77c9 100644 --- a/crates/perry-runtime/src/intl/hooked.rs +++ b/crates/perry-runtime/src/intl/hooked.rs @@ -11,6 +11,7 @@ //! formatting falls back to the plain ECMA-262 rendering. use crate::feature_hooks::Hook; +use crate::object::ObjectHeader; use crate::string::StringHeader; static INSTANCEOF: Hook Option> = Hook::empty(); @@ -18,6 +19,25 @@ static IS_CONSTRUCTOR_VALUE: Hook bool> = Hook::empty(); static SUBCLASS_SUPER: Hook bool> = Hook::empty(); static NUMBER_TO_LOCALE_STRING: Hook *mut StringHeader> = Hook::empty(); static BIGINT_TO_LOCALE_STRING: Hook *mut StringHeader> = Hook::empty(); +static INTL_NAMESPACE_MEMBERS: Hook = Hook::empty(); + +/// Install the `Intl.*` namespace members. Behind `intl-namespace` (default-on; +/// the compiler enables it whenever the program mentions `Intl` or any +/// locale-formatting API): when the feature is off this is a no-op, the +/// `Intl` global is still a real (empty) namespace object, and `-dead_strip` +/// reclaims the constructor/option/format machinery that nothing else +/// reaches. `toLocale*` / `localeCompare` are unaffected — their entry points +/// and helpers live outside this gate. +/// +/// `globalThis` population is live in every program, so it reaches the members +/// only through a slot the `intl-namespace` install fills (see +/// `crate::feature_hooks`); an empty slot leaves the namespace empty, exactly +/// as a build without the feature does. +pub fn install_intl_namespace(ns_obj: *mut ObjectHeader) { + if let Some(install) = INTL_NAMESPACE_MEMBERS.get() { + install(ns_obj); + } +} pub(crate) fn intl_instanceof(value: f64, type_ref: f64) -> Option { INSTANCEOF.get().and_then(|f| f(value, type_ref)) @@ -62,9 +82,10 @@ pub(crate) fn bigint_to_locale_string( .map(|f| f(value, locales, options)) } -/// The hub half of the `intl-namespace` install. +/// The `intl-namespace` install. #[cfg(feature = "intl-namespace")] -pub(crate) fn install() { +pub(crate) fn install_intl_namespace_feature() { + INTL_NAMESPACE_MEMBERS.set(super::install_intl_namespace_members); INSTANCEOF.set(super::intl_instanceof); IS_CONSTRUCTOR_VALUE.set(super::is_intl_constructor_value); SUBCLASS_SUPER.set(super::intl_subclass_super); diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 04faee59c5..e7e19b371b 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -386,7 +386,7 @@ "file": "crates/perry-runtime/src/gc/census.rs", "name": "PASS1_MARKED", "verdict": "non_moving_snapshot", - "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete \u2192 sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs \u2014 it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase \u2014 after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged \u2014 `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` \u2014 and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` \u2192 `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only \u2014 no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound \u2014 the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses \u2014 no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects \u2014 and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module \u2014 all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete \u2192 sweep-entry window of a synchronous full \u2014 where PASS1_MARKED is populated and consumed within one `run_to_completion` \u2014 is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize \u2014 INSIDE the window \u2014 the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-13 for #10182's full-collection throughput follow-up, which touched `gc/cycle.rs` in one hunk, INSIDE the window: the `RememberedSetRebuild` subphase of a synchronous full now first asks `verify::full_remembered_rebuild_provably_empty` and, when it holds, installs `OldToYoungRememberedRebuildState::provably_empty()` (an empty sticky set, no walk) instead of the require-marked rebuild. The predicate reads `arena_block_snapshots()` (one `Vec` through the global allocator), the census's per-block reached/pre-marked facts and the malloc registry's length; the constructor bumps a `Cell` counter and prints one line under `PERRY_GC_DIAG`. None of it allocates a GC object, relocates anything, collects, or runs a JS callback, and both census boundaries stay where they were. Re-audited 2026-09-14 for #10182's pacing-full work, which touched `gc/cycle.rs`, `gc/mod.rs` and `gc/policy.rs`. `gc/cycle.rs`: `GcCycleState::new_full` no longer calls `materialize_all_promoted_page_runs`; that call ran in the constructor, before the census and far before `census_pass1_if_armed`, and removing it adds nothing to the window. `gc/mod.rs`: one `mod promoted_cohort;` declaration. `gc/policy.rs`: (a) `credit_promoted_bytes_to_old_baseline` also credits a `Cell` cohort counter (it runs after a copying minor completes); (b) `finish_full_old_reclaim_baseline` also records the verified old live bytes and resets that counter (Publish, after `step_sweep` consumed the snapshot); (c) `gc_safepoint_moving_minor` arms and disarms the promotion-census record around its nursery minor and calls `run_promoted_cohort_full_if_due`, which starts a synchronous full through the same `gc_collect_full_mark_sweep_with_trigger` entry and reads byte counters before and after it. All of it runs before a cycle starts or after it completes; none of it runs between mark completion and sweep entry, allocates a GC object, relocates anything, or calls into JS. The census the promoted-cohort full may adopt from the promotion walk is built in `BuildValidPointerSet`, before either boundary. Both boundaries are unchanged. Re-audited 2026-09-14 for the #10182 dead-stack scrub in `gc/cycle.rs`: `step_build_valid_pointer_set` now calls `scrub_dead_stack_below`, which zeroes a local array in its own frame (dead stack below the caller), right after the census finishes \u2014 in `BuildValidPointerSet`, before the root scan and far before `census_pass1_if_armed`. It writes no heap memory, allocates nothing, relocates nothing and calls no JS; both boundaries are unchanged. Re-audited 2026-09-14 for #10241 (cohort survival), which touched `gc/cycle.rs` and `gc/policy.rs`. `gc/cycle.rs`: one call, `promoted_cohort::survival::check_minor_view_at_full_sweep_start()`, in `step_sweep` immediately AFTER `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS, i.e. outside the window. It is a no-op unless a promoted-cohort full armed its survival probe; when armed it walks the old page index over the preceding minor's dirty pages (`old_arena_walk_objects_on_pages`, Rust-allocator Vecs), reads GC headers' mark flags and the slots of unmarked ones, and records one enum. It writes no heap memory, allocates no GC object, relocates nothing and calls no JS. `gc/policy.rs`: `run_promoted_cohort_full_if_due` arms the probe before `gc_collect_full_mark_sweep_with_trigger` and takes it after the full returns (feeding `note_full_measured_promotion_survival` and one diagnostic line); both run before a cycle starts or after it completes. Both boundaries are unchanged. Re-audited 2026-09-14 for #10241's in-place-only cohort: `gc/policy.rs` drops the `promoted_cohort::note_promoted` call from `credit_promoted_bytes_to_old_baseline` (the copying minor now calls `promoted_cohort::note_minor_promotion` itself, after the credit). Both run at the end of a copying minor, outside any full cycle; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-14 for the parse-boundary side-allocation band (medium-parse pacing), which touched `gc/policy.rs`. Three hunks: (a) a `Cell` thread-local (`GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES`, a byte COUNT, no pointer) plus three pure predicates over it and `external_side_live_bytes()`; (b) that predicate added as a third disjunct of `tiny_parse_generational_collection_due`, which is read only from the three JSON.parse mutator-side boundaries (`gc_bump_malloc_trigger_inner`, `gc_collect_pending_suppressed_parse_slow`, `gc_schedule_parse_boundary_collection_if_pressure`), none of them reachable from `step_mark_propagation` or `step_sweep`; and (c) one extra `Cell` store in `note_collection_finished_arena_occupancy` plus two extra reads in the `PERRY_GC_DIAG` tiny-parse line. `note_collection_finished_arena_occupancy` runs from `publish_reclaim_outcome` in the Publish subphase, i.e. AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local, exactly as #9831's store on the same line does. Nothing added allocates a GC object, relocates anything, or runs a JS callback, and neither census boundary moved. Re-audited 2026-09-14 for the drained-bytes counterweight to that band, which touched `gc/policy.rs` again. Four hunks: a second `Cell` thread-local (`GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL`, a byte COUNT); one increment of it inside `gc_note_external_side_free`; a pure read (`external_side_old_reclaim_pressure_bytes`) substituted for `external_side_live_bytes()` at the four old-reclaim pressure sites; and one `Cell` store at the top of `finish_full_old_reclaim_baseline`. None of it can run between the census boundaries. `gc_note_external_side_free` is also reached by mutator-side tape materialization, regex scratch teardown, native-addon adjustments and buffer replacement. Its added operation is only a saturating increment of a scalar Cell, with no GC allocation, relocation, collection or JS callback, so this wider caller set does not invalidate the census window. `finish_full_old_reclaim_baseline` runs from `publish_reclaim_outcome` in the Publish subphase, the same place #9831's store already sits. The pressure reads happen at trigger decisions, before a cycle starts. No allocation, relocation, collection or JS callback is added to the mark-complete -> sweep-entry window, and neither boundary moved. Re-audited 2026-09-15 for turnloop P0, which touched `gc/mod.rs` with one added call: `crate::event_pump::shutdown_wait_driver()` inside `js_gc_release_current_thread_collection_side_allocations`, the process-exit funnel. That function runs once no more JavaScript can run on the thread, never from inside a collection cycle; the added call drops the thread's turnloop wait loop (closing its kqueue/epoll descriptor) and may print a diagnostic line. It allocates no GC object, relocates nothing, starts no collection and runs no JS callback. The census boundaries and the mark-complete -> sweep-entry window are untouched.. Re-audited 2026-09-16 for the copying minor's per-parent weak-holder fact: `gc/mod.rs` gains exactly one line, `mod copying_parent_facts;`, a module declaration. The module it declares holds `weak_holder_fact` (a read of the parent's `obj_type`/`class_id` via `weakref::is_weak_holder_header`) and the copying minor's `visit_slot_with_parent`, moved verbatim out of `gc/copying.rs` for the 2000-line lint. Both run only inside a COPYING MINOR, which skips both census boundaries (`census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` are synchronous-full only). Nothing was added to any full-cycle phase, and the declaration itself executes no code. Neither boundary moved and the synchronous mark-complete to sweep-entry window gains no allocation, relocation, collection or JS callback. Re-audited 2026-09-18 for the #10532 follow-up argument-list rooting fix, which touched `gc/mod.rs`. The only change there is `mod collection_points;` plus a `pub(crate) use collection_points::collection_point;` re-export (and, under `#[cfg(test)]`, `arm_collection_point`). `collection_point` is an inline no-op outside `cfg(test)`; under test it only runs a copying minor when called from ordinary MUTATOR code (`proxy.rs`'s `Reflect.apply` rebind path and `registry.rs`'s rest-array bundler), never from inside `step_mark_propagation` or `step_sweep`. Neither `census_pass1_if_armed` nor `census_take_if_armed_at_full_sweep_start` is reachable from it, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-18 (same PR, round 2) for the added `arm_collection_point_after` re-export in `gc/mod.rs`: another pure re-export line, same as the `collection_point`/`arm_collection_point` one already covered above. `arm_collection_point_after` only changes test-only arming state in `collection_points.rs` (which named site fires and on which hit); it still runs no mark/sweep control flow. Re-audited 2026-09-19 for #10735 (require.main threading): gc/mod.rs gains exactly one line, `reg_scanner!(crate::module_require::scan_cjs_main_module_root_mut);`, registering the new CJS_MAIN_MODULE thread-local's mutable-root scanner beside the existing `scan_module_path_roots_mut` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it runs during root scanning, before mark propagation completes, and does not execute between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start`. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-20 for #10834 (inherited-property read cache). `gc/mod.rs` gains exactly one line: `reg_scanner!(crate::object::inherited_read_cache::scan_inherited_read_cache_roots_mut);` in `gc_init()`. A scanner registration adds a root SOURCE for the mutable-root walks. The walk runs inside `RootScanCycleState::step_current_subphase`, i.e. entirely within the RootScan phase: `step_root_scan` only sets `self.phase = GcCyclePhase::MarkPropagation` once that loop reports done (`gc/cycle.rs:958-961`), and `census_pass1_if_armed()` fires at the END of `step_mark_propagation` (`gc/cycle.rs:982`). The scanner therefore runs strictly BEFORE the window opens and can never execute between the boundaries. Its body is a bounded walk of a fixed 512-entry thread-local array calling `visit_tagged_usize_slot` / `visit_usize_slot`; it allocates nothing, relocates nothing and runs no JS callback. Same shape as #9769, #9976/#9977, #10054, #10055 and #10735, all previously cleared. The PR also adds an `INHERITED_READ_CACHE` entry to `DEAD_KEY_PRUNES` in `gc/dead_owner.rs` (not a pinned source). That registry is consumed by `IncrementalSweepState::with_dead_collection_finalize` at `gc/cycle.rs:1548`, which is AFTER `census_take_if_armed_at_full_sweep_start` at `gc/cycle.rs:1505` has already `take()`n the snapshot out of the thread-local -- the same argument that cleared #9845's `collect_dead_registered_regexps_post_trace`. The prune reads addresses and zeroes entries; no GC allocation, relocation or callback. Both additions sit outside the window, on opposite sides of it. Neither boundary moved and the synchronous mark-complete to sweep-entry interval is unchanged. Re-audited 2026-09-22 for #10399 (per-thread module init), which touched `gc/mod.rs`. Two hunks, both init-time: a new free function `raise_default_thread_stack_floor()` and one call to it at the top of `js_gc_init`, before `enter_current_thread_image`'s successor statements. The function reads `RUST_MIN_STACK` from the environment and, only when it is unset, sets it to 32 MiB so a thread spawned against a multi-megabyte static TLS block still has usable stack (glibc carves static TLS out of the thread's stack mapping). It touches no heap object, allocates no GC object, relocates nothing and runs no JS callback. `js_gc_init` is the first runtime call of a compiled `main`, so it runs once before any cycle exists, and it is not reachable from `step_mark_propagation` or `step_sweep`. Same shape as the 2026-09-11 startup-memory-profile re-audit, which cleared the pre-main allocator-policy constructor in the same function. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-23 (size/runtime-decouple, #11135) after the binary-size branch touched `gc/census.rs`, `gc/mod.rs` and `gc/policy.rs`. census.rs: `census_pass1_if_armed` / `census_take_if_armed_at_full_sweep_start` keep their bodies verbatim, moved into `_impl` functions compiled only with the new `gc-instruments` feature (without it both are empty and `census_path()` is `None`, so nothing is ever armed); the take still empties PASS1_MARKED before `take_census`. gc/mod.rs: `gc_init` gains a startup env check that aborts when an instrument knob is set without the feature, before any cycle exists. gc/policy.rs: env-knob OnceLock caches now initialize through `crate::once_init::get_or_init` (same closures, same values). No mark/sweep control flow between the two census boundaries changed; the window is unchanged. Re-audited for Fetch handle reclamation: cycle.rs only redirects the incomplete-cycle Drop cancellation hook to also cancel the Fetch trace. The full-trace finish hook removes native records and cached slots without allocating GC objects or invoking JS; it cannot relocate the census addresses before sweep entry. Re-audited 2026-09-22 for #10928 (one proportional old-reclaim rule), which touched `gc/policy.rs`. Six hunks. (a) Two new thread-locals, `GC_OLD_RECLAIM_PRE_IN_USE_BYTES` (`Cell`) and `GC_OLD_RECLAIM_BACKOFF_SHIFT` (`Cell`): both are byte/shift COUNTS, neither holds a pointer. (b) `gc_old_reclaim_growth_band_bytes` gains a `Cell` read and a left shift -- pure arithmetic over byte counts. (c) `old_reclaim_pressure_due` loses the #7937 absolute first-crossing arm, splits its pure form out as `old_reclaim_pressure_due_inner`, and calls `note_old_reclaim_cycle_started()` when the answer is true. That predicate is read at TRIGGER decisions only -- the allocation-point `gc_check_trigger` and `gc_budgeted_due_trigger` at safepoints -- i.e. before a cycle starts, never between the boundaries; an allocation inside the window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before this code, the same argument the 2026-09-14 due-check fast-path re-audit made for the same function. Even if it did run there it would be sound: `note_old_reclaim_cycle_started` stores one scalar `Cell` from `pacing_arena_in_use_bytes()` (a read of `arena_live_allocated_bytes`), which allocates no GC object, relocates nothing and runs no JS callback -- the window's contract. (d) `update_old_reclaim_backoff` is called only from `finish_full_old_reclaim_baseline`, which runs from `publish_reclaim_outcome` in the Publish subphase, AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local -- exactly where #9831's store and the medium-parse pacing store already sit. (e) `gc_old_reclaim_debt_bytes` drops the absolute arm it mirrored; it remains pure arithmetic read at debt/trigger decisions. (f) `#[cfg(test)]` seams, absent from production builds. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback between them; the change alters only WHEN a collection is scheduled, never what runs inside one. Neither boundary moved and the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-24 for #10960 (growth-aware old-reclaim backoff), which touched `gc/policy.rs` again. One new thread-local, `GC_OLD_RECLAIM_LAST_POST_IN_USE_BYTES` (`Cell`), a byte COUNT that holds no pointer. It is written only by `update_old_reclaim_backoff`, which runs from `finish_full_old_reclaim_baseline` in the Publish subphase, after `step_sweep` has already taken the snapshot out of the thread-local; the change there is pure integer arithmetic deciding whether to widen the band. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback, and neither window boundary moved. Re-audited 2026-09-24 after the class-capture environment added one `reg_scanner!` registration (`scan_class_env_roots_mut`, visiting each guarded class environment's owner class object) to `gc/mod.rs`: a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-25 after the inherited-access lane touched `gc/mod.rs`: the change is one `reg_scanner!` registration for `object::chain_store::scan_chain_store_roots_mut`, a root scanner that visits store-site chain verdicts (one interned key pointer each) during root scanning. It runs at the start of a cycle, never between mark completion and sweep entry, relocates nothing and runs no JS callback. The synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-25 for #10698 (allocation-point trigger watermark), which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains `pub(crate) mod trigger_watermark;` and a `pub(crate) use` re-export -- no control flow. In `gc/policy.rs`: (a) `gc_check_trigger` becomes an out-of-line wrapper over an inlined fast path that reads the watermark cell, the malloc registry's length through an unguarded shared borrow and the inline allocator's offset, then either returns or runs the previous body unchanged (`gc_check_trigger_evaluate`). An allocation inside the window reaches it with `GC_FLAG_IN_ALLOC` set: the fast path returns without acting, which is the outcome of the `GC_FLAG_IN_ALLOC` early return it would otherwise reach, and the slow path still takes that early return. (b) The due-trigger evaluation also returns a watermark -- integer arithmetic over values it already read -- published only on the no-action return past every entry guard. (c) The ladder's thresholds and flags are retyped `Cell` -> `TriggerInput`, whose writes add one store retiring the watermark; heap-generation advances and `Arena::set_current` retire it too. (d) `#[cfg(test)]` seams. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback; `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-25 for #10498 (class-accessor cache), which adds one `reg_scanner!` registration (`scan_class_accessor_cache_roots_mut`, marking and rewriting the cache's key strings) to `gc/mod.rs`: again a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-26 for #10572: `gc/mod.rs` gains one `#[cfg(not(feature = \"hot-diag\"))]` call to `hot_diag::refuse_knobs_without_hot_diag()` in `gc_init`, a startup-only knob check that runs before any cycle and alters no mark/sweep control flow; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-27 for #11416: `gc/mod.rs` loses the `reg_scanner!` registration for `class_accessor_cache::scan_class_accessor_cache_roots_mut` (the cache is deleted); removing a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-27 for the method-calls lane, which adds one `reg_scanner!` registration (`scan_method_site_roots_mut`, marking and rewriting the method closures inherited method-site entries hold) to `gc/mod.rs`: a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window.", + "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete \u2192 sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs \u2014 it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase \u2014 after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged \u2014 `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` \u2014 and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` \u2192 `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only \u2014 no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound \u2014 the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses \u2014 no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects \u2014 and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module \u2014 all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete \u2192 sweep-entry window of a synchronous full \u2014 where PASS1_MARKED is populated and consumed within one `run_to_completion` \u2014 is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize \u2014 INSIDE the window \u2014 the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-13 for #10182's full-collection throughput follow-up, which touched `gc/cycle.rs` in one hunk, INSIDE the window: the `RememberedSetRebuild` subphase of a synchronous full now first asks `verify::full_remembered_rebuild_provably_empty` and, when it holds, installs `OldToYoungRememberedRebuildState::provably_empty()` (an empty sticky set, no walk) instead of the require-marked rebuild. The predicate reads `arena_block_snapshots()` (one `Vec` through the global allocator), the census's per-block reached/pre-marked facts and the malloc registry's length; the constructor bumps a `Cell` counter and prints one line under `PERRY_GC_DIAG`. None of it allocates a GC object, relocates anything, collects, or runs a JS callback, and both census boundaries stay where they were. Re-audited 2026-09-14 for #10182's pacing-full work, which touched `gc/cycle.rs`, `gc/mod.rs` and `gc/policy.rs`. `gc/cycle.rs`: `GcCycleState::new_full` no longer calls `materialize_all_promoted_page_runs`; that call ran in the constructor, before the census and far before `census_pass1_if_armed`, and removing it adds nothing to the window. `gc/mod.rs`: one `mod promoted_cohort;` declaration. `gc/policy.rs`: (a) `credit_promoted_bytes_to_old_baseline` also credits a `Cell` cohort counter (it runs after a copying minor completes); (b) `finish_full_old_reclaim_baseline` also records the verified old live bytes and resets that counter (Publish, after `step_sweep` consumed the snapshot); (c) `gc_safepoint_moving_minor` arms and disarms the promotion-census record around its nursery minor and calls `run_promoted_cohort_full_if_due`, which starts a synchronous full through the same `gc_collect_full_mark_sweep_with_trigger` entry and reads byte counters before and after it. All of it runs before a cycle starts or after it completes; none of it runs between mark completion and sweep entry, allocates a GC object, relocates anything, or calls into JS. The census the promoted-cohort full may adopt from the promotion walk is built in `BuildValidPointerSet`, before either boundary. Both boundaries are unchanged. Re-audited 2026-09-14 for the #10182 dead-stack scrub in `gc/cycle.rs`: `step_build_valid_pointer_set` now calls `scrub_dead_stack_below`, which zeroes a local array in its own frame (dead stack below the caller), right after the census finishes \u2014 in `BuildValidPointerSet`, before the root scan and far before `census_pass1_if_armed`. It writes no heap memory, allocates nothing, relocates nothing and calls no JS; both boundaries are unchanged. Re-audited 2026-09-14 for #10241 (cohort survival), which touched `gc/cycle.rs` and `gc/policy.rs`. `gc/cycle.rs`: one call, `promoted_cohort::survival::check_minor_view_at_full_sweep_start()`, in `step_sweep` immediately AFTER `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS, i.e. outside the window. It is a no-op unless a promoted-cohort full armed its survival probe; when armed it walks the old page index over the preceding minor's dirty pages (`old_arena_walk_objects_on_pages`, Rust-allocator Vecs), reads GC headers' mark flags and the slots of unmarked ones, and records one enum. It writes no heap memory, allocates no GC object, relocates nothing and calls no JS. `gc/policy.rs`: `run_promoted_cohort_full_if_due` arms the probe before `gc_collect_full_mark_sweep_with_trigger` and takes it after the full returns (feeding `note_full_measured_promotion_survival` and one diagnostic line); both run before a cycle starts or after it completes. Both boundaries are unchanged. Re-audited 2026-09-14 for #10241's in-place-only cohort: `gc/policy.rs` drops the `promoted_cohort::note_promoted` call from `credit_promoted_bytes_to_old_baseline` (the copying minor now calls `promoted_cohort::note_minor_promotion` itself, after the credit). Both run at the end of a copying minor, outside any full cycle; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-14 for the parse-boundary side-allocation band (medium-parse pacing), which touched `gc/policy.rs`. Three hunks: (a) a `Cell` thread-local (`GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES`, a byte COUNT, no pointer) plus three pure predicates over it and `external_side_live_bytes()`; (b) that predicate added as a third disjunct of `tiny_parse_generational_collection_due`, which is read only from the three JSON.parse mutator-side boundaries (`gc_bump_malloc_trigger_inner`, `gc_collect_pending_suppressed_parse_slow`, `gc_schedule_parse_boundary_collection_if_pressure`), none of them reachable from `step_mark_propagation` or `step_sweep`; and (c) one extra `Cell` store in `note_collection_finished_arena_occupancy` plus two extra reads in the `PERRY_GC_DIAG` tiny-parse line. `note_collection_finished_arena_occupancy` runs from `publish_reclaim_outcome` in the Publish subphase, i.e. AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local, exactly as #9831's store on the same line does. Nothing added allocates a GC object, relocates anything, or runs a JS callback, and neither census boundary moved. Re-audited 2026-09-14 for the drained-bytes counterweight to that band, which touched `gc/policy.rs` again. Four hunks: a second `Cell` thread-local (`GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL`, a byte COUNT); one increment of it inside `gc_note_external_side_free`; a pure read (`external_side_old_reclaim_pressure_bytes`) substituted for `external_side_live_bytes()` at the four old-reclaim pressure sites; and one `Cell` store at the top of `finish_full_old_reclaim_baseline`. None of it can run between the census boundaries. `gc_note_external_side_free` is also reached by mutator-side tape materialization, regex scratch teardown, native-addon adjustments and buffer replacement. Its added operation is only a saturating increment of a scalar Cell, with no GC allocation, relocation, collection or JS callback, so this wider caller set does not invalidate the census window. `finish_full_old_reclaim_baseline` runs from `publish_reclaim_outcome` in the Publish subphase, the same place #9831's store already sits. The pressure reads happen at trigger decisions, before a cycle starts. No allocation, relocation, collection or JS callback is added to the mark-complete -> sweep-entry window, and neither boundary moved. Re-audited 2026-09-15 for turnloop P0, which touched `gc/mod.rs` with one added call: `crate::event_pump::shutdown_wait_driver()` inside `js_gc_release_current_thread_collection_side_allocations`, the process-exit funnel. That function runs once no more JavaScript can run on the thread, never from inside a collection cycle; the added call drops the thread's turnloop wait loop (closing its kqueue/epoll descriptor) and may print a diagnostic line. It allocates no GC object, relocates nothing, starts no collection and runs no JS callback. The census boundaries and the mark-complete -> sweep-entry window are untouched.. Re-audited 2026-09-16 for the copying minor's per-parent weak-holder fact: `gc/mod.rs` gains exactly one line, `mod copying_parent_facts;`, a module declaration. The module it declares holds `weak_holder_fact` (a read of the parent's `obj_type`/`class_id` via `weakref::is_weak_holder_header`) and the copying minor's `visit_slot_with_parent`, moved verbatim out of `gc/copying.rs` for the 2000-line lint. Both run only inside a COPYING MINOR, which skips both census boundaries (`census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` are synchronous-full only). Nothing was added to any full-cycle phase, and the declaration itself executes no code. Neither boundary moved and the synchronous mark-complete to sweep-entry window gains no allocation, relocation, collection or JS callback. Re-audited 2026-09-18 for the #10532 follow-up argument-list rooting fix, which touched `gc/mod.rs`. The only change there is `mod collection_points;` plus a `pub(crate) use collection_points::collection_point;` re-export (and, under `#[cfg(test)]`, `arm_collection_point`). `collection_point` is an inline no-op outside `cfg(test)`; under test it only runs a copying minor when called from ordinary MUTATOR code (`proxy.rs`'s `Reflect.apply` rebind path and `registry.rs`'s rest-array bundler), never from inside `step_mark_propagation` or `step_sweep`. Neither `census_pass1_if_armed` nor `census_take_if_armed_at_full_sweep_start` is reachable from it, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-18 (same PR, round 2) for the added `arm_collection_point_after` re-export in `gc/mod.rs`: another pure re-export line, same as the `collection_point`/`arm_collection_point` one already covered above. `arm_collection_point_after` only changes test-only arming state in `collection_points.rs` (which named site fires and on which hit); it still runs no mark/sweep control flow. Re-audited 2026-09-19 for #10735 (require.main threading): gc/mod.rs gains exactly one line, `reg_scanner!(crate::module_require::scan_cjs_main_module_root_mut);`, registering the new CJS_MAIN_MODULE thread-local's mutable-root scanner beside the existing `scan_module_path_roots_mut` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it runs during root scanning, before mark propagation completes, and does not execute between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start`. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-20 for #10834 (inherited-property read cache). `gc/mod.rs` gains exactly one line: `reg_scanner!(crate::object::inherited_read_cache::scan_inherited_read_cache_roots_mut);` in `gc_init()`. A scanner registration adds a root SOURCE for the mutable-root walks. The walk runs inside `RootScanCycleState::step_current_subphase`, i.e. entirely within the RootScan phase: `step_root_scan` only sets `self.phase = GcCyclePhase::MarkPropagation` once that loop reports done (`gc/cycle.rs:958-961`), and `census_pass1_if_armed()` fires at the END of `step_mark_propagation` (`gc/cycle.rs:982`). The scanner therefore runs strictly BEFORE the window opens and can never execute between the boundaries. Its body is a bounded walk of a fixed 512-entry thread-local array calling `visit_tagged_usize_slot` / `visit_usize_slot`; it allocates nothing, relocates nothing and runs no JS callback. Same shape as #9769, #9976/#9977, #10054, #10055 and #10735, all previously cleared. The PR also adds an `INHERITED_READ_CACHE` entry to `DEAD_KEY_PRUNES` in `gc/dead_owner.rs` (not a pinned source). That registry is consumed by `IncrementalSweepState::with_dead_collection_finalize` at `gc/cycle.rs:1548`, which is AFTER `census_take_if_armed_at_full_sweep_start` at `gc/cycle.rs:1505` has already `take()`n the snapshot out of the thread-local -- the same argument that cleared #9845's `collect_dead_registered_regexps_post_trace`. The prune reads addresses and zeroes entries; no GC allocation, relocation or callback. Both additions sit outside the window, on opposite sides of it. Neither boundary moved and the synchronous mark-complete to sweep-entry interval is unchanged. Re-audited 2026-09-22 for #10399 (per-thread module init), which touched `gc/mod.rs`. Two hunks, both init-time: a new free function `raise_default_thread_stack_floor()` and one call to it at the top of `js_gc_init`, before `enter_current_thread_image`'s successor statements. The function reads `RUST_MIN_STACK` from the environment and, only when it is unset, sets it to 32 MiB so a thread spawned against a multi-megabyte static TLS block still has usable stack (glibc carves static TLS out of the thread's stack mapping). It touches no heap object, allocates no GC object, relocates nothing and runs no JS callback. `js_gc_init` is the first runtime call of a compiled `main`, so it runs once before any cycle exists, and it is not reachable from `step_mark_propagation` or `step_sweep`. Same shape as the 2026-09-11 startup-memory-profile re-audit, which cleared the pre-main allocator-policy constructor in the same function. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-23 (size/runtime-decouple, #11135) after the binary-size branch touched `gc/census.rs`, `gc/mod.rs` and `gc/policy.rs`. census.rs: `census_pass1_if_armed` / `census_take_if_armed_at_full_sweep_start` keep their bodies verbatim, moved into `_impl` functions compiled only with the new `gc-instruments` feature (without it both are empty and `census_path()` is `None`, so nothing is ever armed); the take still empties PASS1_MARKED before `take_census`. gc/mod.rs: `gc_init` gains a startup env check that aborts when an instrument knob is set without the feature, before any cycle exists. gc/policy.rs: env-knob OnceLock caches now initialize through `crate::once_init::get_or_init` (same closures, same values). No mark/sweep control flow between the two census boundaries changed; the window is unchanged. Re-audited for Fetch handle reclamation: cycle.rs only redirects the incomplete-cycle Drop cancellation hook to also cancel the Fetch trace. The full-trace finish hook removes native records and cached slots without allocating GC objects or invoking JS; it cannot relocate the census addresses before sweep entry. Re-audited 2026-09-22 for #10928 (one proportional old-reclaim rule), which touched `gc/policy.rs`. Six hunks. (a) Two new thread-locals, `GC_OLD_RECLAIM_PRE_IN_USE_BYTES` (`Cell`) and `GC_OLD_RECLAIM_BACKOFF_SHIFT` (`Cell`): both are byte/shift COUNTS, neither holds a pointer. (b) `gc_old_reclaim_growth_band_bytes` gains a `Cell` read and a left shift -- pure arithmetic over byte counts. (c) `old_reclaim_pressure_due` loses the #7937 absolute first-crossing arm, splits its pure form out as `old_reclaim_pressure_due_inner`, and calls `note_old_reclaim_cycle_started()` when the answer is true. That predicate is read at TRIGGER decisions only -- the allocation-point `gc_check_trigger` and `gc_budgeted_due_trigger` at safepoints -- i.e. before a cycle starts, never between the boundaries; an allocation inside the window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before this code, the same argument the 2026-09-14 due-check fast-path re-audit made for the same function. Even if it did run there it would be sound: `note_old_reclaim_cycle_started` stores one scalar `Cell` from `pacing_arena_in_use_bytes()` (a read of `arena_live_allocated_bytes`), which allocates no GC object, relocates nothing and runs no JS callback -- the window's contract. (d) `update_old_reclaim_backoff` is called only from `finish_full_old_reclaim_baseline`, which runs from `publish_reclaim_outcome` in the Publish subphase, AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local -- exactly where #9831's store and the medium-parse pacing store already sit. (e) `gc_old_reclaim_debt_bytes` drops the absolute arm it mirrored; it remains pure arithmetic read at debt/trigger decisions. (f) `#[cfg(test)]` seams, absent from production builds. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback between them; the change alters only WHEN a collection is scheduled, never what runs inside one. Neither boundary moved and the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-24 for #10960 (growth-aware old-reclaim backoff), which touched `gc/policy.rs` again. One new thread-local, `GC_OLD_RECLAIM_LAST_POST_IN_USE_BYTES` (`Cell`), a byte COUNT that holds no pointer. It is written only by `update_old_reclaim_backoff`, which runs from `finish_full_old_reclaim_baseline` in the Publish subphase, after `step_sweep` has already taken the snapshot out of the thread-local; the change there is pure integer arithmetic deciding whether to widen the band. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback, and neither window boundary moved. Re-audited 2026-09-24 after the class-capture environment added one `reg_scanner!` registration (`scan_class_env_roots_mut`, visiting each guarded class environment's owner class object) to `gc/mod.rs`: a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-25 after the inherited-access lane touched `gc/mod.rs`: the change is one `reg_scanner!` registration for `object::chain_store::scan_chain_store_roots_mut`, a root scanner that visits store-site chain verdicts (one interned key pointer each) during root scanning. It runs at the start of a cycle, never between mark completion and sweep entry, relocates nothing and runs no JS callback. The synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-25 for #10698 (allocation-point trigger watermark), which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains `pub(crate) mod trigger_watermark;` and a `pub(crate) use` re-export -- no control flow. In `gc/policy.rs`: (a) `gc_check_trigger` becomes an out-of-line wrapper over an inlined fast path that reads the watermark cell, the malloc registry's length through an unguarded shared borrow and the inline allocator's offset, then either returns or runs the previous body unchanged (`gc_check_trigger_evaluate`). An allocation inside the window reaches it with `GC_FLAG_IN_ALLOC` set: the fast path returns without acting, which is the outcome of the `GC_FLAG_IN_ALLOC` early return it would otherwise reach, and the slow path still takes that early return. (b) The due-trigger evaluation also returns a watermark -- integer arithmetic over values it already read -- published only on the no-action return past every entry guard. (c) The ladder's thresholds and flags are retyped `Cell` -> `TriggerInput`, whose writes add one store retiring the watermark; heap-generation advances and `Arena::set_current` retire it too. (d) `#[cfg(test)]` seams. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback; `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-25 for #10498 (class-accessor cache), which adds one `reg_scanner!` registration (`scan_class_accessor_cache_roots_mut`, marking and rewriting the cache's key strings) to `gc/mod.rs`: again a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-26 for #10572: `gc/mod.rs` gains one `#[cfg(not(feature = \"hot-diag\"))]` call to `hot_diag::refuse_knobs_without_hot_diag()` in `gc_init`, a startup-only knob check that runs before any cycle and alters no mark/sweep control flow; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-27 for #11416: `gc/mod.rs` loses the `reg_scanner!` registration for `class_accessor_cache::scan_class_accessor_cache_roots_mut` (the cache is deleted); removing a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-27 for the method-calls lane, which adds one `reg_scanner!` registration (`scan_method_site_roots_mut`, marking and rewriting the method closures inherited method-site entries hold) to `gc/mod.rs`: a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-28 for #11605 (link-time runtime feature installs): `gc/mod.rs` registers the interpreter root scanner through its always-present forwarder (`dyn_eval_hooks::scan_dyn_eval_roots_mut`, which calls the real scanner through a slot the `dyn-eval` install fills) instead of a feature-gated direct registration, and `js_gc_init` ends by running the program\u2019s feature installer, which only stores fn pointers into `Hook` slots at startup, before any user code or collection. Neither alters mark/sweep control flow or runs inside the mark-complete to sweep-entry window.", "window": { "start": { "file": "crates/perry-runtime/src/gc/census.rs", @@ -403,7 +403,7 @@ "sources": { "crates/perry-runtime/src/gc/census.rs": "25601f25ac70aa998f8cb5c1939d11e7c43a96235d4edf39a78e261b68709471", "crates/perry-runtime/src/gc/cycle.rs": "4744196ba5e9c5ac40912154cf5b45b4a618d81ddc776ab1095fbc585f27c878", - "crates/perry-runtime/src/gc/mod.rs": "bb38a949495846fd30598064bb04d8e8d384721c25a9a21d1029da1e0c5caa3c", + "crates/perry-runtime/src/gc/mod.rs": "7152d3897fe23c32012d51218a613ca993cc40c7042d2735bf1c1a501b63c19e", "crates/perry-runtime/src/gc/policy.rs": "7e2304ec822d26877df4eaf7aefb55c485c66425b8946b56a9b271a0abcc635b", "crates/perry-runtime/src/gc/progress.rs": "a5ad3971bbe4047229ca57325234780daa85921dbc778e1c08dff4ad07ccfb96" } diff --git a/scripts/gc_runtime_root_holders.py b/scripts/gc_runtime_root_holders.py index 2fb970aeaf..5864011ebe 100755 --- a/scripts/gc_runtime_root_holders.py +++ b/scripts/gc_runtime_root_holders.py @@ -326,6 +326,9 @@ def repo_relative(path: PurePath, root: PurePath) -> str: # C-ABI registration the ext crates use, and a `[^()]*` argument body # silently missed it — every holder that trampoline covers then read as # uncovered. +# `SLOT.get()` on a `Hook` static inside a registered scanner — see the +# feature-slot step in `_scan` for why its `SLOT.set(path)` targets seed. +HOOK_GET = re.compile(r"\b([A-Z][A-Z0-9_]*)\s*\.get\(\)") REGISTER_CALL = re.compile( r"(?:gc_register_\w*root_scanner\w*|reg_scanner!|reg_budgeted_scanner!)" r"\s*\((?P(?:[^;()]|\([^()]*\))*)\)", @@ -925,21 +928,53 @@ def path_matches(defining: Path, segments: list[str]) -> bool: # qualification; one that resolves to several must match the path. seeds: set[str] = set() seed_files: dict[str, set[Path]] = {} - for name, segments in registered_paths: + + def add_seed(name: str, segments: list[str]) -> bool: definitions = bodies.get(name) if not definitions: # Not a function in these crates — a type name (`as # MutableRootScanner`) or a path segment. Seeding it would make half # the crate reachable. - continue + return False if len(definitions) == 1: matched = definitions else: matched = [(p, b) for (p, b) in definitions if path_matches(p, segments)] if not matched: matched = definitions # unresolvable: fall back, over-approximate + files = seed_files.setdefault(name, set()) + before = (name in seeds, len(files)) seeds.add(name) - seed_files.setdefault(name, set()).update(p for p, _ in matched) + files.update(p for p, _ in matched) + return before != (True, len(files)) + + for name, segments in registered_paths: + add_seed(name, segments) + + # A registered scanner may forward through a link-time feature slot + # (`perry_runtime::feature_hooks::Hook`): its body reads `SLOT.get()` and + # calls the fn pointer, so the call graph ends there. The functions that + # can be in the slot are exactly the bare paths the SAME file stores with + # `SLOT.set(path)` (a Hook static is private to its file), so each of + # those is registered as far as coverage goes. Resolving it here keeps the + # coverage computed: delete the real scanner or its `set` and the holders + # it reaches read as uncovered again. + changed = True + while changed: + changed = False + for name in sorted(seeds): + for path in sorted(seed_files.get(name, ())): + file_text = strip_comments(texts[path]) + for defining, body in bodies.get(name, []): + if defining != path: + continue + for slot in HOOK_GET.findall(body): + for target in re.findall( + rf"\b{slot}\s*\.set\(\s*((?:\w+::)*[A-Za-z_]\w*)\s*\)", + file_text, + ): + segments = target.split("::") + changed |= add_seed(segments[-1], segments[:-1]) def reachable_text(call_pattern: re.Pattern) -> dict[Path, str]: reachable: set[str] = set() @@ -1348,11 +1383,36 @@ def print_list(root: Path) -> int: gc_register_mutable_root_scanner(crate::thing::scan_thing_roots_mut); gc_register_mutable_root_scanner(crate::other::scan_other_roots_mut); gc_register_mutable_root_scanner(crate::dup_a::scan_dup_roots_mut); + gc_register_mutable_root_scanner(crate::fwd::scan_fwd_roots_mut); """ + "\n".join( f" gc_register_mutable_root_scanner(crate::pad::scan_pad_{i}_mut);" for i in range(MIN_REGISTERED) ) + """ } +""", + # A registered forwarder that reaches its scanner only through a + # link-time feature slot. The slot's `set` target is covered; a scanner + # stored into a slot no registered function reads is not. + "crates/perry-runtime/src/fwd.rs": """ +static SCAN: Hook = Hook::empty(); +static UNREAD: Hook = Hook::empty(); +pub fn scan_fwd_roots_mut(v: &mut V) { + if let Some(scan) = SCAN.get() { scan(v); } +} +pub fn install() { + SCAN.set(crate::behind_slot::scan_behind_slot_mut); + UNREAD.set(crate::behind_slot::scan_unread_slot_mut); +} +""", + "crates/perry-runtime/src/behind_slot.rs": """ +static COVERED_VIA_HOOK: RefCell> = RefCell::new(Vec::new()); +static UNCOVERED_UNREAD_HOOK: RefCell> = RefCell::new(Vec::new()); +pub fn scan_behind_slot_mut(v: &mut V) { + for p in COVERED_VIA_HOOK.borrow_mut().iter_mut() { v.visit(p); } +} +pub fn scan_unread_slot_mut(v: &mut V) { + for p in UNCOVERED_UNREAD_HOOK.borrow_mut().iter_mut() { v.visit(p); } +} """, "crates/perry-runtime/src/thing.rs": """ static COVERED_DIRECT: RefCell> = RefCell::new(Vec::new()); @@ -1586,6 +1646,18 @@ def expect_absent(rel: str, name: str, why: str) -> None: True, "crate::perry_thread_local! declaration with a type opaque to rules A/B", ) + expect( + "crates/perry-runtime/src/behind_slot.rs", + "COVERED_VIA_HOOK", + True, + "reached through a Hook slot the registered forwarder reads", + ) + expect( + "crates/perry-runtime/src/behind_slot.rs", + "UNCOVERED_UNREAD_HOOK", + False, + "stored into a Hook slot no registered scanner reads", + ) expect( "crates/perry-runtime/src/leak.rs", "UNCOVERED_TYPED", From 4b3c8bae683a2b7696996b7faa63f3d4390e5592 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 28 Sep 2026 14:30:18 +0200 Subject: [PATCH 11/13] gc-call-effects: delegate the feature Hook indirect calls on Leaf helpers to their only targets --- .../11605-runtime-link-time-features.md | 1 + crates/perry-runtime/src/dyn_eval_hooks.rs | 21 +++++++++++++++++-- crates/perry-runtime/src/feature_hooks.rs | 8 +++++++ scripts/gc_call_effects/seeds.txt | 9 ++++++++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/changelog.d/11605-runtime-link-time-features.md b/changelog.d/11605-runtime-link-time-features.md index 08d2fc57e8..222f937708 100644 --- a/changelog.d/11605-runtime-link-time-features.md +++ b/changelog.d/11605-runtime-link-time-features.md @@ -1 +1,2 @@ - **perf(size): the runtime's always-live hubs no longer pin optional runtime features into prebuilt links.** Follow-up to #11598 (which did this for perry-stdlib). An installed perry links the prebuilt full-feature `libperry_runtime.a`, and always-live runtime code named optional subsystems directly: `globalThis` population (`eval` → the script interpreter and the swc/perry-parser behind it, `Intl` namespace members, `Temporal`), the generic operators and property/`instanceof`/construct paths (Temporal's `==`/ToPrimitive/ToString/JSON/`valueOf` arms, Temporal and Intl subclassing), the native-module member lookup (`bun.YAML`/`TOML`/`semver`/`JSONL`), `Date.prototype.toLocale*String(locales, options)` (ICU date formatting), `Number`/`BigInt.prototype.toLocaleString(locales, options)`, Date time-zone offsets (the compiled IANA database), the RegExp `matchAll` iterator (the regex engine), URL host canonicalization and IDNA (`url`/`idna`), and the GC/exception integration of the script interpreter (root scanner, move hook, dead-owner prune, `try` savepoint). Each now goes through a `perry_runtime::feature_hooks::Hook` slot filled by a `js_runtime_install_` entry point (`dyn-eval`, `temporal`, `intl-namespace`, `intl-datetime`, `bun-cli-utils`, `regex-engine`, `url-engine`); an empty slot answers exactly what the `#[cfg(not(feature))]` branch answered, and every always-live table keeps its shape (the savepoint field, the prune entry and the root scanner stay registered and forward). The generated installer object from #11598 now also registers a runtime installer, which `js_gc_init` runs before any user code: the program's runtime features (from the same `auto_optimized_cross_features` analysis) on a prebuilt-archive link, `js_runtime_install_compiled` on an auto-optimized link or for programs with deferred dynamic code. `stdlib_installs.rs` gains the runtime trigger table, with tests recomputing it from `perry-runtime/Cargo.toml` and checking it against the defined install symbols. perry-stdlib now reuses the runtime's `Hook`. Installed-mode sizes (Linux x86_64, release recipe): the `node:net` + `fetch` + TLS backend 19.45 → 15.05 MB (24.10 MB before #11598), a runtime-only fs program 13.72 → 8.76 MB, `node:net` 16.18 → 11.23 MB, `node:sqlite` 15.96 → 11.00 MB, fetch 17.39 → 12.66 MB; auto-optimized links unchanged. Validation: perry-runtime's suite single-threaded 4706 passed / 0 failed / 12 ignored on base and branch; no new warnings across 10 runtime feature subsets; the Node builtin compatibility matrix and 259 stdlib + 108 runtime-feature `test-files/` programs identical between the #11598 and patched installed-mode packages (differences only in tests printing random bytes, `Date.now()` or per-build class names; four fixed-port `node:net` tests collided under the harness's parallelism and are identical run sequentially). Not changed: the GC/diagnostic instruments (`diagnostics`, `gc-instruments`, `hot-diag`) remain in the prebuilt archive — their probes sit on hot paths behind runtime env gates, so they are a packaging decision (build the prebuilt runtime without them) rather than a hook conversion. +- **GC call effects stay Leaf across the new Hook slots.** Routing the interpreter's `try` savepoint and the link-step installer through fn-pointer slots put an indirect call (a possible collection, to the S1 checker) under `js_try_push`, `js_eh_try_push`, `js_gc_init` and `perry_runtime_widget_init`, all committed Leaf; `js_try_push` runs on every `try`. The forwarders `dyn_eval_hooks::interp_savepoint` / `interp_restore` and `feature_hooks::run_feature_installer` are now `#[inline(never)]` named symbols, and `scripts/gc_call_effects/seeds.txt` delegates each indirect call to the only functions its slot can hold (`dyn_eval::interp_savepoint` / `interp_restore`, written only by the `dyn-eval` install; the `js_runtime_install_*` entry points, the only calls in the installer `stubs::generate_feature_installer_object` emits), plus a `forbid` rule so no archive code can register another installer. The targets become graph callees, so the graph, not the rule's reason, decides the class; the regenerated tables keep all four helpers at their previous class. diff --git a/crates/perry-runtime/src/dyn_eval_hooks.rs b/crates/perry-runtime/src/dyn_eval_hooks.rs index 0939c5580d..8550cdc6e1 100644 --- a/crates/perry-runtime/src/dyn_eval_hooks.rs +++ b/crates/perry-runtime/src/dyn_eval_hooks.rs @@ -57,10 +57,25 @@ pub(crate) fn prune_dead_function_owners_young(is_dead: &dyn Fn(usize) -> bool) /// The interpreter's `try` savepoint; `0` (the catch table's idle value) /// without the evaluator. +/// +/// `js_try_push` runs this on every `try`, and the GC call-effects tables +/// (`crates/perry-codegen/src/gc_effects/`) keep `js_try_push` Leaf only +/// because `scripts/gc_call_effects/seeds.txt` delegates this function's +/// indirect call to its one possible target, `crate::dyn_eval::interp_savepoint` +/// (the only value [`install`] stores in the slot). `#[inline(never)]` keeps +/// the indirect call in this named symbol, where that rule can match it; a +/// closure (`map_or(0, |f| f())`) would move it into an unnamed one. +#[inline(never)] pub(crate) fn interp_savepoint() -> u64 { - INTERP_SAVEPOINT.get().map_or(0, |f| f()) + match INTERP_SAVEPOINT.get() { + Some(savepoint) => savepoint(), + None => 0, + } } +/// Restore the interpreter to a `try` savepoint. Delegated in seeds.txt to +/// `crate::dyn_eval::interp_restore`, like [`interp_savepoint`]. +#[inline(never)] pub(crate) fn interp_restore(savepoint: u64) { if let Some(restore) = INTERP_RESTORE.get() { restore(savepoint); @@ -73,7 +88,9 @@ pub(crate) fn dynamic_import_data_url(specifier: &str) -> Option { DATA_URL_IMPORT.get().and_then(|f| f(specifier)) } -/// The `dyn-eval` install. +/// The `dyn-eval` install: the only writer of these slots. The GC +/// call-effects rules for [`interp_savepoint`] / [`interp_restore`] name the +/// targets stored here; change both together. #[cfg(feature = "dyn-eval")] pub(crate) fn install() { FUNCTION_FROM_STRINGS.set(crate::dyn_eval::dyn_function_from_strings); diff --git a/crates/perry-runtime/src/feature_hooks.rs b/crates/perry-runtime/src/feature_hooks.rs index bfde1db5b5..85bab47cb5 100644 --- a/crates/perry-runtime/src/feature_hooks.rs +++ b/crates/perry-runtime/src/feature_hooks.rs @@ -100,6 +100,14 @@ pub extern "C" fn js_runtime_register_feature_installer(installer: extern "C" fn /// [`js_runtime_install_compiled`] outside tests: `js_gc_init` is live in every /// program, so a fallback reference here would pin every feature again. The /// crate's own unit tests keep the pre-hook behavior. +/// +/// The installer is always the one `perry_codegen::stubs::generate_feature_installer_object` +/// generates, which only calls `js_runtime_install_*` entry points. +/// `scripts/gc_call_effects/seeds.txt` delegates this indirect call to those +/// entry points (and forbids any archive code from registering an installer), +/// which is what keeps `js_gc_init` Leaf in the GC call-effects tables. +/// `#[inline(never)]` keeps the indirect call in this named symbol. +#[inline(never)] pub(crate) fn run_feature_installer() { if let Some(installer) = FEATURE_INSTALLER.get() { installer(); diff --git a/scripts/gc_call_effects/seeds.txt b/scripts/gc_call_effects/seeds.txt index e86f2b789b..6441d598c7 100644 --- a/scripts/gc_call_effects/seeds.txt +++ b/scripts/gc_call_effects/seeds.txt @@ -174,3 +174,12 @@ indirect regex ^>::resolve_and_cache => regex forbid regex ^std::io::stdio::set_output_capture$ -- the stdio exemption assumes no output-capture sink is installed indirect regex ^>>:: -- std's RandomState KEYS thread-local (initializer: hashmap_random_keys -> getrandom); no Perry thread-local has this type indirect regex ^perry_runtime::gc::full_trace:: -- the full-trace fetch hook (audited, not delegated): its only registration (perry_ffi_gc_register_fetch_trace from perry-stdlib fetch/lifecycle.rs) installs `phase` / `observe`, which update the per-thread fetch epoch and mark fetch handles through the runtime's `mark` -- heap-only marking during an active full trace; they run no JS and start no collection. Delegating would drag in the std LocalKey accessor pointers the graph cannot resolve +# +# Link-time feature Hook slots (#11605, perry-runtime `feature_hooks`). A hub +# that must not name an optional subsystem calls it through a Hook fn-pointer +# slot. Most such calls sit in helpers that run JS or collect anyway; these are +# the ones on Leaf helpers, each delegated to the only functions its slot can +# hold, so the graph (not this reason) decides the class. +indirect regex ^perry_runtime::feature_hooks::run_feature_installer$ => regex ^js_runtime_install_ -- js_gc_init runs the installer registered by js_runtime_register_feature_installer. The only registration is the static constructor perry_codegen::stubs::generate_feature_installer_object emits, and the installer it registers is a generated function that only calls js_runtime_install_* entry points (made callees here; the forbid rule below keeps archive code from registering anything else) +forbid exact js_runtime_register_feature_installer -- the run_feature_installer delegation assumes the generated link-step object is the only registrant +indirect regex ^perry_runtime::dyn_eval_hooks::interp_(savepoint|restore)$ => regex ^perry_runtime::dyn_eval::interp_(savepoint|restore)$ -- the try savepoint forwarders js_try_push / js_eh_try_push (and the throw restore) call: their private INTERP_SAVEPOINT / INTERP_RESTORE slots are written only by dyn_eval_hooks::install, with dyn_eval::interp_savepoint / interp_restore (made callees here). Without dyn-eval the slots stay empty and the targets match nothing From ca2531723687d61149485868250dae384754f7b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 28 Sep 2026 16:43:43 +0200 Subject: [PATCH 12/13] gc-call-effects: fix a savepoint/restore delegation that cross-matched its own targets, regenerate all three tables The single combined seeds.txt rule `interp_(savepoint|restore) => interp_(savepoint|restore)` matched each side independently, so it delegated interp_savepoint's indirect call to EITHER dyn_eval::interp_savepoint OR dyn_eval::interp_restore instead of pairing them 1:1. dyn_eval::interp_restore genuinely reenters (roots_truncate drops interpreter-held Rc values through an unresolved drop-glue indirect call on swc_ecma_ast::expr::Expr); on linux-x86_64 that drop glue is not inlined away, so the stray edge made js_try_push / js_eh_try_push classify Reenters -- UNSAFE drift against their committed Leaf (caught by gc-call-effects-linux in CI). macos-aarch64 and windows-x86_64 happened to devirtualize the same drop glue and stayed clean, which is why this didn't show up on either of those two platforms. Split into two rules, each naming only the one function its own slot can ever hold. js_try_push / js_eh_try_push / js_gc_init confirmed Leaf and perry_runtime_widget_init unchanged (Reenters on macos-aarch64 only, pre-existing and unrelated to this PR) across all three targets, verified by building perry-runtime-static/perry-stdlib-static locally for each target (native macOS, windows-x86_64 via cargo-xwin, linux-x86_64 via cargo-zigbuild) and running the checker against the real archives. Also regenerate all three committed tables: the nine js_runtime_install_* / js_runtime_register_feature_installer symbols this PR adds were never added to crates/perry-codegen/src/gc_effects/*.tsv at all (safe drift, all Leaf). --- changelog.d/11605-runtime-link-time-features.md | 3 ++- crates/perry-codegen/src/gc_effects/linux-x86_64.tsv | 9 +++++++++ crates/perry-codegen/src/gc_effects/macos-aarch64.tsv | 9 +++++++++ crates/perry-codegen/src/gc_effects/windows-x86_64.tsv | 9 +++++++++ scripts/gc_call_effects/seeds.txt | 3 ++- 5 files changed, 31 insertions(+), 2 deletions(-) diff --git a/changelog.d/11605-runtime-link-time-features.md b/changelog.d/11605-runtime-link-time-features.md index 222f937708..8887d67bf7 100644 --- a/changelog.d/11605-runtime-link-time-features.md +++ b/changelog.d/11605-runtime-link-time-features.md @@ -1,2 +1,3 @@ - **perf(size): the runtime's always-live hubs no longer pin optional runtime features into prebuilt links.** Follow-up to #11598 (which did this for perry-stdlib). An installed perry links the prebuilt full-feature `libperry_runtime.a`, and always-live runtime code named optional subsystems directly: `globalThis` population (`eval` → the script interpreter and the swc/perry-parser behind it, `Intl` namespace members, `Temporal`), the generic operators and property/`instanceof`/construct paths (Temporal's `==`/ToPrimitive/ToString/JSON/`valueOf` arms, Temporal and Intl subclassing), the native-module member lookup (`bun.YAML`/`TOML`/`semver`/`JSONL`), `Date.prototype.toLocale*String(locales, options)` (ICU date formatting), `Number`/`BigInt.prototype.toLocaleString(locales, options)`, Date time-zone offsets (the compiled IANA database), the RegExp `matchAll` iterator (the regex engine), URL host canonicalization and IDNA (`url`/`idna`), and the GC/exception integration of the script interpreter (root scanner, move hook, dead-owner prune, `try` savepoint). Each now goes through a `perry_runtime::feature_hooks::Hook` slot filled by a `js_runtime_install_` entry point (`dyn-eval`, `temporal`, `intl-namespace`, `intl-datetime`, `bun-cli-utils`, `regex-engine`, `url-engine`); an empty slot answers exactly what the `#[cfg(not(feature))]` branch answered, and every always-live table keeps its shape (the savepoint field, the prune entry and the root scanner stay registered and forward). The generated installer object from #11598 now also registers a runtime installer, which `js_gc_init` runs before any user code: the program's runtime features (from the same `auto_optimized_cross_features` analysis) on a prebuilt-archive link, `js_runtime_install_compiled` on an auto-optimized link or for programs with deferred dynamic code. `stdlib_installs.rs` gains the runtime trigger table, with tests recomputing it from `perry-runtime/Cargo.toml` and checking it against the defined install symbols. perry-stdlib now reuses the runtime's `Hook`. Installed-mode sizes (Linux x86_64, release recipe): the `node:net` + `fetch` + TLS backend 19.45 → 15.05 MB (24.10 MB before #11598), a runtime-only fs program 13.72 → 8.76 MB, `node:net` 16.18 → 11.23 MB, `node:sqlite` 15.96 → 11.00 MB, fetch 17.39 → 12.66 MB; auto-optimized links unchanged. Validation: perry-runtime's suite single-threaded 4706 passed / 0 failed / 12 ignored on base and branch; no new warnings across 10 runtime feature subsets; the Node builtin compatibility matrix and 259 stdlib + 108 runtime-feature `test-files/` programs identical between the #11598 and patched installed-mode packages (differences only in tests printing random bytes, `Date.now()` or per-build class names; four fixed-port `node:net` tests collided under the harness's parallelism and are identical run sequentially). Not changed: the GC/diagnostic instruments (`diagnostics`, `gc-instruments`, `hot-diag`) remain in the prebuilt archive — their probes sit on hot paths behind runtime env gates, so they are a packaging decision (build the prebuilt runtime without them) rather than a hook conversion. -- **GC call effects stay Leaf across the new Hook slots.** Routing the interpreter's `try` savepoint and the link-step installer through fn-pointer slots put an indirect call (a possible collection, to the S1 checker) under `js_try_push`, `js_eh_try_push`, `js_gc_init` and `perry_runtime_widget_init`, all committed Leaf; `js_try_push` runs on every `try`. The forwarders `dyn_eval_hooks::interp_savepoint` / `interp_restore` and `feature_hooks::run_feature_installer` are now `#[inline(never)]` named symbols, and `scripts/gc_call_effects/seeds.txt` delegates each indirect call to the only functions its slot can hold (`dyn_eval::interp_savepoint` / `interp_restore`, written only by the `dyn-eval` install; the `js_runtime_install_*` entry points, the only calls in the installer `stubs::generate_feature_installer_object` emits), plus a `forbid` rule so no archive code can register another installer. The targets become graph callees, so the graph, not the rule's reason, decides the class; the regenerated tables keep all four helpers at their previous class. +- **GC call effects stay Leaf across the new Hook slots.** Routing the interpreter's `try` savepoint and the link-step installer through fn-pointer slots put an indirect call (a possible collection, to the S1 checker) under `js_try_push`, `js_eh_try_push`, `js_gc_init` and `perry_runtime_widget_init`, all committed Leaf; `js_try_push` runs on every `try`. The forwarders `dyn_eval_hooks::interp_savepoint` / `interp_restore` and `feature_hooks::run_feature_installer` are now `#[inline(never)]` named symbols, and `scripts/gc_call_effects/seeds.txt` delegates each indirect call to the only functions its slot can hold (`dyn_eval::interp_savepoint` / `interp_restore`, written only by the `dyn-eval` install; the `js_runtime_install_*` entry points, the only calls in the installer `stubs::generate_feature_installer_object` emits), plus a `forbid` rule so no archive code can register another installer. The targets become graph callees, so the graph, not the rule's reason, decides the class. +- **Fix: the savepoint/restore delegation cross-matched its own targets.** The original rule wrote one regex, `interp_(savepoint|restore)`, on *both* sides of the `=>`; the checker matches each side independently, so it delegated `interp_savepoint`'s call to *either* `dyn_eval::interp_savepoint` *or* `dyn_eval::interp_restore` -- not the intended 1:1 pairing. `dyn_eval::interp_restore` genuinely reenters (its `roots_truncate` drops interpreter-held `Rc` values, an unresolved drop-glue indirect call through `swc_ecma_ast::expr::Expr`), and on `linux-x86_64` specifically that drop-glue call is not inlined away, so the stray edge made `js_try_push` / `js_eh_try_push` classify `Reenters` -- UNSAFE drift against their committed `Leaf` (`gc-call-effects-linux` caught it; `macos-aarch64` and `windows-x86_64` happened to devirtualize the same drop glue and stayed clean, which is why this didn't show up on either of those two). Split into two rules, each naming only the one function its own slot can ever hold (`interp_savepoint` => `dyn_eval::interp_savepoint`, `interp_restore` => `dyn_eval::interp_restore`); `js_try_push` / `js_eh_try_push` / `js_gc_init` are confirmed `Leaf` and `perry_runtime_widget_init` unchanged (`Reenters` on `macos-aarch64` only, pre-existing and unrelated -- its own panic-hook closure, not this PR) on all three targets. Also regenerated all three committed tables for the nine `js_runtime_install_*` / `js_runtime_register_feature_installer` symbols this PR adds (previously absent from the tables entirely, safe drift, now `Leaf`). diff --git a/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv b/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv index b71c7ad644..ed7572a03c 100644 --- a/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv +++ b/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv @@ -2881,6 +2881,15 @@ js_run_ext_pump Reenters js_run_module_init_catching Reenters js_run_stdlib_pump Reenters js_runtime_init Reenters +js_runtime_install_bun_cli_utils Leaf +js_runtime_install_compiled Leaf +js_runtime_install_dyn_eval Leaf +js_runtime_install_intl_datetime Leaf +js_runtime_install_intl_namespace Leaf +js_runtime_install_regex_engine Leaf +js_runtime_install_temporal Leaf +js_runtime_install_url_engine Leaf +js_runtime_register_feature_installer Leaf js_runtime_validate_crypto_key_arg Reenters js_runtime_validate_integer_arg Reenters js_runtime_validate_string_arg Reenters diff --git a/crates/perry-codegen/src/gc_effects/macos-aarch64.tsv b/crates/perry-codegen/src/gc_effects/macos-aarch64.tsv index 405bf6af51..8a112954da 100644 --- a/crates/perry-codegen/src/gc_effects/macos-aarch64.tsv +++ b/crates/perry-codegen/src/gc_effects/macos-aarch64.tsv @@ -2881,6 +2881,15 @@ js_run_ext_pump Reenters js_run_module_init_catching Reenters js_run_stdlib_pump Reenters js_runtime_init Reenters +js_runtime_install_bun_cli_utils Leaf +js_runtime_install_compiled Leaf +js_runtime_install_dyn_eval Leaf +js_runtime_install_intl_datetime Leaf +js_runtime_install_intl_namespace Leaf +js_runtime_install_regex_engine Leaf +js_runtime_install_temporal Leaf +js_runtime_install_url_engine Leaf +js_runtime_register_feature_installer Leaf js_runtime_validate_crypto_key_arg ThrowOnly js_runtime_validate_integer_arg ThrowOnly js_runtime_validate_string_arg ThrowOnly diff --git a/crates/perry-codegen/src/gc_effects/windows-x86_64.tsv b/crates/perry-codegen/src/gc_effects/windows-x86_64.tsv index 8334ff53d5..4a14fd45bb 100644 --- a/crates/perry-codegen/src/gc_effects/windows-x86_64.tsv +++ b/crates/perry-codegen/src/gc_effects/windows-x86_64.tsv @@ -2881,6 +2881,15 @@ js_run_ext_pump Reenters js_run_module_init_catching Reenters js_run_stdlib_pump Reenters js_runtime_init Reenters +js_runtime_install_bun_cli_utils Leaf +js_runtime_install_compiled Leaf +js_runtime_install_dyn_eval Leaf +js_runtime_install_intl_datetime Leaf +js_runtime_install_intl_namespace Leaf +js_runtime_install_regex_engine Leaf +js_runtime_install_temporal Leaf +js_runtime_install_url_engine Leaf +js_runtime_register_feature_installer Leaf js_runtime_validate_crypto_key_arg Reenters js_runtime_validate_integer_arg Reenters js_runtime_validate_string_arg Reenters diff --git a/scripts/gc_call_effects/seeds.txt b/scripts/gc_call_effects/seeds.txt index 6441d598c7..f2c88e76c9 100644 --- a/scripts/gc_call_effects/seeds.txt +++ b/scripts/gc_call_effects/seeds.txt @@ -182,4 +182,5 @@ indirect regex ^perry_runtime::gc::full_trace:: -- the full-trace fetch hook (au # hold, so the graph (not this reason) decides the class. indirect regex ^perry_runtime::feature_hooks::run_feature_installer$ => regex ^js_runtime_install_ -- js_gc_init runs the installer registered by js_runtime_register_feature_installer. The only registration is the static constructor perry_codegen::stubs::generate_feature_installer_object emits, and the installer it registers is a generated function that only calls js_runtime_install_* entry points (made callees here; the forbid rule below keeps archive code from registering anything else) forbid exact js_runtime_register_feature_installer -- the run_feature_installer delegation assumes the generated link-step object is the only registrant -indirect regex ^perry_runtime::dyn_eval_hooks::interp_(savepoint|restore)$ => regex ^perry_runtime::dyn_eval::interp_(savepoint|restore)$ -- the try savepoint forwarders js_try_push / js_eh_try_push (and the throw restore) call: their private INTERP_SAVEPOINT / INTERP_RESTORE slots are written only by dyn_eval_hooks::install, with dyn_eval::interp_savepoint / interp_restore (made callees here). Without dyn-eval the slots stay empty and the targets match nothing +indirect regex ^perry_runtime::dyn_eval_hooks::interp_savepoint$ => regex ^perry_runtime::dyn_eval::interp_savepoint$ -- the try savepoint forwarder js_try_push / js_eh_try_push call on every `try`: its private INTERP_SAVEPOINT slot is written only by dyn_eval_hooks::install, to dyn_eval::interp_savepoint (made the only callee here). A single combined `(savepoint|restore)` alternation on both sides used to also delegate this call to dyn_eval::interp_restore -- a real (and correctly unresolved) Reenters path through roots_truncate's Rc drop glue -- because the regex groups are matched independently, not paired; split so each forwarder names only the one function its own slot can hold. Without dyn-eval the slot stays empty and the target matches nothing +indirect regex ^perry_runtime::dyn_eval_hooks::interp_restore$ => regex ^perry_runtime::dyn_eval::interp_restore$ -- the throw-path restore forwarder (js_throw's savepoint table, exception/savepoints.rs): its private INTERP_RESTORE slot is written only by dyn_eval_hooks::install, to dyn_eval::interp_restore (made the only callee here). dyn_eval::interp_restore's roots_truncate drops interpreter-held AST Rc values -- an unresolved drop-glue indirect call -- so this forwarder is correctly Reenters; only js_throw (ThrowOnly) reaches it, never the Leaf try-push path. Without dyn-eval the slot stays empty and the target matches nothing From 85e0fe18ecb4e73bc49ac4fca836717752e594f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 1 Oct 2026 22:08:58 +0200 Subject: [PATCH 13/13] fix: retain target-specific Inkwell lock entries --- Cargo.lock | 29 +++++++++++++++++-- .../11629-release-runtime-no-instruments.md | 2 ++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bb8fa35e9b..092890f804 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3318,6 +3318,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inkwell" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7decbc9dfa45a4a827a6ff7b822c113b1285678a937e84213417d4ca8a095782" +dependencies = [ + "bitflags 2.12.1", + "inkwell_internals 0.14.0", + "libc", + "llvm-sys", + "thiserror 2.0.18", +] + [[package]] name = "inkwell" version = "0.10.0" @@ -3325,12 +3338,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30932a1258d02e3c7344618abcdbb5dca03e0f8d2ad34496067d1f371cfceda6" dependencies = [ "bitflags 2.12.1", - "inkwell_internals", + "inkwell_internals 0.15.0", "libc", "llvm-sys", "thiserror 2.0.18", ] +[[package]] +name = "inkwell_internals" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cfe97ee860815a90ed17e09639513269e39420a7440f3f4c996f238c514cf8d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "inkwell_internals" version = "0.15.0" @@ -4919,7 +4943,8 @@ version = "0.5.1655" dependencies = [ "aho-corasick", "anyhow", - "inkwell", + "inkwell 0.10.0", + "inkwell 0.9.0", "llvm-sys", "log", "memchr", diff --git a/changelog.d/11629-release-runtime-no-instruments.md b/changelog.d/11629-release-runtime-no-instruments.md index 5997811f8e..bff6fb00c7 100644 --- a/changelog.d/11629-release-runtime-no-instruments.md +++ b/changelog.d/11629-release-runtime-no-instruments.md @@ -1 +1,3 @@ - **perf(size): release packages build their runtime archives without the GC/diagnostic instruments.** Follow-up to #11605. perry-runtime's `diagnostics`, `gc-instruments` and `hot-diag` features are in its `default` set, so the prebuilt full-feature `libperry_runtime.a` that an installed perry links carried every census, verifier, trace and hot-path diagnostic into every program. Their code is now gated on build-script cfgs (`perry_diagnostics`, `perry_gc_instruments`, `perry_hot_diag`) that `crates/perry-runtime/build.rs` sets from the matching features unless `PERRY_RELEASE_STRIP_INSTRUMENTS=1`; `.github/workflows/release-packages.yml` sets it for every package build (including both Linux docker builds). An env var rather than a feature change keeps one feature union across every shipped archive (#6303/#7358: the ext crates and the stdlib bundle their own perry-runtime copies), and a workspace build, `cargo test` and auto-optimized links (`PERRY_GC_INSTRUMENTS=1`, or an instrument knob set while compiling) are unchanged. A stripped binary behaves as a build without the features always has: an instrument knob refuses at startup with the existing "built without the GC instruments" message. A new compile-time note explains that a prebuilt link cannot honor an instrument request and names `PERRY_WORKSPACE_ROOT`. Installed-mode sizes (Linux x86_64, release recipe, on top of #11605): the `node:net` + `fetch` + TLS backend 15.05 → 14.71 MB, a runtime-only fs program 8.76 → 8.39 MB, `node:net` 11.23 → 10.87 MB, `node:sqlite` 11.00 → 10.64 MB, fetch 12.66 → 12.29 MB; the prebuilt core runtime (already built without the instruments) and auto-optimized links unchanged. + +Retain declared Windows Inkwell0.9 lock entries alongside non-Windows0.10 after integrating current dependency updates.