From 637d7994470fd3d613d46d8a41e18fd9f5aade77 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:34:35 +0000 Subject: [PATCH 1/4] perf: number-to-string, string-keyed Map, regex GC pressure, randomUUID #10762: String(n), `${n}`, n.toString() and "" + n on a number operand build a small integer's SSO text inline at the call site (fixed-point digit split, exact for every value under 100000), with the runtime call on a cold arm. `const s = String(n)` / `${n}` / "" + n (a + with a string literal) now record a runtime-derived String proof, so s.charCodeAt and the other string lowerings no longer fall to the generic method site, and the inline charCodeAt reads an ASCII SSO receiver's byte from the value instead of materializing a heap copy. #10697: populating globalThis installed builtins onto intrinsics through the exotic-store gauntlet and armed PERRY_OWN_NAMED_PROP_INSTALLED, which sent every proven Map/Set/Date builtin call through the ~600-instruction hasOwn predicate. The runtime's own builtin definitions now arm it only for Map/Set/Date (or unreadable) owners; the universal dispatcher reads a separate flag every install still arms. Small-map lookups also answer a bit-identical key of any type from the inlined hot lane. #11549: the lent regex scratch cell grows past 32 registers, and operation-scoped regex Buffers are accounted as transient external bytes, so a large pattern in a loop no longer feeds released-bytes pressure into full collections. The scavenge nursery now powers on at a quarter of its base and climbs back on survivor influx, which keeps peak RSS flat now that the phantom fulls are gone; the #8122 census seeds at half the power-on cap. #10523: the UUID formatter writes through a constant position table and the stdlib copies its bytes without re-validating them as UTF-8. No version bump. --- changelog.d/PRNUM-inline-number-to-string.md | 37 ++++ ...-map-own-override-flag-builtin-installs.md | 44 ++++ changelog.d/PRNUM-random-uuid-format.md | 20 ++ .../PRNUM-regex-scratch-gc-pressure.md | 73 +++++++ .../src/expr/logical_collections.rs | 27 +++ crates/perry-codegen/src/expr/mod.rs | 3 + .../src/expr/number_to_string_inline.rs | 125 +++++++++++ .../src/expr/number_to_string_inline_tests.rs | 206 ++++++++++++++++++ .../lower_call/property_get/number_string.rs | 16 ++ .../perry-codegen/src/lower_string_concat.rs | 18 ++ .../src/lower_string_method/char_code_at.rs | 70 +++++- .../perry-codegen/src/type_analysis/refine.rs | 20 ++ crates/perry-runtime/src/gc/policy.rs | 23 +- crates/perry-runtime/src/gc/tenuring.rs | 180 +++++++++++++-- .../src/gc/tests/copying/adaptive_tenuring.rs | 23 +- .../tests/runtime_roots/perex_construction.rs | 31 +++ .../gc/tests/runtime_roots/perex_execution.rs | 54 +++++ crates/perry-runtime/src/gc/tests/triggers.rs | 5 +- crates/perry-runtime/src/map.rs | 39 ++-- crates/perry-runtime/src/map/string_key.rs | 44 ++++ .../src/object/descriptor_state.rs | 4 +- .../src/object/exotic_expando.rs | 2 +- .../src/object/field_set_by_name.rs | 2 +- .../src/object/global_this/populate.rs | 8 + crates/perry-runtime/src/object/mod.rs | 2 + .../perry-runtime/src/object/own_override.rs | 107 ++++++++- .../own_override_builtin_install_tests.rs | 78 +++++++ .../perry-runtime/src/regex/perex_memory.rs | 20 +- .../perry-runtime/src/regex/perex_runtime.rs | 34 ++- crates/perry-stdlib/src/crypto/random.rs | 10 +- crates/perry-uuid/src/lib.rs | 34 ++- docs/src/internals/garbage-collector.md | 8 +- scripts/gc_runtime_root_holders.json | 28 ++- ...97_own_override_after_global_population.ts | 40 ++++ .../test_gap_10762_inline_number_to_string.ts | 75 +++++++ ..._gap_11549_regex_large_register_scratch.ts | 55 +++++ 36 files changed, 1469 insertions(+), 96 deletions(-) create mode 100644 changelog.d/PRNUM-inline-number-to-string.md create mode 100644 changelog.d/PRNUM-map-own-override-flag-builtin-installs.md create mode 100644 changelog.d/PRNUM-random-uuid-format.md create mode 100644 changelog.d/PRNUM-regex-scratch-gc-pressure.md create mode 100644 crates/perry-codegen/src/expr/number_to_string_inline.rs create mode 100644 crates/perry-codegen/src/expr/number_to_string_inline_tests.rs create mode 100644 crates/perry-runtime/src/object/own_override_builtin_install_tests.rs create mode 100644 test-files/test_gap_10697_own_override_after_global_population.ts create mode 100644 test-files/test_gap_10762_inline_number_to_string.ts create mode 100644 test-files/test_gap_11549_regex_large_register_scratch.ts diff --git a/changelog.d/PRNUM-inline-number-to-string.md b/changelog.d/PRNUM-inline-number-to-string.md new file mode 100644 index 0000000000..3f3736ca31 --- /dev/null +++ b/changelog.d/PRNUM-inline-number-to-string.md @@ -0,0 +1,37 @@ +perf(codegen): number-to-string builds a small integer's text at the call site, and its result keeps its string proof (#10762). + +- `String(n)`, `` `${n}` ``, `n.toString()` and `"" + n` on an operand the type + analysis proves numeric now compute the SSO bits of an integer in + `-9999..=99999` inline, with no call. The digits come from a fixed-point + split (`abs * ceil(2^32 / 10^4)`, then `* 10` per digit, exact for every + `abs < 100000`), about 30 branch-free instructions. Every other value + (fractions, `NaN`, the infinities, larger integers, and a declared `number` + that holds something else at run time) takes the original runtime call on + a cold arm, so the text is unchanged, and the bits match + `small_integer_sso_bits` exactly. An operand without a numeric proof keeps + the plain call, so the code is emitted only where it is expected to fire. +- `const s = String(n)`, `` `${n}` `` and `"" + n` (a `+` with a string-literal + operand) record a runtime-derived `String` proof for `s`. Before, the local + lost the proof its initializer carries, and `s.charCodeAt(i)`, `s.length` + and the other string lowerings fell to the generic method site, although + `String(n).charCodeAt(i)` written inline took the fast path. A declared + `string` is still not a proof (#7837). +- The inline `charCodeAt` reads an all-ASCII SSO receiver's byte straight out + of the value. Before, an SSO receiver, which is what every short number's text + now is, went to the slow arm, which materialized a heap copy through the + intern table (about 175 instructions) to read one byte. + +Measured (`callgrind`, instructions per iteration fitted over 20k→120k, x86-64, +`PERRY_NO_AUTO_OPTIMIZE=1`, `k = i % 1000`, the text consumed by +`charCodeAt(0) + length`): `String(n)` 449 → 169, `` `${n}` `` 462 → 169, +`String(-k)` 644 → 199, a fraction 1,251 → 987, and a 7-digit integer (a heap +string) 623 → 560 over 1M→3M. Consumed only by `.length`: `String(n)` +163 → 131, `"" + n` 186 → 133, `` `${n}` `` 172 → 131, `n.toString()` +160 → 133. A bare loop is 23. + +Tests: `number_to_string_inline_tests` (each spelling emits the inline arm; an +`any` operand does not; `s.charCodeAt` on each coerced local reaches the SSO +arm), sabotage-checked, and `test_gap_10762_inline_number_to_string` (every +integer in the inline range against a reference, the range edges, lying +annotations, SSO `charCodeAt` including non-ASCII and bad indexes; +byte-identical to Node). No version bump. diff --git a/changelog.d/PRNUM-map-own-override-flag-builtin-installs.md b/changelog.d/PRNUM-map-own-override-flag-builtin-installs.md new file mode 100644 index 0000000000..72bb7928e4 --- /dev/null +++ b/changelog.d/PRNUM-map-own-override-flag-builtin-installs.md @@ -0,0 +1,44 @@ +perf(runtime): populating `globalThis` no longer puts every `Map`/`Set`/`Date` builtin call on the slow side of the own-override guard (#10697). + +The #10943 guard in front of a proven `Map`/`Set`/`Date` builtin call answers +"no own override" from one load of `PERRY_OWN_NAMED_PROP_INSTALLED`, and asks +the authoritative `hasOwn` predicate only once any non-ordinary cell has taken +a named property. The runtime's own lazy `globalThis` population set that flag: +it installs statics on constructor intrinsics such as `%TypedArray%` (closure +cells), `constructor` on `Array.prototype` (an array cell) and aliases such as +`Number.parseFloat`, and each of those stores passes the exotic-store gauntlet +that arms it. Any program that referenced `globalThis` or a lazily installed +global then paid about 600 instructions of `js_receiver_may_own_named_method` +→ `js_object_has_own` (with a key-string coercion) on every `m.get(k)` and +`m.set(k, v)`. That is the "count by category" shape #10697 measured at 4.2x +node. + +- The runtime's own builtin definitions (`define_builtin_data_property` and all + of `populate_global_this_builtins`) run inside `as_builtin_definition`. Inside + it, an install arms the exported flag only when its owner is a Map, Set or + Date cell, or its header cannot be read. Those are the only receivers whose + guard answer comes from the flag: an array answers from its own header and + named-property storage, and a declared-`Map` receiver of any other kind is + brand-checked into generic dispatch. User installs arm exactly as before. +- The universal dispatcher's `own_user_method_value` now reads a separate + internal flag that every install still arms, builtin or not, so its answers + are unchanged. +- Small-map lookups (≤ 8 entries) answer a bit-identical key of any type from + the inlined hot lane. A Map never holds two SameValueZero-equal keys and + stored keys are normalized, so an identity hit is the match. Before, only a + plain-number key could use that lane, and every string lookup paid the + out-of-line call to `find_key_index_cold` first. + +Measured (`callgrind`, instructions per iteration fitted over 20k→120k, x86-64, +`PERRY_NO_AUTO_OPTIMIZE=1`, program references `globalThis`): four constant +string keys, get-or-default then set, 1,866 → 373; a plain `m.get(CATS[i & 3])` +947 → 206, of which 111 is the array read itself. Output identical to Node. + +Tests: `a_builtin_install_on_an_intrinsic_does_not_arm_the_guard` (272 arms +from one intrinsic install before; 0 now, while installs onto a Map, builtin +or user, still arm), `a_small_map_answers_an_identical_key_without_the_cold_path` +(8 of 8 identical lookups went cold before; 0 now, and content matches still +go cold), both sabotage-checked, and +`test_gap_10697_own_override_after_global_population` (own overrides on +Map/Set/Date/Array still win after population, and the populated builtins still +work). No version bump. diff --git a/changelog.d/PRNUM-random-uuid-format.md b/changelog.d/PRNUM-random-uuid-format.md new file mode 100644 index 0000000000..bde442de4e --- /dev/null +++ b/changelog.d/PRNUM-random-uuid-format.md @@ -0,0 +1,20 @@ +perf(uuid): `randomUUID()` formats without bounds checks or a UTF-8 re-validation (#10523). + +#10523's main cost, one `getrandom` system call per UUID, was fixed by the +per-thread entropy cache (#11351): 120,000 UUIDs now make 944 calls, one per 128, +the batching Node uses. This removes most of what remained in user space: + +- `Hyphenated::new` wrote each byte's two hex digits at a running index with a + per-byte hyphen test, so every store was bounds-checked. A constant table of + digit positions unrolls to straight-line stores. +- `js_crypto_random_uuid` and `js_crypto_random_uuidv7` copy the 36 bytes + through the new `Hyphenated::as_bytes`. `as_str` validated known-ASCII bytes + as UTF-8 on every UUID only for the string constructor to copy them. + +Measured (`callgrind`, whole-process instructions ÷ UUIDs, `node:crypto` +`randomUUID()`): 1,085 → 1,005 per UUID at 200k, 1,119 → 933 at 1M. The two +hot functions fell from about 367 to 290 instructions per UUID, and the 56 of +UTF-8 validation are gone. + +Tests: `hyphenated_layout_is_exact` pins the digit order and hyphen positions +byte for byte, and that `as_bytes` equals `as_str`'s bytes. No version bump. diff --git a/changelog.d/PRNUM-regex-scratch-gc-pressure.md b/changelog.d/PRNUM-regex-scratch-gc-pressure.md new file mode 100644 index 0000000000..f45985d31f --- /dev/null +++ b/changelog.d/PRNUM-regex-scratch-gc-pressure.md @@ -0,0 +1,73 @@ +perf(regex,gc): regex match scratch no longer drives full collections, and the nursery powers on small (#11549). + +A pattern with more than 32 match registers, such as dotenv's 42-register line +pattern, could not use the thread's lent scratch cell, which held a fixed 32 +registers. Every search of it built owned `MatchBuffers` and freed them again, +and each `perex_memory::Buffer` reported its bytes through +`gc_note_external_side_alloc` / `gc_note_external_side_free`. The free side +feeds `GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL`, the released-bytes term of +old-reclaim pressure, so a hot loop of such searches accumulated phantom +pressure that matched no garbage on the heap and kept scheduling full +collections. + +- The lent cell's registers are a growable per-thread `Vec`, sized to the + largest program the thread has searched (capped at 4096 registers, 32 KiB). + A search that fits no longer constructs or frees anything. Like the cell's + frames and undo entries, this memory is the thread's and is not reported to + the collector. +- `perex_memory::Buffer`, the operation-scoped compile and match scratch that + the owned path still uses (nested searches, programs over the cap), is + accounted as transient: new `gc_note_external_transient_alloc` / `_free` + move only the live external-byte term. The bytes are still visible to + pressure while they exist, but they no longer step the allocation-churn + trigger or count as released pressure, and noting them never collects. + `Reservation` and the replacement span/piece vectors, whose size follows the + subject, keep the ordinary external accounting. + +That alone was measured and not shipped before, because once the phantom +fulls stop, the young generation runs to its 16 MB cap before collecting and +peak RSS rose (+23% dotenv, +21% moment here). The second half keeps RSS flat: + +- The scavenge nursery cap now **powers on at a quarter of the base** (4 MB at + the default 16 MB, `NURSERY_CAP_SHRINK_SHIFT`), grows back through the + existing debounced rule when survivor influx exceeds 4% of the cap (to the + base within four minors, then the ×2/×4 scale as before), and shrinks back to + the floor while influx stays under 1%. A copying minor is O(survivors), so a + small Eden costs little where little survives, and peak RSS is set by the + first nurseries: a shrink that only engaged later could not lower it + (measured: 68.5 → 66.5 MB on dotenv). The tenured-proportional term + (`old / 2`) is unchanged, so a large old generation still gets a large Eden. +- The #8122 allocation census now seeds at half the power-on cap rather than + half the base, so it still runs before the first minor it exists to precede. + +Measured (`callgrind` instructions per iteration fitted over n=2,500→10,000; +peak RSS by `getrusage` at 5k / 20k / 80k iterations; x86-64, +`PERRY_NO_AUTO_OPTIMIZE=1`; output identical to Node): + +| | main instr | branch instr | main RSS (MB) | branch RSS (MB) | +|---|---:|---:|---|---| +| dotenv/parse | 3,293,191 | 2,259,517 (**−31.4%**) | 55.2 / 55.3 / 55.1 | 52.1 / 52.0 / 52.1 | +| moment/parse_format | 2,506,824 | 1,994,023 (**−20.5%**) | 65.2 / 65.2 / 101.6 | 62.7 / 69.6 / 98.0 | + +A retaining program pays a few extra early minors while the cap climbs back +to the base. On a loop that keeps one object in ten, total instructions were +27% and 22% below main at 500k and 1M iterations and within +0.5% to +1.5% of +it at 2M and 4M, with peak RSS within ±5%. The climb stays debounced on +purpose: a program's first minor sees its start-up data survive (about 10% of +a 4 MB nursery on dotenv), and an undebounced jump to the base on that one +reading put dotenv's peak RSS back at 62 MB. + +Not fixed here, and not new: under the generational collector moment's RSS +still climbs with N on main as well as here (flat at 62 MB with +`PERRY_GEN_GC=0`), so something native is released only by full collections. +The phantom fulls used to hide part of it; it wants its own issue. + +Tests: `a_search_over_thirty_two_registers_borrows_the_lent_scratch` (64 of 64 +searches took the owned path before; 0 now), +`regex_scratch_buffers_do_not_count_as_released_external_pressure` (a freed +8 KiB buffer added 8,192 bytes of released pressure before; 0 now) and +`the_nursery_starts_at_a_floor_and_follows_survivor_influx`, each +sabotage-checked against the unfixed code; five existing tenuring/trigger tests +updated to the new power-on cap with their intent kept; and +`test_gap_11549_regex_large_register_scratch` (large, nested and interleaved +programs; byte-identical to Node). No version bump. diff --git a/crates/perry-codegen/src/expr/logical_collections.rs b/crates/perry-codegen/src/expr/logical_collections.rs index 779407509c..7e43d26468 100644 --- a/crates/perry-codegen/src/expr/logical_collections.rs +++ b/crates/perry-codegen/src/expr/logical_collections.rs @@ -1175,13 +1175,40 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // and `${i}` allocate nothing for it. The result is therefore // SSO-or-heap, not heap — see `proven_heap_string_operand`. Expr::StringCoerce(operand) => { + let numeric = crate::type_analysis::is_numeric_expr(ctx, operand); let v = lower_expr(ctx, operand)?; + // A number operand's small-integer text is built inline (#10762). + if numeric { + return crate::expr::number_to_string_inline::emit_number_to_string_inline( + ctx, + &v, + |ctx| { + Ok(ctx + .block() + .call(DOUBLE, "js_string_coerce_box", &[(DOUBLE, &v)])) + }, + ); + } Ok(ctx .block() .call(DOUBLE, "js_string_coerce_box", &[(DOUBLE, &v)])) } Expr::TemplateStringCoerce(operand) => { + let numeric = crate::type_analysis::is_numeric_expr(ctx, operand); let v = lower_expr(ctx, operand)?; + if numeric { + return crate::expr::number_to_string_inline::emit_number_to_string_inline( + ctx, + &v, + |ctx| { + Ok(ctx.block().call( + DOUBLE, + "js_template_string_coerce_box", + &[(DOUBLE, &v)], + )) + }, + ); + } // S2: a string operand is answered inline; see `ic_fast_split.rs`. Ok(crate::expr::ic_fast_split::emit_template_string_coerce( ctx, &v, diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index e2eae9837a..42e8664d11 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -3081,6 +3081,9 @@ mod math_simple; pub(crate) mod method_site; mod misc_methods; mod new_dynamic; +pub(crate) mod number_to_string_inline; +#[cfg(test)] +mod number_to_string_inline_tests; mod objects_arrays_lit; pub(crate) mod os_uri_dates; pub(crate) mod property_get; diff --git a/crates/perry-codegen/src/expr/number_to_string_inline.rs b/crates/perry-codegen/src/expr/number_to_string_inline.rs new file mode 100644 index 0000000000..6f84bc5a5b --- /dev/null +++ b/crates/perry-codegen/src/expr/number_to_string_inline.rs @@ -0,0 +1,125 @@ +//! Inline small-integer number-to-string (#10762). +//! +//! `String(n)`, `` `${n}` ``, `n.toString()` and `"" + n` on a number all +//! reach `perry-runtime`'s `small_integer_sso_bits` for an integer in +//! `-9999..=99999`: the answer is an SSO immediate, computed with no +//! allocation. What was left was the call itself and the tag ladder in front +//! of it. This emits the same computation at the call site for operands the +//! type analysis says are numbers, and keeps the runtime call on a cold arm +//! for everything else, including a declared-`number` slot that holds +//! something else at run time. +//! +//! The bits are identical to the runtime's, so the two arms are +//! interchangeable: most significant digit in byte 0, a leading `-` below the +//! digits, and the length at `SHORT_STRING_LEN_SHIFT`. `-0` converts to `0` +//! and compares equal to `0.0`, so it prints `"0"` like the runtime. + +use super::FnCtx; +use crate::nanbox::{double_literal, i64_literal, SHORT_STRING_TAG}; +use crate::types::{DOUBLE, I1, I32, I64}; + +/// Bit offset of an SSO string's length byte. Must match +/// `perry-runtime::value::tags::SHORT_STRING_LEN_SHIFT`. +const SHORT_STRING_LEN_SHIFT: u64 = 40; + +/// Emit the SSO bits of `v` inline when it is an integral double in +/// `-9999..=99999`, and `slow(ctx)` otherwise. Returns the merged NaN-boxed +/// string. `slow` runs with the builder positioned in the cold block and must +/// return a DOUBLE. +pub(crate) fn emit_number_to_string_inline( + ctx: &mut FnCtx<'_>, + v: &str, + slow: impl FnOnce(&mut FnCtx<'_>) -> anyhow::Result, +) -> anyhow::Result { + let int_idx = ctx.new_block("num2str.int"); + let fast_idx = ctx.new_block("num2str.fast"); + let slow_idx = ctx.new_block("num2str.slow"); + let merge_idx = ctx.new_block("num2str.merge"); + let int_label = ctx.block_label(int_idx); + let fast_label = ctx.block_label(fast_idx); + let slow_label = ctx.block_label(slow_idx); + let merge_label = ctx.block_label(merge_idx); + + // Range first: `fptosi` of an out-of-range value is poison, so it runs + // only behind this branch. Every comparison with a NaN — a NaN-boxed + // non-number included — is false. + { + let blk = ctx.block(); + let lo = blk.fcmp("oge", v, &double_literal(-9_999.0)); + let hi = blk.fcmp("ole", v, &double_literal(99_999.0)); + let in_range = blk.and(I1, &lo, &hi); + blk.cond_br(&in_range, &int_label, &slow_label); + } + + ctx.current_block = int_idx; + let int = { + let blk = ctx.block(); + let int = blk.fptosi(DOUBLE, v, I32); + let back = blk.sitofp(I32, &int, DOUBLE); + let integral = blk.fcmp("oeq", &back, v); + blk.cond_br(&integral, &fast_label, &slow_label); + int + }; + + ctx.current_block = fast_idx; + let fast = { + let blk = ctx.block(); + let neg = blk.icmp_slt(I32, &int, "0"); + let negated = blk.sub(I32, "0", &int); + let abs32 = blk.select(I1, &neg, I32, &negated, &int); + let abs = blk.zext(I32, &abs32, I64); + // Five digits, most significant first, by fixed-point division: + // `abs * ceil(2^32 / 10^4)` puts `abs / 10^4` in the high word and + // the scaled remainder in the low word, and each `* 10` of the low + // word shifts the next digit up. Exact for every `abs < 100000` + // (checked exhaustively), one multiply per digit where the plain + // `/ 10`, `% 10` pairs cost about five instructions each. Leading + // zeros are digits too, so they are shifted out below. + let low_mask = i64_literal(0xFFFF_FFFF); + let mut scaled = blk.mul(I64, &abs, "429497"); + let mut packed = blk.lshr(I64, &scaled, "32"); + for byte in 1..5u32 { + let low = blk.and(I64, &scaled, &low_mask); + scaled = blk.mul(I64, &low, "10"); + let digit = blk.lshr(I64, &scaled, "32"); + let shifted = blk.shl(I64, &digit, &(byte * 8).to_string()); + packed = blk.or(I64, &packed, &shifted); + } + let ascii = blk.or(I64, &packed, &i64_literal(0x30_3030_3030)); + let mut ndig = "1".to_string(); + for bound in ["10", "100", "1000", "10000"] { + let ge = blk.icmp_uge(I64, &abs, bound); + let one = blk.zext(I1, &ge, I64); + ndig = blk.add(I64, &ndig, &one); + } + let zeros = blk.sub(I64, "5", &ndig); + let shift = blk.shl(I64, &zeros, "3"); + let payload = blk.lshr(I64, &ascii, &shift); + let with_sign = blk.shl(I64, &payload, "8"); + let with_sign = blk.or(I64, &with_sign, &(b'-' as u64).to_string()); + let signed_len = blk.add(I64, &ndig, "1"); + let payload = blk.select(I1, &neg, I64, &with_sign, &payload); + let len = blk.select(I1, &neg, I64, &signed_len, &ndig); + let len = blk.shl(I64, &len, &SHORT_STRING_LEN_SHIFT.to_string()); + let bits = blk.or(I64, &payload, &len); + let bits = blk.or(I64, &bits, &i64_literal(SHORT_STRING_TAG)); + let boxed = blk.bitcast_i64_to_double(&bits); + blk.br(&merge_label); + boxed + }; + + ctx.current_block = slow_idx; + let slow_val = slow(ctx)?; + let slow_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = merge_idx; + let fast_label_end = ctx.block_label(fast_idx); + Ok(ctx.block().phi( + DOUBLE, + &[ + (fast.as_str(), fast_label_end.as_str()), + (slow_val.as_str(), slow_end.as_str()), + ], + )) +} diff --git a/crates/perry-codegen/src/expr/number_to_string_inline_tests.rs b/crates/perry-codegen/src/expr/number_to_string_inline_tests.rs new file mode 100644 index 0000000000..eba2b894df --- /dev/null +++ b/crates/perry-codegen/src/expr/number_to_string_inline_tests.rs @@ -0,0 +1,206 @@ +//! #10762: which lowering the number-to-string spellings and a following +//! `charCodeAt` select. The runtime answers are pinned by +//! `test_gap_10762_inline_number_to_string.ts`; these pin that the inline arms +//! are actually emitted, and emitted only where the operand is a number. + +use crate::{compile_module, CompileOptions}; +use perry_hir::types::Type; +use perry_hir::{BinaryOp, Expr, Function, Module, ModuleInitKind, Param, Stmt}; + +const NUM_ID: u32 = 1; +const STR_ID: u32 = 2; +const INLINE_FAST: &str = "num2str.fast"; +const SSO_CHAR_CODE: &str = "cca.sso_fast"; + +fn ir_opts() -> CompileOptions { + CompileOptions { + emit_ir_only: true, + output_type: "executable".to_string(), + ..Default::default() + } +} + +fn param(ty: Type) -> Param { + Param { + id: NUM_ID, + name: "n".to_string(), + ty, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + } +} + +fn module_with(params: Vec, body: Vec) -> Module { + Module { + name: "num2str_inline.ts".to_string(), + imports: Vec::new(), + exports: Vec::new(), + classes: Vec::new(), + interfaces: Vec::new(), + type_aliases: Vec::new(), + enums: Vec::new(), + globals: Vec::new(), + functions: vec![Function { + id: 1, + name: "probe".to_string(), + type_params: Vec::new(), + params, + return_type: Type::Any, + body, + is_async: false, + is_generator: false, + is_strict: true, + was_plain_async: false, + was_unrolled: false, + is_exported: true, + captures: Vec::new(), + decorators: Vec::new(), + }], + script_global_functions: Vec::new(), + references_global_this: false, + annexb_global_undefined_names: Vec::new(), + init_is_strict: false, + init: Vec::new(), + classic_for_lexical_bindings: std::collections::HashSet::new(), + exported_native_instances: Vec::new(), + exported_func_return_native_instances: Vec::new(), + exported_objects: Vec::new(), + exported_functions: Vec::new(), + widgets: Vec::new(), + uses_fetch: false, + uses_webassembly: false, + extern_funcs: Vec::new(), + init_was_unrolled: false, + has_top_level_await: false, + init_kind: ModuleInitKind::Eager, + async_step_closures: std::collections::HashSet::new(), + closure_display_names: std::collections::HashMap::new(), + class_display_names: std::collections::HashMap::new(), + closure_source_text: std::collections::HashMap::new(), + class_source_text: std::collections::HashMap::new(), + async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), + gen_param_prologue_len: std::collections::HashMap::new(), + } +} + +/// The whole module's IR: whichever clones of the body a typed parameter +/// produces, the lowering under test is in it. +fn ir(params: Vec, body: Vec) -> String { + let module = module_with(params, body); + String::from_utf8(compile_module(&module, ir_opts()).unwrap()).expect("LLVM IR is UTF-8") +} + +fn number_n() -> Expr { + // `n * 1` — numeric by construction, whatever the parameter's proof. + Expr::Binary { + op: BinaryOp::Mul, + left: Box::new(Expr::LocalGet(NUM_ID)), + right: Box::new(Expr::Integer(1)), + } +} + +fn method(object: Expr, property: &str, args: Vec) -> Expr { + Expr::Call { + callee: Box::new(Expr::PropertyGet { + object: Box::new(object), + property: property.to_string(), + byte_offset: 0, + }), + args, + type_args: Vec::new(), + byte_offset: 0, + } +} + +fn returns(e: Expr) -> Vec { + vec![Stmt::Return(Some(e))] +} + +/// Every spelling of a number's conversion gets the inline arm. +/// +/// Sabotage: removing any one call site's `emit_number_to_string_inline` +/// leaves its IR without `num2str.fast`. +#[test] +fn each_spelling_of_a_number_conversion_is_inlined() { + let spellings = [ + ("String(n)", Expr::StringCoerce(Box::new(number_n()))), + ("`${n}`", Expr::TemplateStringCoerce(Box::new(number_n()))), + ("n.toString()", method(number_n(), "toString", Vec::new())), + ( + "\"\" + n", + Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::String(String::new())), + right: Box::new(number_n()), + }, + ), + ]; + for (label, expr) in spellings { + let ir = ir(vec![param(Type::Number)], returns(expr)); + assert!( + ir.contains(INLINE_FAST), + "{label} must build its text inline:\n{ir}" + ); + } +} + +/// An operand the analysis cannot call a number keeps the plain call: the +/// inline arm is code size spent on every site, so it is only emitted where it +/// is expected to fire. +#[test] +fn an_unproven_operand_keeps_the_runtime_call() { + let ir = ir( + vec![param(Type::Any)], + returns(Expr::StringCoerce(Box::new(Expr::LocalGet(NUM_ID)))), + ); + assert!( + !ir.contains(INLINE_FAST), + "an `any` operand must not be inlined:\n{ir}" + ); + assert!(ir.contains("@js_string_coerce_box(")); +} + +/// `const s = String(n); s.charCodeAt(0)` (and `${n}`, `"" + n`) keeps the +/// string proof its initializer carries, so it takes the inline `charCodeAt`, including the +/// arm that reads a short string's byte out of the value. +/// +/// Sabotage: without these initializers in `proven_type_from_init` the call +/// goes to the generic method site and `cca.sso_fast` is absent; without the +/// SSO arm the inline lowering has no `cca.sso_fast` block. +#[test] +fn a_string_coerced_local_reads_char_codes_inline() { + let concat = Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::String(String::new())), + right: Box::new(number_n()), + }; + let inits = [ + ("String(n)", Expr::StringCoerce(Box::new(number_n()))), + ("`${n}`", Expr::TemplateStringCoerce(Box::new(number_n()))), + ("\"\" + n", concat), + ]; + for (label, init) in inits { + let body = vec![ + Stmt::Let { + id: STR_ID, + name: "s".to_string(), + ty: Type::String, + mutable: false, + init: Some(init), + }, + Stmt::Return(Some(method( + Expr::LocalGet(STR_ID), + "charCodeAt", + vec![Expr::Integer(0)], + ))), + ]; + let ir = ir(vec![param(Type::Number)], body); + assert!( + ir.contains(SSO_CHAR_CODE), + "s = {label}; s.charCodeAt must be inline:\n{ir}" + ); + } +} diff --git a/crates/perry-codegen/src/lower_call/property_get/number_string.rs b/crates/perry-codegen/src/lower_call/property_get/number_string.rs index 3d7e9ce3d5..9caad137a1 100644 --- a/crates/perry-codegen/src/lower_call/property_get/number_string.rs +++ b/crates/perry-codegen/src/lower_call/property_get/number_string.rs @@ -208,10 +208,26 @@ pub(crate) fn try_lower_number_string_methods( }) .unwrap_or(false); if !has_user_to_string { + let numeric = args.is_empty() && crate::type_analysis::is_numeric_expr(ctx, object); let v = lower_expr(ctx, object)?; for a in args { let _ = lower_expr(ctx, a)?; } + // A number receiver's small-integer text is built inline (#10762). + if numeric { + return crate::expr::number_to_string_inline::emit_number_to_string_inline( + ctx, + &v, + |ctx| { + Ok(ctx.block().call( + DOUBLE, + "js_jsvalue_to_string_method_box", + &[(DOUBLE, &v)], + )) + }, + ) + .map(Some); + } let blk = ctx.block(); // #3146: an explicit `.toString()` member call must throw a // TypeError on a nullish receiver, unlike abstract ToString diff --git a/crates/perry-codegen/src/lower_string_concat.rs b/crates/perry-codegen/src/lower_string_concat.rs index 24389a07fe..db6275d4ad 100644 --- a/crates/perry-codegen/src/lower_string_concat.rs +++ b/crates/perry-codegen/src/lower_string_concat.rs @@ -575,6 +575,24 @@ fn coerce_concat_body( &[(DOUBLE, l_box), (DOUBLE, r_box)], )); } + // `"" + n` on a number: the small-integer text is built inline + // (#10762); anything else takes the fused call below. + if matches!(left, Expr::String(prefix) if prefix.is_empty()) + && crate::type_analysis::is_numeric_expr(ctx, right) + { + return crate::expr::number_to_string_inline::emit_number_to_string_inline( + ctx, + r_box, + |ctx| { + let l_handle = str_operand_handle_tag_dispatched(ctx, left, l_box); + Ok(ctx.block().call( + DOUBLE, + "js_string_concat_value_box", + &[(I64, &l_handle), (DOUBLE, r_box)], + )) + }, + ); + } // Literal prefix + proven-small value: the per-site table keeps the // hot key off the runtime entirely (`concat_site_cache.rs`). if let Some(value) = diff --git a/crates/perry-codegen/src/lower_string_method/char_code_at.rs b/crates/perry-codegen/src/lower_string_method/char_code_at.rs index 2e1f6417e6..3c884ef1c0 100644 --- a/crates/perry-codegen/src/lower_string_method/char_code_at.rs +++ b/crates/perry-codegen/src/lower_string_method/char_code_at.rs @@ -25,6 +25,12 @@ use crate::lower_string_concat::str_operand_handle_tag_dispatched; const STRING_HEADER_UTF16_LEN_OFFSET: &str = "0"; const STRING_HEADER_BYTE_LEN_OFFSET: &str = "4"; const STRING_HEADER_SIZE: &str = "20"; +/// Bit offset of an SSO string's length byte; must match +/// `perry-runtime::value::tags::SHORT_STRING_LEN_SHIFT`. +const SHORT_STRING_LEN_SHIFT: &str = "40"; +/// The high bit of each of an SSO string's five payload bytes. Clear in all +/// five means the payload is ASCII. +const SHORT_STRING_HIGH_BITS: &str = "551911719040"; // 0x80_8080_8080 /// Inline `s.charCodeAt(i)` for a heap-tagged, all-ASCII receiver. /// @@ -40,10 +46,11 @@ const STRING_HEADER_SIZE: &str = "20"; /// The guard chain reproduces exactly what `js_string_char_code_at` + /// `js_string_index_to_i32` would compute, and anything it cannot prove /// branches to those same two calls: -/// * `STRING_TAG` receiver — an SSO short string, a lying `string` -/// annotation, or `undefined` all take the slow arm, which still routes -/// through `str_operand_handle_tag_dispatched` (SSO materialization -/// included); +/// * `STRING_TAG` receiver — a lying `string` annotation or `undefined` +/// take the slow arm, which still routes through +/// `str_operand_handle_tag_dispatched`. An all-ASCII SSO receiver reads +/// its byte straight out of the value (#10762); a non-ASCII one takes the +/// slow arm; /// * handle ≥ 4096 — the runtime's `is_valid_string_ptr` magnitude check; /// * `0.0 <= index < 2^31-1` — ORDERED comparisons, so a NaN-boxed index /// (a string, a bool, `undefined`, or a genuine NaN) fails both and takes @@ -90,13 +97,54 @@ pub(super) fn lower_char_code_at_inline( let hdr_idx = ctx.new_block("cca.hdr"); let fast_idx = ctx.new_block("cca.fast"); + let sso_check_idx = ctx.new_block("cca.sso_check"); + let sso_idx = ctx.new_block("cca.sso"); + let sso_fast_idx = ctx.new_block("cca.sso_fast"); let slow_idx = ctx.new_block("cca.slow"); let merge_idx = ctx.new_block("cca.merge"); let hdr_label = ctx.block_label(hdr_idx); let fast_label = ctx.block_label(fast_idx); + let sso_check_label = ctx.block_label(sso_check_idx); + let sso_label = ctx.block_label(sso_idx); + let sso_fast_label = ctx.block_label(sso_fast_idx); let slow_label = ctx.block_label(slow_idx); let merge_label = ctx.block_label(merge_idx); - ctx.block().cond_br(&entry_ok, &hdr_label, &slow_label); + ctx.block().cond_br(&entry_ok, &hdr_label, &sso_check_label); + + // SSO receiver (#10762): `String(n)`, `${n}` and `"" + n` return a short + // number's text as an SSO immediate, and the slow arm below materialized + // a heap copy of it through the intern table (about 175 instructions) to + // read one byte. The bytes are in the value itself. An all-ASCII payload + // has one UTF-16 code unit per byte, so byte `index` is the answer; a + // non-ASCII payload (UTF-8 sequences) keeps the slow arm. + ctx.current_block = sso_check_idx; + let is_sso = ctx + .block() + .icmp_eq(I64, &tag, crate::nanbox::SHORT_STRING_TAG_TOP16_I64); + let sso_ok = ctx.block().and(I1, &is_sso, &idx_ok); + ctx.block().cond_br(&sso_ok, &sso_label, &slow_label); + + // Dominated by the index range test, so `fptosi` is in range. + ctx.current_block = sso_idx; + let sso_idx_i32 = ctx.block().fptosi(DOUBLE, idx_d, I32); + let sso_idx_i64 = ctx.block().zext(I32, &sso_idx_i32, I64); + let sso_len_word = ctx.block().lshr(I64, &bits, SHORT_STRING_LEN_SHIFT); + let sso_len = ctx.block().and(I64, &sso_len_word, "255"); + let sso_in_bounds = ctx.block().icmp_ult(I64, &sso_idx_i64, &sso_len); + let high_bits = ctx.block().and(I64, &bits, SHORT_STRING_HIGH_BITS); + let sso_ascii = ctx.block().icmp_eq(I64, &high_bits, "0"); + let sso_fast_ok = ctx.block().and(I1, &sso_in_bounds, &sso_ascii); + ctx.block() + .cond_br(&sso_fast_ok, &sso_fast_label, &slow_label); + + // `index < len <= 5`, so the shift is at most 32. + ctx.current_block = sso_fast_idx; + let sso_shift = ctx.block().shl(I64, &sso_idx_i64, "3"); + let sso_word = ctx.block().lshr(I64, &bits, &sso_shift); + let sso_byte = ctx.block().and(I64, &sso_word, "255"); + let sso_val = ctx.block().uitofp(I64, &sso_byte, DOUBLE); + let sso_pred = ctx.block().label.clone(); + ctx.block().br(&merge_label); // Header block: ASCII + in-range test. Dominated by `handle >= 4096`, so // the two loads are safe; dominated by the index range test, so `fptosi` @@ -150,8 +198,12 @@ pub(super) fn lower_char_code_at_inline( ctx.block().br(&merge_label); ctx.current_block = merge_idx; - Some( - ctx.block() - .phi(DOUBLE, &[(&fast_val, &fast_pred), (&slow_val, &slow_pred)]), - ) + Some(ctx.block().phi( + DOUBLE, + &[ + (&fast_val, &fast_pred), + (&sso_val, &sso_pred), + (&slow_val, &slow_pred), + ], + )) } diff --git a/crates/perry-codegen/src/type_analysis/refine.rs b/crates/perry-codegen/src/type_analysis/refine.rs index 4f9b253aa7..29bd9f147c 100644 --- a/crates/perry-codegen/src/type_analysis/refine.rs +++ b/crates/perry-codegen/src/type_analysis/refine.rs @@ -233,6 +233,26 @@ pub(crate) fn proven_type_from_init(ctx: &FnCtx<'_>, init: &Expr) -> Option { Some(HirType::String) } + // `String(x)` and `${x}` return a string primitive or throw, whatever + // `x` is (#10762). Without this, `const s = String(n)` lost the proof + // its own initializer carries, and `s.charCodeAt(i)` fell to the + // generic method site while `String(n).charCodeAt(i)` took the inline + // string lowering. The value may be SSO, not only a heap string; the + // string lowerings tag-dispatch a proven local for exactly that. + Expr::StringCoerce(_) | Expr::TemplateStringCoerce(_) => Some(HirType::String), + // `+` with a string literal on either side concatenates: a string or a + // throw, whatever the other operand is (`"" + n`, `"k" + i`). Only a + // literal counts; a declared `string` operand is not a proof (#7837). + Expr::Binary { + op: BinaryOp::Add, + left, + right, + } if [left, right] + .iter() + .any(|side| matches!(side.as_ref(), Expr::String(_) | Expr::WtfString(_))) => + { + Some(HirType::String) + } // `Symbol()` identities are system-allocated and never relocated; // `Symbol.for()` identities are process-lifetime `Box` allocations. // Recording the constructor provenance (rather than trusting a diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index ee6adf6909..b6b95cbffe 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -145,7 +145,7 @@ fn young_scavenge_cap_probe(old_reclaimable: impl FnOnce() -> usize) -> YoungSca }; // #8122: before the first copying minor has measured survivors, denominate // the FIRST cap in this program's objects too (one header walk, once per - // process, halfway to the base cap). Not while a collection is in + // process, halfway to the power-on cap). Not while a collection is in // progress or a budgeted cycle is active — the young generation is being // rewritten then and the walk would read forwarding stubs. // @@ -786,6 +786,27 @@ pub(crate) fn gc_note_external_side_free(bytes: usize) { GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL.with(|c| c.set(c.get().saturating_add(bytes))); } +/// Record `bytes` of operation-scoped native scratch that its own operation +/// frees before returning (#11549), such as regex match and compile buffers. +/// +/// Only the live term moves. The collector can never reclaim this memory, so +/// it does not count toward the allocation-churn step and its release does not +/// add to [`external_side_old_reclaim_pressure_bytes`]. Through +/// [`gc_note_external_side_alloc`]/[`gc_note_external_side_free`], a 42-register +/// regex in a loop (dotenv's line pattern) fed 336+ bytes per call into that +/// released-bytes term. The term never matched any garbage on the heap, and it +/// alone drove the loop into repeated full collections. +/// +/// Never collects, so callers may hold raw heap views across it. +pub(crate) fn gc_note_external_transient_alloc(bytes: usize) { + GC_EXTERNAL_SIDE_LIVE_BYTES.with(|c| c.set(c.get().saturating_add(bytes))); +} + +/// Release bytes recorded by [`gc_note_external_transient_alloc`]. +pub(crate) fn gc_note_external_transient_free(bytes: usize) { + GC_EXTERNAL_SIDE_LIVE_BYTES.with(|c| c.set(c.get().saturating_sub(bytes))); +} + /// The external side-buffer term of OLD-RECLAIM pressure. /// /// Live bytes plus all reported releases since the last full baseline. The diff --git a/crates/perry-runtime/src/gc/tenuring.rs b/crates/perry-runtime/src/gc/tenuring.rs index eb6b5f50af..1a742305ab 100644 --- a/crates/perry-runtime/src/gc/tenuring.rs +++ b/crates/perry-runtime/src/gc/tenuring.rs @@ -198,6 +198,17 @@ const RAISE_DEBOUNCE_CYCLES: u8 = 2; /// adaptive threshold has eliminated the re-copying). const NURSERY_CAP_SCALE_MAX: u8 = 4; +/// How far below the base the cap may shrink, as a right shift: 2 = ÷4, a +/// 4 MB nursery at the default 16 MB base (#11549). +/// +/// A copying minor is O(survivors), so on a workload whose survivor influx is +/// a sliver of the cap, a smaller Eden costs a few more cheap collections and +/// saves the rest of the Eden in resident memory. Measured on dotenv/parse +/// (1-2 KB of influx per 12-16 MB minor): the ÷4 nursery took peak RSS from +/// 68.5 MB to 52.1 MB for +0.4% instructions. The floor stops the collection +/// count from running away where the fixed per-minor cost is what matters. +const NURSERY_CAP_SHRINK_SHIFT_MAX: u8 = 2; + crate::perry_thread_local! { /// Power-on threshold. This is `OCCUPANCY_MIN_SURVIVALS`, not the ceiling: /// see `SURVIVOR_ROUND_MEASURED`. Starting at the ceiling is a claim that @@ -220,6 +231,20 @@ crate::perry_thread_local! { /// Influx-driven multiplier (1, 2, or 4) applied to the scavenge nursery /// cap. Power of two; grows/shrinks one step at a time, debounced. static NURSERY_CAP_SCALE: super::TriggerInput = const { super::TriggerInput::new(1) }; + /// Shift below the base cap, `0..=NURSERY_CAP_SHRINK_SHIFT_MAX`, taken only + /// while `NURSERY_CAP_SCALE` is 1 (#11549). Grows and shrinks one step at + /// a time, debounced like the scale. + /// + /// Power-on is the floor, for the reason `TENURING_SURVIVALS` powers on at + /// its floor: a full-size first nursery is a claim that young objects live + /// long, made before any have had the chance to die, and it is the + /// expensive direction of that claim. For resident memory it is also + /// irreversible, because peak RSS is set by the first nurseries and a + /// later shrink cannot lower a high-water mark. Survivor influx above 4% + /// of the cap walks it back up in two cycles per step, so a workload that + /// retains reaches the base within four minors. + static NURSERY_CAP_SHRINK_SHIFT: super::TriggerInput = + const { super::TriggerInput::new(NURSERY_CAP_SHRINK_SHIFT_MAX) }; static CAP_GROW_STREAK: Cell = const { Cell::new(0) }; static CAP_SHRINK_STREAK: Cell = const { Cell::new(0) }; /// #7929: mean size of the objects the last copying minor moved. Seeded at @@ -357,7 +382,8 @@ pub(super) fn scavenge_nursery_cap_effective_bytes() -> usize { /// as the two-term policy it is. pub(super) fn influx_driven_nursery_cap_bytes() -> usize { let constant_band = gc_scavenge_nursery_cap_bytes() - .saturating_mul(NURSERY_CAP_SCALE.with(TriggerInput::get) as usize); + .saturating_mul(NURSERY_CAP_SCALE.with(TriggerInput::get) as usize) + >> NURSERY_CAP_SHRINK_SHIFT.with(TriggerInput::get); // The multiply is done in u64 deliberately. `usize::saturating_mul` on an // ILP32 target (watchOS/visionOS are 32-bit) would saturate a 64 MB band // against a 1000-per-mille factor at `u32::MAX` and the following divide @@ -477,10 +503,11 @@ pub(super) fn note_surviving_object_census(moved_bytes: usize, moved_objects: us /// /// # What it does /// -/// Halfway to the configured base cap (8 MB of from-space by default) — a -/// point every program that will ever reach the cap passes exactly once — hop -/// the young generation's headers (`arena::young_allocation_census`, ~1M -/// instructions for 8 MB of small objects, paid ONCE per process) and install +/// Halfway to the power-on cap (2 MB of from-space by default: the 16 MB base +/// at its ÷4 power-on floor, #11549) — a point every program that will ever +/// reach the cap passes exactly once — hop the young generation's headers +/// (`arena::young_allocation_census`, ~1M instructions per 8 MB of small +/// objects, paid ONCE per process) and install /// `bytes / objects` as the mean. The first minor is then object-denominated /// like every later one, and a smaller representation no longer buys the /// collector a bigger first trace. The one-sided clamp still applies: a mean @@ -488,7 +515,7 @@ pub(super) fn note_surviving_object_census(moved_bytes: usize, moved_objects: us /// allocation stream cannot raise the cap. The collector's survivor census /// overwrites this seed at the first minor, so steady state is unchanged. /// -/// Returns without walking when the base cap is not yet half full, when a +/// Returns without walking when the power-on cap is not yet half full, when a /// census (either kind) already exists, or when the nursery is empty. /// Has the object denomination been seeded, by an allocation census or by a /// copying minor's survivor census? Once true it stays true for the process @@ -499,10 +526,13 @@ pub(super) fn object_census_seeded() -> bool { } /// The young-generation occupancy at which the one-time allocation census is -/// taken: halfway to the base cap. Shared with the trigger watermark (#10698), +/// taken: halfway to the power-on cap. Shared with the trigger watermark (#10698), /// which must not let a fast-path answer carry past the reading that seeds. pub(super) fn object_census_seed_point_bytes() -> usize { - super::policy::gc_scavenge_nursery_cap_bytes() / 2 + // Half the POWER-ON cap, not the base: the nursery starts at the shrink + // floor (#11549), and a seed point past the first cap would let the first + // minor run before the census it exists to precede. + (super::policy::gc_scavenge_nursery_cap_bytes() >> NURSERY_CAP_SHRINK_SHIFT_MAX) / 2 } pub(super) fn maybe_seed_object_census_from_allocation(from_space_in_use_bytes: usize) { @@ -711,29 +741,55 @@ pub(super) fn retune_after_scavenge( /// Shrink one step when influx falls below 1% for two consecutive cycles. /// The 4%/1% band is wide enough that the scale cannot oscillate on a /// steady workload (growing halves the observed ratio, 4%/2 = 2% > 1%). +/// +/// #11549: the shrink continues below the base, to `base >> 2`, while the +/// influx stays under 1% (see [`NURSERY_CAP_SHRINK_SHIFT_MAX`]), and a grow +/// undoes that shift, one debounced step at a time, before it raises the +/// scale. Each shrink step at most doubles the observed ratio, 1% to 2%, still +/// under the 4% that grows it back, so the same band keeps the extension from +/// oscillating. +/// +/// The climb is debounced like every other step, not a jump back to the base +/// on the first retaining minor: a program's first minor sees its start-up +/// data survive (dotenv/parse: ~10% of a 4 MB nursery), and a jump on that one +/// reading put its peak RSS straight back at the 16 MB nursery's (52 → 62 MB). +/// A genuinely retaining program pays a few extra early minors on the climb, +/// which measured within ±1.5% of main in total instructions from 2M +/// iterations of a keep-one-in-ten loop and 22–27% below it at 0.5–1M. fn retune_nursery_cap_scale(eden_live_bytes: usize) { let cap = scavenge_nursery_cap_effective_bytes(); let scale = NURSERY_CAP_SCALE.with(TriggerInput::get); + let shift = NURSERY_CAP_SHRINK_SHIFT.with(TriggerInput::get); if eden_live_bytes > cap / 25 { CAP_SHRINK_STREAK.with(|s| s.set(0)); - if scale < NURSERY_CAP_SCALE_MAX { + if shift > 0 || scale < NURSERY_CAP_SCALE_MAX { let streak = CAP_GROW_STREAK.with(|s| s.get()).saturating_add(1); if streak >= RAISE_DEBOUNCE_CYCLES { CAP_GROW_STREAK.with(|s| s.set(0)); - NURSERY_CAP_SCALE.with(|s| s.set(scale * 2)); - diag_cap_scale(scale, scale * 2, eden_live_bytes); + if shift > 0 { + NURSERY_CAP_SHRINK_SHIFT.with(|s| s.set(shift - 1)); + diag_cap_shift(shift, shift - 1, eden_live_bytes); + } else { + NURSERY_CAP_SCALE.with(|s| s.set(scale * 2)); + diag_cap_scale(scale, scale * 2, eden_live_bytes); + } } else { CAP_GROW_STREAK.with(|s| s.set(streak)); } } } else if eden_live_bytes < cap / 100 { CAP_GROW_STREAK.with(|s| s.set(0)); - if scale > 1 { + if scale > 1 || shift < NURSERY_CAP_SHRINK_SHIFT_MAX { let streak = CAP_SHRINK_STREAK.with(|s| s.get()).saturating_add(1); if streak >= RAISE_DEBOUNCE_CYCLES { CAP_SHRINK_STREAK.with(|s| s.set(0)); - NURSERY_CAP_SCALE.with(|s| s.set(scale / 2)); - diag_cap_scale(scale, scale / 2, eden_live_bytes); + if scale > 1 { + NURSERY_CAP_SCALE.with(|s| s.set(scale / 2)); + diag_cap_scale(scale, scale / 2, eden_live_bytes); + } else { + NURSERY_CAP_SHRINK_SHIFT.with(|s| s.set(shift + 1)); + diag_cap_shift(shift, shift + 1, eden_live_bytes); + } } else { CAP_SHRINK_STREAK.with(|s| s.set(streak)); } @@ -744,6 +800,16 @@ fn retune_nursery_cap_scale(eden_live_bytes: usize) { } } +fn diag_cap_shift(from: u8, to: u8, eden_live_bytes: usize) { + if crate::gc::gc_diag_enabled() { + eprintln!( + "[gc-tenuring] nursery cap shrink 1/{} -> 1/{} (eden_live_bytes={eden_live_bytes})", + 1u32 << from, + 1u32 << to + ); + } +} + /// Minimum Eden survival rate, in tenths, for a mark-sweep to seed the /// promote-on-first-copy lock: ≥90% of the Eden bytes the sweep classified /// must have been live. That is the "the aging round would filter nothing" @@ -857,6 +923,7 @@ pub(super) fn reset_for_test() { UNLOCK_STREAK.with(|s| s.set(0)); PREV_COPIED_BYTES.with(|c| c.set(0)); NURSERY_CAP_SCALE.with(|s| s.set(1)); + NURSERY_CAP_SHRINK_SHIFT.with(|s| s.set(NURSERY_CAP_SHRINK_SHIFT_MAX)); CAP_GROW_STREAK.with(|s| s.set(0)); CAP_SHRINK_STREAK.with(|s| s.set(0)); MEAN_SURVIVING_OBJECT_BYTES.with(|s| s.set(NURSERY_CAP_REFERENCE_OBJECT_BYTES)); @@ -947,11 +1014,12 @@ mod tests { #[test] fn census_carries_forward_across_a_cycle_that_moved_nothing() { reset_for_test(); - let base = gc_scavenge_nursery_cap_bytes(); + // The power-on floor (#11549) is the band this test scales. + let base = gc_scavenge_nursery_cap_bytes() / 4; assert_eq!( influx_driven_nursery_cap_bytes(), base, - "unmeasured process must pace exactly as the pre-#7929 collector did" + "an unmeasured process paces on the unscaled power-on band" ); note_surviving_object_census(56 * 1000, 1000); @@ -970,7 +1038,7 @@ mod tests { } /// #8122: the allocation census seeds the mean ONCE, only past half the - /// base cap, and never after a census of either kind exists. + /// power-on cap, and never after a census of either kind exists. /// /// Pure-policy half. The walk itself (a real nursery, real headers) is /// driven in `gc::tests::copying::adaptive_tenuring`; this pins the gate @@ -979,13 +1047,17 @@ mod tests { fn allocation_census_seed_is_gated_and_one_shot() { reset_for_test(); let base = gc_scavenge_nursery_cap_bytes(); + // Half the ÷4 power-on cap (#11549), as a literal so a drifted seed + // point fails here rather than moving with the code. + let seed_point = base / 8; + assert_eq!(object_census_seed_point_bytes(), seed_point); assert!(!object_census_seeded_for_test()); - // Below half the base cap: nothing happens, the seed stays armed. - maybe_seed_object_census_from_allocation(base / 2 - 1); + // Below half the power-on cap: nothing happens, the seed stays armed. + maybe_seed_object_census_from_allocation(seed_point - 1); assert!( !object_census_seeded_for_test(), - "the probe must not fire below half the base cap" + "the probe must not fire below half the power-on cap" ); assert_eq!( mean_surviving_object_bytes(), @@ -1043,6 +1115,8 @@ mod tests { #[test] fn drops_immediately_and_rises_debounced() { reset_for_test(); + // A threshold test, not a cap test: hold `desired` still (#11549). + start_at_base_for_test(); let desired = desired_survivor_bytes(); // Power-on is the FLOOR now, not the ceiling (startup follow-up): the // ladder may not claim a lifetime in either direction without evidence. @@ -1106,6 +1180,54 @@ mod tests { reset_for_test(); } + /// #11549: the nursery powers on at the `base / 4` floor; a heavy influx + /// walks it up, one debounced step at a time, undoing the shrink before the + /// scale may grow past the base; a sustained sliver of influx walks it back + /// down to the floor and no further. + /// + /// The expected caps are literals, not `base >> NURSERY_CAP_SHRINK_SHIFT_MAX`, + /// which would pass with the floor set to 0 and the rule doing nothing. + /// Sabotage: power-on at the base fails the first assertion (16 MB); an + /// undebounced climb fails "one heavy cycle alone does not move it" (a + /// program's first minor sees its start-up data survive); dropping the + /// shrink leaves the cap at the base after the quiet phase. + #[test] + fn the_nursery_starts_at_a_floor_and_follows_survivor_influx() { + reset_for_test(); + let base = gc_scavenge_nursery_cap_bytes(); + let cap = influx_driven_nursery_cap_bytes; + assert_eq!(cap(), base / 4, "power-on is the floor"); + for _ in 0..8 { + retune_nursery_cap_scale(1024); + } + assert_eq!(cap(), base / 4, "a tiny influx never goes below the floor"); + + let heavy = base; + retune_nursery_cap_scale(heavy); + assert_eq!(cap(), base / 4, "one heavy cycle alone does not move it"); + retune_nursery_cap_scale(heavy); + assert_eq!(cap(), base / 2); + retune_nursery_cap_scale(heavy); + retune_nursery_cap_scale(heavy); + assert_eq!(cap(), base); + retune_nursery_cap_scale(heavy); + retune_nursery_cap_scale(heavy); + assert_eq!(cap(), base * 2, "past the base, the scale grows as before"); + + // Quiet again: the scale comes down first, then the shrink. + retune_nursery_cap_scale(1024); + retune_nursery_cap_scale(1024); + assert_eq!(cap(), base); + retune_nursery_cap_scale(1024); + retune_nursery_cap_scale(1024); + assert_eq!(cap(), base / 2); + for _ in 0..8 { + retune_nursery_cap_scale(1024); + } + assert_eq!(cap(), base / 4); + reset_for_test(); + } + /// #9851, both halves of the rule in one test, in the #7909 two-phase shape /// so the decline is ATTRIBUTED rather than merely absent. /// @@ -1126,6 +1248,8 @@ mod tests { #[test] fn occupancy_alone_never_claims_promote_on_first_copy_but_the_lock_still_can() { reset_for_test(); + // A threshold test, not a cap test: hold `desired` still (#11549). + start_at_base_for_test(); let d = desired_survivor_bytes(); // Phase 1: influx 16x the desired survivor size — the occupancy formula @@ -1286,6 +1410,8 @@ mod tests { #[test] fn survival_rate_lock_breaks_a_saturated_pipeline() { reset_for_test(); + // A threshold test, not a cap test: hold `desired` still (#11549). + start_at_base_for_test(); let d = desired_survivor_bytes(); // tree.ts steady state: influx sits JUST under desired (occupancy // alone settles at S=2), the survivor space holds 3 cohorts, and @@ -1321,9 +1447,17 @@ mod tests { reset_for_test(); } + /// Put the cap at the base, as a workload whose influx has walked it up + /// from the power-on floor would have (#11549). For tests of the ladder + /// ABOVE the base; the floor has its own test. + fn start_at_base_for_test() { + NURSERY_CAP_SHRINK_SHIFT.with(|s| s.set(0)); + } + #[test] fn cap_scale_grows_on_heavy_influx_and_shrinks_when_quiet() { reset_for_test(); + start_at_base_for_test(); let base = gc_scavenge_nursery_cap_bytes(); assert_eq!(scavenge_nursery_cap_effective_bytes(), base); // Influx above 4% of the cap: one debounce cycle, then a ×2 step. @@ -1572,14 +1706,16 @@ mod tests { // Honest about its limits: on a quiescent test thread old-gen is // ~empty, so this cannot distinguish the two terms by value — it only // proves the accessor and the policy agree on the live inputs, and - // that the #7377 floor survives whatever old-gen happens to be. + // that the #7377 floor survives whatever old-gen happens to be. That + // floor is the influx term's own, now the ÷4 power-on floor (#11549): + // bounded well away from the near-zero cap #7377 fixed. reset_for_test(); let expected = scavenge_nursery_cap_from( influx_driven_nursery_cap_bytes(), old_gen_reclaimable_pressure_bytes(), ); assert_eq!(scavenge_nursery_cap_effective_bytes(), expected); - assert!(scavenge_nursery_cap_effective_bytes() >= gc_scavenge_nursery_cap_bytes()); + assert!(scavenge_nursery_cap_effective_bytes() >= gc_scavenge_nursery_cap_bytes() / 4); reset_for_test(); } } diff --git a/crates/perry-runtime/src/gc/tests/copying/adaptive_tenuring.rs b/crates/perry-runtime/src/gc/tests/copying/adaptive_tenuring.rs index b8259420ac..1f21b6c865 100644 --- a/crates/perry-runtime/src/gc/tests/copying/adaptive_tenuring.rs +++ b/crates/perry-runtime/src/gc/tests/copying/adaptive_tenuring.rs @@ -133,7 +133,7 @@ fn copying_minor_feeds_the_object_denomination_census() { } /// #8122: BEFORE any copying minor has run, once the young generation is -/// half-way to the base cap, one header walk seeds the object denomination +/// half-way to the power-on cap, one header walk seeds the object denomination /// with the mean size of what was actually allocated — so the FIRST minor is /// object-denominated too, and a smaller representation stops buying the /// collector a bigger first trace. @@ -159,11 +159,17 @@ fn allocation_census_seeds_the_first_cap_before_any_minor() { ); assert!(!crate::gc::tenuring::object_census_seeded_for_test()); - // Fill Eden past half the base cap with two-field object literals — the - // representation whose shrink motivated this. Unrooted is fine: nothing - // collects here, and an allocation census counts dead objects too. + // Fill Eden past the seed point (half the power-on cap, #11549) with + // two-field object literals — the representation whose shrink motivated + // this. Unrooted is fine: nothing collects here, and an allocation census + // counts dead objects too. + let seed_point = crate::gc::tenuring::object_census_seed_point_bytes(); + assert!( + seed_point < base / 4, + "the census must run before the first (power-on) cap is reached" + ); let (mut allocated_bytes, mut allocated_objects) = (0usize, 0usize); - while crate::arena::copying_from_space_in_use_bytes() < base / 2 + 64 * 1024 { + while crate::arena::copying_from_space_in_use_bytes() < seed_point + 64 * 1024 { for _ in 0..1024 { let obj = crate::object::js_object_alloc(0, 2); let header = unsafe { crate::value::addr_class::try_read_gc_header(obj as usize) } @@ -182,7 +188,7 @@ fn allocation_census_seeds_the_first_cap_before_any_minor() { let _ = crate::gc::policy::young_scavenge_cap_due(); assert!( crate::gc::tenuring::object_census_seeded_for_test(), - "half-way to the base cap the allocation census must have run" + "half-way to the power-on cap the allocation census must have run" ); let seeded = crate::gc::tenuring::mean_surviving_object_bytes(); // The nursery may hold a few pre-existing objects from the guard's own @@ -196,10 +202,11 @@ fn allocation_census_seeds_the_first_cap_before_any_minor() { seeded, crate::gc::tenuring::NURSERY_CAP_REFERENCE_OBJECT_BYTES ); - // ...and the first cap already reflects it — before any collection. + // ...and the first cap already reflects it — before any collection. The + // first cap is the ÷4 power-on floor (#11549). assert_eq!( crate::gc::tenuring::influx_driven_nursery_cap_bytes(), - base * crate::gc::tenuring::nursery_cap_object_scale_permille(seeded) / 1000 + base / 4 * crate::gc::tenuring::nursery_cap_object_scale_permille(seeded) / 1000 ); // One-shot: a different population allocated afterwards does not move diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_construction.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_construction.rs index fbf11f0a06..2a4bae1a97 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_construction.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_construction.rs @@ -70,6 +70,37 @@ fn the_pre_search_poll_runs_on_one_search_in_sixty_four() { ); } +/// Operation-owned regex scratch is transient: while it lives it counts as +/// live external bytes, and releasing it adds nothing to the released-bytes +/// pressure that schedules full collections (#11549). +/// +/// Sabotage: noting a `Buffer` through `gc_note_external_side_alloc`/`_free` +/// as before makes `drained` grow by the buffer's size. +#[test] +fn regex_scratch_buffers_do_not_count_as_released_external_pressure() { + use crate::gc::policy::GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL; + use crate::regex::perex_memory::{Buffer, MemoryBudget}; + + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let live = external_side_live_bytes(); + let drained = GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL.with(TriggerInput::get); + let memory = MemoryBudget::new(1 << 20); + { + let buffer = Buffer::::new(&memory, 1024).unwrap(); + assert!( + external_side_live_bytes() >= live + 1024 * std::mem::size_of::(), + "a live buffer must still be visible as live external bytes" + ); + drop(buffer); + } + assert_eq!(external_side_live_bytes(), live); + assert_eq!( + GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL.with(TriggerInput::get), + drained, + "a freed scratch buffer must not count as released pressure" + ); +} + fn construct<'s>(scope: &'s RuntimeHandleScope, pattern: &[u8], flags: &[u8]) -> RuntimeHandle<'s> { let p = text(scope, pattern); let f = text(scope, flags); diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_execution.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_execution.rs index 572fa5b9f6..0d66afdb7e 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_execution.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_execution.rs @@ -37,6 +37,60 @@ fn compile<'s>( BoundProgram::new(program, &mut budget).unwrap() } +/// dotenv's line pattern: 42 match registers, over the 32 the lent cell held. +const DOTENV_LINE: &str = r#"(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)"#; + +/// A program with more than 32 registers borrows the thread's lent scratch +/// instead of building and freeing owned buffers on every search (#11549). +/// +/// Sabotage: restoring the fixed 32-register cell sends every one of these +/// searches down the owned path, and `owned` reads 64. +#[test] +fn a_search_over_thirty_two_registers_borrows_the_lent_scratch() { + use crate::regex::perex_runtime::OWNED_SEARCHES_RUN; + + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let scope = RuntimeHandleScope::new(); + let program = compile(&scope, DOTENV_LINE, "m"); + let registers = program + .with_view(|program| program.register_count()) + .unwrap(); + assert!( + registers > 32, + "fixture must need more registers than the old cell held, got {registers}" + ); + let input = subject(&scope, b"KEY_1=value_1 # trailing comment\n"); + let memory = MemoryBudget::new(1 << 20); + let search = || { + let mut budget = Budget::new(usize::MAX); + host::find( + &program, + &input, + 0, + CaptureMode::All, + &mut budget, + &memory, + usize::MAX, + &mut host::poll, + ) + .unwrap() + .expect("fixture: every search must match") + .full + }; + // Warm-up sizes the cell. A search that outgrows its frames or undo + // entries grows them for the next call, which can take a few calls. + for _ in 0..8 { + search(); + } + let before = OWNED_SEARCHES_RUN.with(std::cell::Cell::get); + for _ in 0..64 { + assert_eq!(search(), Span::new(0, 32).unwrap()); + } + let owned = OWNED_SEARCHES_RUN.with(std::cell::Cell::get) - before; + assert_eq!(owned, 0, "no search may fall back to owned buffers"); + assert_eq!(memory.live_bytes(), 0); +} + #[test] fn perex_host_buffers_account_overlap_failure_and_unwind() { let _guard = CopyingNurseryTestGuard::new(0); diff --git a/crates/perry-runtime/src/gc/tests/triggers.rs b/crates/perry-runtime/src/gc/tests/triggers.rs index 8fd1fc2515..db957f8bf2 100644 --- a/crates/perry-runtime/src/gc/tests/triggers.rs +++ b/crates/perry-runtime/src/gc/tests/triggers.rs @@ -753,7 +753,10 @@ fn test_effective_arena_trigger_respects_armed_values() { // the cap's own basis. let nursery_capped = gc_moving_loop_polls_enabled(); let ceiling = gc_trigger_absolute_ceiling_bytes(); - let nursery_cap = gc_scavenge_nursery_cap_bytes(); + // The clamp is the EFFECTIVE cap, which powers on at the ÷4 floor rather + // than the configured base (#11549). + let nursery_cap = gc_scavenge_nursery_cap_bytes() + .min(super::super::tenuring::scavenge_nursery_cap_effective_bytes()); let prev_trigger = GC_NEXT_TRIGGER_BYTES.with(|c| c.get()); let prev_armed = GC_TRIGGER_ARMED.with(|c| c.get()); diff --git a/crates/perry-runtime/src/map.rs b/crates/perry-runtime/src/map.rs index b0ac88ef19..717bce3f84 100644 --- a/crates/perry-runtime/src/map.rs +++ b/crates/perry-runtime/src/map.rs @@ -1421,28 +1421,32 @@ pub(crate) unsafe fn compact_if_holey(map: *mut MapHeader) { /// paths into one body, and the register pressure of those cold paths costs /// every lookup the full prologue/epilogue (eight callee-saved GPRs and four /// FP registers on arm64 — the profile put a third of the function's self -/// time there). The lane here answers the two shapes the numeric side-table -/// exists for — a plain (untagged, non-NaN, non-zero) number key against a -/// small map's entries by bit identity, or against the dense integer range -/// table — and returns `None` for everything else so [`find_key_index_cold`] -/// decides it. A dense-range miss is definitive for its span (every insert, +/// time there). The lane here answers a small map's lookup by bit identity for +/// any key, a plain (untagged, non-NaN, non-zero) number key's miss there, and +/// a plain number against the dense integer range table, and returns `None` +/// for everything else so [`find_key_index_cold`] decides it. A dense-range miss is definitive for its span (every insert, /// delete, clear and GC rewrite keeps the table exact), exactly as in the cold /// path; a key outside the span goes to the hashed index there. #[inline(always)] unsafe fn find_key_index_hot(map: *const MapHeader, key: f64) -> Option { let used = (*map).used; let key_bits = key.to_bits(); - if !is_plain_nonzero_number_bits(key_bits) { - return None; - } if used <= SIDE_TABLE_THRESHOLD { + // A bit-identical entry is THE match for a key of any type: a Map + // never holds two SameValueZero-equal keys, and both sides are already + // normalized (see `find_identical_key`). The common string-keyed shape + // looks a key up with the very value it was inserted with, so it is + // answered here without the out-of-line call (#10697). A miss is + // definitive only for a plain number; any other key may still be + // content-equal to an entry, which the cold path decides. let entries = entries_ptr(map); - for i in 0..used { - if ptr::read(entries.add((i as usize) * 2)).to_bits() == key_bits { - return Some(i as i32); - } + if let Some(i) = find_identical_key(entries, used, key_bits) { + return Some(i); } - return Some(-1); + return is_plain_nonzero_number_bits(key_bits).then_some(-1); + } + if !is_plain_nonzero_number_bits(key_bits) { + return None; } let index = (*map).store.as_ref().map(|store| &store.numeric)?; let dense = index.dense.as_ref()?; @@ -1466,12 +1470,21 @@ pub(crate) unsafe fn find_key_index(map: *const MapHeader, key: f64) -> i32 { find_key_index_cold(map, key) } +#[cfg(test)] +crate::perry_thread_local! { + /// Test-only: lookups [`find_key_index_hot`] handed to the cold path, so a + /// test can assert which lane answered (#10697). + pub(crate) static COLD_LOOKUPS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Every lookup shape [`find_key_index_hot`] declines: tagged, zero and NaN /// keys, string content hashing, the pointer-identity index, the hashed /// numeric index, and the generic linear compare. Out of line on purpose — /// see the hot lane. #[inline(never)] unsafe fn find_key_index_cold(map: *const MapHeader, key: f64) -> i32 { + #[cfg(test)] + COLD_LOOKUPS.with(|n| n.set(n.get() + 1)); let used = (*map).used; let key_bits = key.to_bits(); diff --git a/crates/perry-runtime/src/map/string_key.rs b/crates/perry-runtime/src/map/string_key.rs index 24ba76dbbf..85d35c6728 100644 --- a/crates/perry-runtime/src/map/string_key.rs +++ b/crates/perry-runtime/src/map/string_key.rs @@ -185,6 +185,50 @@ mod tests { f64::from_bits(JSValue::try_short_string(s.as_bytes()).unwrap().bits()) } + /// A small map answers a lookup made with the very key value it stored, + /// SSO or heap, from the inlined hot lane without the out-of-line cold + /// call; a content-equal but distinct heap key, and every miss, still + /// reach the cold path and get the same answers (#10697). + /// + /// Sabotage: restoring the hot lane's plain-number-only admission sends + /// all eight identical lookups to the cold path, and `cold` reads 8. + #[test] + fn a_small_map_answers_an_identical_key_without_the_cold_path() { + let mut map = js_map_alloc(4); + let keys = [ + sso("alpha"), + sso("beta"), + heap("category-gamma"), + heap("category-delta"), + ]; + for (i, &k) in keys.iter().enumerate() { + map = js_map_set(map, k, i as f64); + } + let cold = || super::super::COLD_LOOKUPS.with(std::cell::Cell::get); + let before = cold(); + for _ in 0..2 { + for (i, &k) in keys.iter().enumerate() { + assert_eq!(js_map_get(map, k), i as f64); + } + } + assert_eq!( + cold() - before, + 0, + "identical keys must not leave the hot lane" + ); + + let before = cold(); + assert_eq!(js_map_get(map, heap("category-gamma")), 2.0); + assert_eq!(js_map_get(map, heap("alpha")), 0.0); + assert_eq!(js_map_get(map, sso("gamma")).to_bits(), TAG_UNDEFINED); + assert_eq!(js_map_get(map, 7.5).to_bits(), TAG_UNDEFINED); + assert_eq!( + cold() - before, + 3, + "content matches and non-number misses go cold" + ); + } + /// Every (key, entry) pairing the lane decides agrees with the generic /// comparison it replaces: same content across SSO / heap / distinct heap /// allocations, and every flavour of mismatch. diff --git a/crates/perry-runtime/src/object/descriptor_state.rs b/crates/perry-runtime/src/object/descriptor_state.rs index 000896b7c5..b5d3d04d83 100644 --- a/crates/perry-runtime/src/object/descriptor_state.rs +++ b/crates/perry-runtime/src/object/descriptor_state.rs @@ -1880,7 +1880,9 @@ pub(crate) fn define_builtin_data_property( } super::object_ops::define_property_force_store_value(obj, key, value); } else { - js_object_set_field_by_name(obj, key, value); + super::own_override::as_builtin_definition(|| { + js_object_set_field_by_name(obj, key, value); + }); } } set_builtin_property_attrs(obj as usize, name, attrs); diff --git a/crates/perry-runtime/src/object/exotic_expando.rs b/crates/perry-runtime/src/object/exotic_expando.rs index 309ddcaaea..60732983ca 100644 --- a/crates/perry-runtime/src/object/exotic_expando.rs +++ b/crates/perry-runtime/src/object/exotic_expando.rs @@ -209,7 +209,7 @@ pub(crate) fn value_store(kind: ExoticKind, addr: usize, key: &str, bits: u64) { // that gauntlet, so the guard's predicate answered "no own override" and // the builtin still won. Arming at the store itself is the funnel the // gauntlet was chosen to approximate. - crate::object::own_override::note_exotic_named_prop_install(); + crate::object::own_override::note_exotic_named_prop_install(addr); match kind { ExoticKind::Error => { crate::node_submodules::set_error_user_prop(addr, key, f64::from_bits(bits)) diff --git a/crates/perry-runtime/src/object/field_set_by_name.rs b/crates/perry-runtime/src/object/field_set_by_name.rs index f58f9664aa..8ec5e79109 100644 --- a/crates/perry-runtime/src/object/field_set_by_name.rs +++ b/crates/perry-runtime/src/object/field_set_by_name.rs @@ -320,7 +320,7 @@ pub extern "C" fn js_object_set_field_by_name( // it over-approximates, and the only cost of that is the guard's slow // side. Named keys only: an index write is not a method shadow. if !key.is_null() { - crate::object::own_override::note_exotic_named_prop_install(); + crate::object::own_override::note_exotic_named_prop_install(obj as usize); } // A Buffer is an ordinary object in Node (a Uint8Array), so `buf.foo = v` // stores an own property — and an own key SHADOWS the same-named 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..65078a56b6 100644 --- a/crates/perry-runtime/src/object/global_this/populate.rs +++ b/crates/perry-runtime/src/object/global_this/populate.rs @@ -11,6 +11,14 @@ use super::*; /// `var arrayProto = Array.prototype` chained read inside /// `runInContext`. pub(crate) fn populate_global_this_builtins(singleton_at_entry: *mut ObjectHeader) { + // Every install below is a builtin definition, which arms the own-override + // guard only on a Map, Set or Date owner (#10697). + super::super::own_override::as_builtin_definition(|| { + populate_global_this_builtins_inner(singleton_at_entry) + }) +} + +fn populate_global_this_builtins_inner(singleton_at_entry: *mut ObjectHeader) { if singleton_at_entry.is_null() { return; } diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index d761a5ed66..3f84e49532 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -199,6 +199,8 @@ mod object_literal_ops; pub(crate) mod object_ops; pub(crate) mod own_override; #[cfg(test)] +mod own_override_builtin_install_tests; +#[cfg(test)] mod own_override_push_tests; pub(crate) use object_ops::{ensure_key_in_keys_array, install_builtin_getter}; mod object_ops_frozen; diff --git a/crates/perry-runtime/src/object/own_override.rs b/crates/perry-runtime/src/object/own_override.rs index f97de1bbf0..e1247b4642 100644 --- a/crates/perry-runtime/src/object/own_override.rs +++ b/crates/perry-runtime/src/object/own_override.rs @@ -55,7 +55,8 @@ use std::sync::atomic::Ordering; -/// Has any non-`ObjectHeader` cell ever taken a named property? +/// Has any non-`ObjectHeader` cell ever taken a named property that could +/// shadow a builtin on a guarded receiver? /// /// Set-only. See the module docs for why it is armed early and never cleared. /// Exported so EMITTED CODE can test it inline. The guard's common case is @@ -66,15 +67,107 @@ use std::sync::atomic::Ordering; /// emitted guard reads with one monotonic load and a not-taken branch, with /// the call left behind it for the case that is almost never taken. /// +/// The runtime's own builtin definitions do not arm it unless their owner is +/// a Map, Set or Date cell (#10697; see [`as_builtin_definition`]). [`OWN_NAMED_PROP_EVER`] +/// is armed by every install, builtin or not, for the dispatcher. +/// /// A `u32` rather than a bool so the emitted load matches the barrier gate's /// alignment and width; only zero / non-zero is meaningful. #[no_mangle] pub static PERRY_OWN_NAMED_PROP_INSTALLED: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); -/// Armed from the top of `field_set_by_name`'s exotic-store gauntlet. +/// Has any non-`ObjectHeader` cell ever taken a named property at all, +/// including the runtime's own builtin definitions? Set-only, like +/// [`PERRY_OWN_NAMED_PROP_INSTALLED`], and armed wherever that flag was armed +/// before #10697, so [`own_user_method_value`] answers exactly as it did. +static OWN_NAMED_PROP_EVER: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +crate::perry_thread_local! { + /// Set while the runtime runs its own builtin definitions (#10697). A + /// flag, never an address. + static BUILTIN_INTRINSIC_INSTALL: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +#[cfg(test)] +crate::perry_thread_local! { + /// Test-only: installs on this thread that armed the guard's flag. The + /// flag itself is process-global and set-only, so another test has + /// usually armed it already and it cannot be observed changing (#10697). + pub(crate) static ARMS_NOTED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Run `install`, one of the runtime's own builtin definitions, so that the +/// named-property installs it makes arm [`PERRY_OWN_NAMED_PROP_INSTALLED`] +/// only when their owner is a Map, Set or Date cell (#10697). +/// +/// Populating `globalThis` installs statics on constructor intrinsics such as +/// `%TypedArray%` (closure cells), `constructor` on `Array.prototype` (an +/// array cell), and aliases such as `Number.parseFloat`. Each of those stores +/// passes the exotic gauntlet that arms the flag. One lazy population anywhere +/// in a program then sent every guarded `Map`/`Set`/`Date` builtin call +/// through the authoritative `hasOwn` predicate: about 600 instructions on +/// each `m.get(k)`, 4.2x node in #10697's count-by-category shape. +/// +/// The flag answers for the emitted guard and its predicate only, and those +/// protect proven Map, Set, Array and Date receivers. An array answers from +/// its own header and named-property storage, never from this flag, and a +/// declared-`Map` receiver of any other kind is brand-checked into generic +/// dispatch. So only an install onto a Map, Set or Date cell can matter to +/// it, and [`note_exotic_named_prop_install`] still arms for those, and for an +/// owner whose header cannot be read, inside this scope too. A builtin +/// definition installs the builtin itself in any case, never a user override +/// of it. [`OWN_NAMED_PROP_EVER`] is armed exactly as before. +/// +/// Thread-local rather than save-and-restore of the global flag, so an arm by +/// another thread in the same window is never undone. Nests. +pub(crate) fn as_builtin_definition(install: impl FnOnce() -> R) -> R { + let outer = BUILTIN_INTRINSIC_INSTALL.with(|flag| flag.replace(true)); + // Restored on unwind too: a leaked `true` would stop user installs from + // arming, which is the silent wrong value the flag exists to prevent. + struct Restore(bool); + impl Drop for Restore { + fn drop(&mut self) { + BUILTIN_INTRINSIC_INSTALL.with(|flag| flag.set(self.0)); + } + } + let _restore = Restore(outer); + install() +} + +/// Can a named property on `owner` shadow a builtin that the emitted guard +/// answers from [`PERRY_OWN_NAMED_PROP_INSTALLED`]? `owner` is an address or +/// NaN-boxed pointer bits. Unreadable owners answer yes. +fn owner_is_flag_guarded(owner: usize) -> bool { + let bits = owner as u64; + let addr = if bits >> 48 == 0x7FFD { + (bits & crate::value::POINTER_MASK) as usize + } else { + owner + }; + // SAFETY: `try_read_gc_header` validates the address before reading. + match unsafe { crate::value::addr_class::try_read_gc_header(addr) } { + Some(header) => matches!( + header.obj_type, + crate::gc::GC_TYPE_MAP | crate::gc::GC_TYPE_SET | crate::gc::GC_TYPE_DATE_CELL + ), + None => true, + } +} + +/// Armed where a non-ordinary `owner` takes a named property: the top of +/// `field_set_by_name`'s exotic-store gauntlet and the exotic expando store. #[inline] -pub(crate) fn note_exotic_named_prop_install() { +pub(crate) fn note_exotic_named_prop_install(owner: usize) { + if !OWN_NAMED_PROP_EVER.load(Ordering::Relaxed) { + OWN_NAMED_PROP_EVER.store(true, Ordering::Relaxed); + } + if BUILTIN_INTRINSIC_INSTALL.with(std::cell::Cell::get) && !owner_is_flag_guarded(owner) { + return; + } + #[cfg(test)] + ARMS_NOTED.with(|n| n.set(n.get() + 1)); // Relaxed is enough: a stale `false` can only be read by a thread that has // not yet observed the store, and that thread's own receivers cannot be // the one just written (the write happens-before any publication of the @@ -258,9 +351,11 @@ unsafe fn authoritative_has_own_key(recv: f64, key: *mut crate::StringHeader) -> /// # Safety /// `recv` is any NaN-boxed value; `name` is this call's method name. pub(crate) unsafe fn own_user_method_value(recv: f64, name: &str) -> Option { - // The same relaxed arm the emitted guard consults: nothing anywhere has - // ever put a named property on a non-object cell, so nothing can shadow. - if PERRY_OWN_NAMED_PROP_INSTALLED.load(Ordering::Relaxed) == 0 { + // Nothing anywhere has ever put a named property on a non-object cell, so + // nothing can shadow. The flag the runtime's own builtin installs also arm + // (#10697), so this reads exactly as it did before they stopped arming the + // emitted guard's. + if !OWN_NAMED_PROP_EVER.load(Ordering::Relaxed) { return None; } resolve_own_user_method(recv, name) diff --git a/crates/perry-runtime/src/object/own_override_builtin_install_tests.rs b/crates/perry-runtime/src/object/own_override_builtin_install_tests.rs new file mode 100644 index 0000000000..3a38f7da8a --- /dev/null +++ b/crates/perry-runtime/src/object/own_override_builtin_install_tests.rs @@ -0,0 +1,78 @@ +//! #10697: the runtime's own builtin installs do not arm the own-override +//! guard unless their owner is a Map, Set or Date cell; user installs do. + +use super::own_override::ARMS_NOTED; +use crate::object::descriptor_state::{define_builtin_data_property, PropertyAttrs}; + +extern "C" fn intrinsic_fixture(_closure: *const crate::closure::ClosureHeader) -> f64 { + 0.0 +} + +fn key(name: &str) -> *const crate::StringHeader { + crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32) +} + +fn arms() -> usize { + ARMS_NOTED.with(std::cell::Cell::get) +} + +/// Sabotage: installing through `js_object_set_field_by_name` directly, as +/// `define_builtin_data_property` did, makes `arms` read 1. +#[test] +fn a_builtin_install_on_an_intrinsic_does_not_arm_the_guard() { + let _lock = crate::gc::global_side_table_test_lock(); + let ctor = crate::closure::js_closure_alloc(intrinsic_fixture as *const u8, 0); + let before = arms(); + define_builtin_data_property( + ctor as *mut crate::object::ObjectHeader, + key("from"), + 1.0, + "from".to_string(), + PropertyAttrs::new(true, false, true), + ); + assert_eq!( + arms() - before, + 0, + "a builtin static on a constructor must not arm" + ); + // `Array.prototype` is an array cell and takes `constructor` this way. + let proto = crate::array::js_array_alloc(0); + let before = arms(); + define_builtin_data_property( + proto as *mut crate::object::ObjectHeader, + key("constructor"), + 1.0, + "constructor".to_string(), + PropertyAttrs::new(true, false, true), + ); + assert_eq!( + arms() - before, + 0, + "`constructor` on an array prototype must not arm" + ); + // A Map owner is a kind the guard answers from the flag: still armed. + let map = crate::map::js_map_alloc(0); + let before = arms(); + define_builtin_data_property( + map as *mut crate::object::ObjectHeader, + key("size2"), + 1.0, + "size2".to_string(), + PropertyAttrs::new(true, false, true), + ); + assert!( + arms() > before, + "a builtin install onto a Map must still arm" + ); + // The scope must not outlive the install. + let before = arms(); + crate::object::js_object_set_field_by_name( + map as *mut crate::object::ObjectHeader, + key("get"), + 1.0, + ); + assert!( + arms() > before, + "an own `get` on a Map must still arm the guard" + ); +} diff --git a/crates/perry-runtime/src/regex/perex_memory.rs b/crates/perry-runtime/src/regex/perex_memory.rs index 5a72bbedcc..b49dc9ca74 100644 --- a/crates/perry-runtime/src/regex/perex_memory.rs +++ b/crates/perry-runtime/src/regex/perex_memory.rs @@ -1,5 +1,5 @@ //! Operation-owned native scratch, charged to Perry's external-byte budget. -//! No GC pointer may be stored in these buffers. Allocation/accounting can +//! No GC pointer may be stored in these buffers. `Reservation` accounting can //! collect, so callers must release all program/subject views first. use std::cell::Cell; @@ -97,9 +97,10 @@ impl Drop for Reservation<'_> { } } -/// Stable initialized native allocation. Its accounting owner is established -/// before notifying the collector, so a collecting/unwinding notification -/// cannot strand a buffer or leave its bytes charged. +/// Stable initialized native allocation, freed by the operation that made it. +/// Its bytes are live external bytes while it exists, but they are transient: +/// they neither step the allocation-churn trigger nor count as released +/// pressure when dropped (#11549), and noting them never collects. pub(crate) struct Buffer<'a, T: Copy + Default> { data: Vec, budget: &'a MemoryBudget, @@ -128,10 +129,9 @@ impl<'a, T: Copy + Default> Buffer<'a, T> { }; budget.live.set(live); budget.peak.set(budget.peak.get().max(live)); - if bytes != 0 { - crate::exception::catch_js_throw(|| crate::gc::gc_note_external_side_alloc(bytes)) - .map_err(|value| StorageError::Abrupt(value.to_bits()))?; - } + // Transient (#11549): the operation frees this before it returns and + // the collector can never reclaim it, so it must not pace collections. + crate::gc::gc_note_external_transient_alloc(bytes); Ok(owned) } } @@ -152,8 +152,6 @@ impl DerefMut for Buffer<'_, T> { impl Drop for Buffer<'_, T> { fn drop(&mut self) { self.budget.live.set(self.budget.live.get() - self.bytes); - if self.bytes != 0 { - crate::gc::gc_note_external_side_free(self.bytes); - } + crate::gc::gc_note_external_transient_free(self.bytes); } } diff --git a/crates/perry-runtime/src/regex/perex_runtime.rs b/crates/perry-runtime/src/regex/perex_runtime.rs index 9cafb56082..f26920a16f 100644 --- a/crates/perry-runtime/src/regex/perex_runtime.rs +++ b/crates/perry-runtime/src/regex/perex_runtime.rs @@ -201,10 +201,14 @@ impl std::ops::DerefMut for Slots<'_, T, N> { /// fewer: `/^[a-z]+_[0-9]+$/` needs 2. Frames and undo entries start empty and /// only grow through `rebuffer`, so they are never inline. const INLINE_REGISTERS: usize = 8; -/// Registers the lent cell holds. This array is allocated once per thread, not -/// per call, so it is sized for the programs a search may bring rather than -/// for what is cheap to move. -const LENT_REGISTERS: usize = 32; +/// Most registers the lent cell grows to. The cell is allocated once per +/// thread, not per call, and keeps the size of the largest program it has +/// served, so this caps that memory at 32 KiB per thread. +/// +/// It was a fixed 32 (#11549). Every search of a larger program, such as +/// dotenv's 42-register line pattern, then took the owned path and built and +/// freed its buffers on every call. Real patterns stay well under this cap. +const LENT_REGISTERS_MAX: usize = 4096; /// Capture spans an `exec` result can have and still be read without /// allocating. const INLINE_CAPTURES: usize = 16; @@ -253,7 +257,7 @@ impl ScratchOwner for MatchBuffers<'_> { /// frames and undo entries are the engine's own opaque scratch, exactly as in /// the owned buffers this replaces (see this module's header). struct ScratchCell { - registers: [usize; LENT_REGISTERS], + registers: Vec, frames: Vec, undo: Vec, } @@ -270,7 +274,7 @@ crate::perry_thread_local! { /// costing a `_tlv_get_addr` call — the opposite of what this change is for. static LENT_SCRATCH: std::cell::RefCell = const { std::cell::RefCell::new(ScratchCell { - registers: [0; LENT_REGISTERS], + registers: Vec::new(), frames: Vec::new(), undo: Vec::new(), }) @@ -306,6 +310,14 @@ crate::perry_thread_local! { const { std::cell::Cell::new(0) }; } +#[cfg(test)] +crate::perry_thread_local! { + /// Test-only: how many searches built their own `MatchBuffers` instead of + /// borrowing the lent cell, so a test can assert which path ran (#11549). + pub(crate) static OWNED_SEARCHES_RUN: std::cell::Cell = + const { std::cell::Cell::new(0) }; +} + /// Run the pre-search poll on one call in `PRE_SEARCH_POLL_STRIDE`. #[inline] fn poll_on_stride(poll: &mut impl FnMut() -> Result<(), EngineError>) -> Result<(), EngineError> { @@ -398,6 +410,12 @@ fn find_near_lent<'mem, S: ImmutableSubject>( return Ok(Lent::Fallback); }; let cell = &mut *cell; + if cell.registers.len() < registers { + // Grows a handful of times per thread, then never again. The + // cell's memory is the thread's, like `frames` and `undo`, so it + // is not noted to the collector (#11549). + cell.registers.resize(registers.max(32), 0); + } // Charged exactly like the owner it replaces: the operation's limit // sees the slots a search may use, whether or not they were allocated // for it. The thread keeps the memory; the operation only borrows it. @@ -542,7 +560,7 @@ pub(crate) fn find_near<'mem, S: ImmutableSubject>( // Lend the thread's scratch first: a search that fits it constructs and // moves nothing (#10166). Anything the cell cannot serve falls through to // the owned buffers below with the budget it entered on. - if registers <= LENT_REGISTERS { + if registers <= LENT_REGISTERS_MAX { let entry = *budget; match find_near_lent( &resources, registers, start, near, mode, budget, memory, quantum, poll, @@ -552,6 +570,8 @@ pub(crate) fn find_near<'mem, S: ImmutableSubject>( } } + #[cfg(test)] + OWNED_SEARCHES_RUN.with(|n| n.set(n.get() + 1)); poll()?; let buffers = MatchBuffers::new(memory, size)?; // On an error that is not a capacity request -- WorkLimit, diff --git a/crates/perry-stdlib/src/crypto/random.rs b/crates/perry-stdlib/src/crypto/random.rs index 4dfbc60836..f7b7f776b3 100644 --- a/crates/perry-stdlib/src/crypto/random.rs +++ b/crates/perry-stdlib/src/crypto/random.rs @@ -134,8 +134,10 @@ pub unsafe extern "C" fn js_crypto_random_uuid(options_bits: f64) -> *mut String } else { perry_uuid::v4() }; - let uuid_str = uuid.as_str(); - js_string_from_bytes(uuid_str.as_ptr(), uuid_str.len() as u32) + // The bytes directly: `as_str` would re-validate 36 known-ASCII bytes as + // UTF-8 on every UUID only for the string constructor to copy them. + let uuid_bytes = uuid.as_bytes(); + js_string_from_bytes(uuid_bytes.as_ptr(), uuid_bytes.len() as u32) } /// Generate an RFC 9562 version 7 UUID — a 48-bit millisecond Unix @@ -147,8 +149,8 @@ pub unsafe extern "C" fn js_crypto_random_uuid(options_bits: f64) -> *mut String #[no_mangle] pub extern "C" fn js_crypto_random_uuidv7() -> *mut StringHeader { let uuid = perry_uuid::v7(); - let uuid_str = uuid.as_str(); - js_string_from_bytes(uuid_str.as_ptr(), uuid_str.len() as u32) + let uuid_bytes = uuid.as_bytes(); + js_string_from_bytes(uuid_bytes.as_ptr(), uuid_bytes.len() as u32) } /// Validates `randomUUID`'s options bag and returns `disableEntropyCache`. diff --git a/crates/perry-uuid/src/lib.rs b/crates/perry-uuid/src/lib.rs index 7d2e639c00..2d7a06bbaa 100644 --- a/crates/perry-uuid/src/lib.rs +++ b/crates/perry-uuid/src/lib.rs @@ -66,15 +66,15 @@ pub struct Hyphenated([u8; 36]); impl Hyphenated { fn new(bytes: [u8; 16]) -> Self { const HEX: &[u8; 16] = b"0123456789abcdef"; + // Where each byte's two digits start, hyphens skipped. A constant + // table instead of a running index with a per-byte hyphen test: the + // loop unrolls to straight-line stores with no bounds checks, which was + // a third of `randomUUID()`'s instructions (#10523). + const AT: [usize; 16] = [0, 2, 4, 6, 9, 11, 14, 16, 19, 21, 24, 26, 28, 30, 32, 34]; let mut out = [b'-'; 36]; - let mut at = 0; - for (i, b) in bytes.into_iter().enumerate() { - if matches!(i, 4 | 6 | 8 | 10) { - at += 1; - } - out[at] = HEX[(b >> 4) as usize]; - out[at + 1] = HEX[(b & 15) as usize]; - at += 2; + for i in 0..16 { + out[AT[i]] = HEX[(bytes[i] >> 4) as usize]; + out[AT[i] + 1] = HEX[(bytes[i] & 15) as usize]; } Hyphenated(out) } @@ -82,6 +82,11 @@ impl Hyphenated { // Only ASCII hex digits and hyphens are ever written. std::str::from_utf8(&self.0).unwrap() } + /// The 36 ASCII bytes, without `as_str`'s UTF-8 validation, for a caller + /// that copies them into a string of its own. + pub fn as_bytes(&self) -> &[u8; 36] { + &self.0 + } } #[cfg(any(feature = "v4", feature = "v7"))] impl std::fmt::Display for Hyphenated { @@ -173,6 +178,19 @@ mod tests { } } } + // #10523: the table-driven formatter writes every byte's digits in RFC + // 9562 order around the four hyphens; `as_bytes` is `as_str`'s bytes. + #[cfg(any(feature = "v4", feature = "v7"))] + #[test] + fn hyphenated_layout_is_exact() { + let bytes: [u8; 16] = std::array::from_fn(|i| (i as u8) * 17); + let h = super::Hyphenated::new(bytes); + assert_eq!(h.as_str(), "00112233-4455-6677-8899-aabbccddeeff"); + assert_eq!(h.as_bytes(), h.as_str().as_bytes()); + let h = + super::Hyphenated::new([0xf0, 0x0f, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 0xff]); + assert_eq!(h.as_str(), "f00f0102-0304-0506-0708-090a0b0c0dff"); + } #[cfg(feature = "v4")] #[test] fn v4_layout_and_fresh_entropy() { diff --git a/docs/src/internals/garbage-collector.md b/docs/src/internals/garbage-collector.md index 69006a2d45..e2c3735ec6 100644 --- a/docs/src/internals/garbage-collector.md +++ b/docs/src/internals/garbage-collector.md @@ -136,7 +136,13 @@ direct minor. `PERRY_GC_SCAVENGE_NURSERY_MB` tunes its base high-water cap, tenuring feedback may grow the effective cap by up to 4× on live-set-bound workloads, where a fixed cap would multiply the per-collection -fixed cost by an enormous collection count. Generated write barriers are also on +fixed cost by an enormous collection count. In the other direction, the cap +powers on at a quarter of the base, a right shift of 2 +, +and returns there while survivor influx stays under 1% of it. A copying minor +costs O(survivors), so a small Eden is cheap where little survives, and peak +resident memory is set by the first nurseries. Influx above 4% walks +the cap back up to the base within four minors. Generated write barriers are also on by default. Turning them off makes generational minors unsound, so the runtime deliberately falls back to full mark-sweep. diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 04faee59c5..80375e0e2c 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -58,12 +58,36 @@ "scanner": "object::scan_object_cache_roots_mut -> closure::shape::scan_function_prototype_roots_mut", "why": "The per-agent Function.prototype OBJECT (the prototype the base Function ShapeId names), read by the shape-proven fn.bind/call/apply path. Visited by `closure::shape::scan_function_prototype_roots_mut` from the registered `object::scan_object_cache_roots_mut`, which keeps it alive and rewrites the slot when it moves. Uncovered here only because that registered scanner is in another file (same pattern as NULL_STUB_SLOT)." }, + { + "file": "crates/perry-runtime/src/gc/tenuring.rs", + "name": "NURSERY_CAP_SHRINK_SHIFT", + "verdict": "not_a_gc_pointer", + "why": "TriggerInput right shift applied to the scavenge nursery cap below its base, 0..=2 (#11549). A small integer policy dial like its sibling NURSERY_CAP_SCALE, never an address." + }, + { + "file": "crates/perry-runtime/src/map.rs", + "name": "COLD_LOOKUPS", + "verdict": "test_only", + "why": "#[cfg(test)] Cell counting Map lookups the hot lane handed to find_key_index_cold, so a test can assert which lane answered (#10697). A count, never a pointer; absent from shipped binaries." + }, { "file": "crates/perry-runtime/src/node_submodules/diagnostics.rs", "name": "CONSOLE_CHANNEL_IDS", "verdict": "not_a_gc_pointer", "why": "Per-thread cache of five diagnostics_channel ids (i64 keys into the thread-local DIAG_CHANNELS table, minted by NEXT_DIAG_ID) for console.log/info/debug/error/warn; integers, never a NaN-boxed value. #11471 made these per-thread." }, + { + "file": "crates/perry-runtime/src/object/own_override.rs", + "name": "ARMS_NOTED", + "verdict": "test_only", + "why": "#[cfg(test)] Cell counting installs that armed the own-override guard flag on this thread (#10697), because the process-global flag itself is set-only and usually already armed by another test. A count, never a pointer; absent from shipped binaries." + }, + { + "file": "crates/perry-runtime/src/object/own_override.rs", + "name": "BUILTIN_INTRINSIC_INSTALL", + "verdict": "not_a_gc_pointer", + "why": "Cell set while the runtime runs its own builtin definitions (as_builtin_definition, #10697), so installs onto non-Map/Set/Date owners do not arm PERRY_OWN_NAMED_PROP_INSTALLED. A flag, never an address." + }, { "file": "crates/perry-runtime/src/value/nanbox.rs", "name": "FFI_SSO_RING", @@ -386,7 +410,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 #11549: `gc/policy.rs` gains `gc_note_external_transient_alloc`/`_free`, which only add to and subtract from the thread-local live external-byte counter and never collect, plus comment edits; no mark/sweep control flow changes and nothing runs inside the mark-complete to sweep-entry window.", "window": { "start": { "file": "crates/perry-runtime/src/gc/census.rs", @@ -404,7 +428,7 @@ "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/policy.rs": "7e2304ec822d26877df4eaf7aefb55c485c66425b8946b56a9b271a0abcc635b", + "crates/perry-runtime/src/gc/policy.rs": "d137fdfcc17f07396802c67cfacfc6fc4ee8b7180380ca609d740f2b77fc230f", "crates/perry-runtime/src/gc/progress.rs": "a5ad3971bbe4047229ca57325234780daa85921dbc778e1c08dff4ad07ccfb96" } } diff --git a/test-files/test_gap_10697_own_override_after_global_population.ts b/test-files/test_gap_10697_own_override_after_global_population.ts new file mode 100644 index 0000000000..45977cccd7 --- /dev/null +++ b/test-files/test_gap_10697_own_override_after_global_population.ts @@ -0,0 +1,40 @@ +// #10697: populating globalThis installs the runtime's builtins onto its +// intrinsics. Those installs no longer arm the own-override guard that proven +// Map/Set/Date/Array builtin calls consult, so a program that touches a lazy +// global keeps the direct builtin call. A user's own override must still win +// afterwards, and the populated builtins must still be callable. + +// Force the lazy population first (an intrinsic, an alias, a global function). +const g: any = globalThis; +console.log(typeof g.Uint8Array, typeof g.unescape, Number.parseFloat === g.parseFloat); +console.log(Uint8Array.from([1, 2, 3]).join(","), Number.parseFloat("2.5"), g.unescape("%41")); +console.log(Array.prototype.constructor === Array, [].constructor === Array); + +// Plain builtin calls on proven receivers, after population. +const m = new Map(); +const cats = ["alpha", "beta", "gamma", "delta"]; +for (let i = 0; i < 40; i++) m.set(cats[i & 3], (m.get(cats[i & 3]) || 0) + 1); +console.log([...m].join(";"), m.has("beta"), m.get("delta")); +const s = new Set([1, 2, 3]); +console.log(s.has(2), s.size); +const d = new Date(0); +console.log(d.getTime(), d.toISOString()); +const a = [3, 1, 2]; +console.log(a.indexOf(1), a.includes(3), a.slice(1).join(",")); + +// Own overrides installed AFTER population must still beat the builtin. +const m2 = new Map([["k", 1]]); +(m2 as any).get = (k: string) => "own-get:" + k; +console.log(m2.get("k")); +const s2 = new Set([1]); +(s2 as any).has = (v: number) => "own-has:" + v; +console.log(s2.has(1)); +const d2 = new Date(0); +(d2 as any).getTime = () => "own-getTime"; +console.log(d2.getTime()); +const a2 = [1, 2, 3]; +(a2 as any).indexOf = (v: number) => "own-indexOf:" + v; +console.log(a2.indexOf(2)); + +// And a Map created before the override still uses the builtin. +console.log(m.get("alpha"), m.get("missing")); diff --git a/test-files/test_gap_10762_inline_number_to_string.ts b/test-files/test_gap_10762_inline_number_to_string.ts new file mode 100644 index 0000000000..e9fdf058e1 --- /dev/null +++ b/test-files/test_gap_10762_inline_number_to_string.ts @@ -0,0 +1,75 @@ +// #10762: `String(n)`, `${n}`, `n.toString()` and `"" + n` build a number +// operand's small-integer text inline at the call site, and `s.charCodeAt(i)` +// reads an ASCII short string's byte straight out of the value. Both have a +// runtime fallback; this pins the boundary between the two arms and the +// fallbacks themselves. + +function spell(n: number): string { + const a = String(n); + const b = `${n}`; + const c = n.toString(); + const d = "" + n; + const agree = a === b && b === c && c === d; + return agree ? a + "|" + a.length : "DISAGREE " + [a, b, c, d].join(","); +} + +// Around every edge of the inline range (-9999..=99999) and each digit count. +const edges: number[] = [ + 0, -0, 1, -1, 9, 10, -9, -10, 99, 100, -99, -100, 999, 1000, -999, -1000, + 9999, 10000, -9999, -10000, 99999, 100000, -99999, 99998.5, -9998.5, + 0.5, -0.5, 1e-7, 1e21, NaN, Infinity, -Infinity, 2147483648, -2147483649, +]; +for (const n of edges) console.log(n, spell(n)); + +// Every integer the inline arm covers, checked against a digit-by-digit +// reference; only a count and the first mismatch are printed. +function reference(n: number): string { + if (n === 0) return "0"; + let rest = Math.abs(n); + let out = ""; + while (rest > 0) { + out = String.fromCharCode(48 + (rest % 10)) + out; + rest = Math.floor(rest / 10); + } + return n < 0 ? "-" + out : out; +} +let checked = 0; +let firstBad = ""; +for (let n = -9999; n <= 99999; n++) { + const got = String(n); + if (got !== reference(n) || `${n}` !== got || n.toString() !== got || "" + n !== got) { + if (firstBad === "") firstBad = n + " -> " + got; + } + checked++; +} +console.log("checked", checked, firstBad === "" ? "all match" : "first mismatch " + firstBad); + +// A `number` annotation is not enforced at run time: the inline arm must +// decline anything that is not a plain double and take the full conversion. +const liars: any[] = ["abc", "12", true, null, undefined, 12n, [1, 2], { a: 1 }]; +for (const v of liars) { + const n: number = v; + console.log(String(n), `${n}`, "" + n); +} +// (`"" + valueOfOnly` is left out: it prints "seven" instead of "7" on main +// as well, a separate ToPrimitive-hint bug in the declared-number concat.) +const valueOfOnly: number = { valueOf: () => 7, toString: () => "seven" } as any; +console.log(String(valueOfOnly), `${valueOfOnly}`); + +// Integer-valued results from arithmetic, loop counters and Int32 locals. +let acc = 0; +for (let i = -3; i < 4; i++) acc += String(i * 7).length + `${i | 0}`.length; +console.log(acc, String(2 ** 16), `${(3 * 33333) | 0}`, (65535 & 0xffff).toString()); + +// charCodeAt on short strings: numbers, ASCII words, non-ASCII, bad indexes. +const shorts = [String(42), `${-7}`, "" + 12345, "abcde", "é", "aé", "€", ""]; +for (const s of shorts) { + const codes: (number | string)[] = []; + for (let i = -1; i <= s.length; i++) { + const c = s.charCodeAt(i); + codes.push(Number.isNaN(c) ? "NaN" : c); + } + console.log(JSON.stringify(s), codes.join(","), s.charCodeAt(0.9), s.charCodeAt(1.5)); +} +const idxLiar: number = "1" as any; +console.log(String(123).charCodeAt(idxLiar), String(123).charCodeAt(NaN)); diff --git a/test-files/test_gap_11549_regex_large_register_scratch.ts b/test-files/test_gap_11549_regex_large_register_scratch.ts new file mode 100644 index 0000000000..0fd2ed6ac7 --- /dev/null +++ b/test-files/test_gap_11549_regex_large_register_scratch.ts @@ -0,0 +1,55 @@ +// #11549: a pattern with more than 32 match registers borrows the thread's +// lent scratch instead of building and freeing owned buffers per call, and +// regex scratch no longer counts as released external GC pressure. Results +// must be unchanged, including for nested searches (a replacer callback that +// runs another large pattern while the outer search holds the lent cell) and +// across programs of different sizes on one thread. + +// dotenv's line pattern: 42 registers. +const LINE = /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/mg; + +let doc = "# generated\nexport NODE_ENV=production\nPORT=8080\n"; +doc += 'MULTI="line one\nline two"\n'; +doc += "SINGLE='single # not a comment'\n"; +doc += "EMPTY=\n SPACED = spaced value \n"; +for (let k = 0; k < 6; k++) doc += "KEY_" + k + "=value_" + k + " # trailing " + k + "\n"; + +function parse(src: string): Record { + const out: Record = {}; + LINE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = LINE.exec(src)) != null) out[m[1]] = (m[2] || "").trim(); + return out; +} + +// Many iterations with churn in between, so collections run mid-loop. +let sig = 0; +let last = ""; +for (let i = 0; i < 3000; i++) { + const parsed = parse(doc); + const junk: string[] = []; + for (let j = 0; j < 20; j++) junk.push("x" + i + "_" + j); + sig = (sig + Object.keys(parsed).length + junk.length) % 1000003; + last = JSON.stringify(parsed); +} +console.log(sig, last); + +// A wide alternation: more registers than the old 32-slot cell. +const WIDE = new RegExp(Array.from({ length: 40 }, (_, i) => "(w" + i + ")").join("|")); +console.log(["w0", "w17", "w39", "w40"].map((s) => (WIDE.exec(s) || []).filter((x) => x).join("/"))); + +// Nested: the outer search holds the lent cell while the callback searches. +const nested = "a=1\nb=2\nc=3\n".replace(/^(\w)=(\d)$/gm, (_all, k, v) => { + const inner = parse(k.toUpperCase() + "_X=" + v + " # c\n"); + return k + "->" + JSON.stringify(inner); +}); +console.log(nested); + +// Alternate small and large programs on the same thread. +const SMALL = /(\d+)-(\d+)/; +const out: string[] = []; +for (let i = 0; i < 5; i++) { + out.push((SMALL.exec("x" + i + "-" + (i * 3) + "y") || []).slice(1).join(":")); + out.push(String(Object.keys(parse("K" + i + "=v" + i + "\n")).length)); +} +console.log(out.join(",")); From 5827d44e3f63955e0d9fa55a8abfb980a305820b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 16:08:07 +0000 Subject: [PATCH 2/4] changelog: key the four fragments to #11643 --- ...nline-number-to-string.md => 11643-inline-number-to-string.md} | 0 ...nstalls.md => 11643-map-own-override-flag-builtin-installs.md} | 0 .../{PRNUM-random-uuid-format.md => 11643-random-uuid-format.md} | 0 ...-scratch-gc-pressure.md => 11643-regex-scratch-gc-pressure.md} | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{PRNUM-inline-number-to-string.md => 11643-inline-number-to-string.md} (100%) rename changelog.d/{PRNUM-map-own-override-flag-builtin-installs.md => 11643-map-own-override-flag-builtin-installs.md} (100%) rename changelog.d/{PRNUM-random-uuid-format.md => 11643-random-uuid-format.md} (100%) rename changelog.d/{PRNUM-regex-scratch-gc-pressure.md => 11643-regex-scratch-gc-pressure.md} (100%) diff --git a/changelog.d/PRNUM-inline-number-to-string.md b/changelog.d/11643-inline-number-to-string.md similarity index 100% rename from changelog.d/PRNUM-inline-number-to-string.md rename to changelog.d/11643-inline-number-to-string.md diff --git a/changelog.d/PRNUM-map-own-override-flag-builtin-installs.md b/changelog.d/11643-map-own-override-flag-builtin-installs.md similarity index 100% rename from changelog.d/PRNUM-map-own-override-flag-builtin-installs.md rename to changelog.d/11643-map-own-override-flag-builtin-installs.md diff --git a/changelog.d/PRNUM-random-uuid-format.md b/changelog.d/11643-random-uuid-format.md similarity index 100% rename from changelog.d/PRNUM-random-uuid-format.md rename to changelog.d/11643-random-uuid-format.md diff --git a/changelog.d/PRNUM-regex-scratch-gc-pressure.md b/changelog.d/11643-regex-scratch-gc-pressure.md similarity index 100% rename from changelog.d/PRNUM-regex-scratch-gc-pressure.md rename to changelog.d/11643-regex-scratch-gc-pressure.md From 280ced30b2ef10a65bf706cff58d4e42d16c3076 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 16:36:37 +0000 Subject: [PATCH 3/4] gc: gate the transient external-byte notes on regex-engine, their one user Without the feature the regex module is compiled out and both functions were dead code, which the -D warnings workspace check rejects. Re-pins PASS1_MARKED's gc/policy.rs source hash. --- crates/perry-runtime/src/gc/policy.rs | 2 ++ scripts/gc_runtime_root_holders.json | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index b6b95cbffe..efb700c507 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -798,11 +798,13 @@ pub(crate) fn gc_note_external_side_free(bytes: usize) { /// alone drove the loop into repeated full collections. /// /// Never collects, so callers may hold raw heap views across it. +#[cfg(feature = "regex-engine")] pub(crate) fn gc_note_external_transient_alloc(bytes: usize) { GC_EXTERNAL_SIDE_LIVE_BYTES.with(|c| c.set(c.get().saturating_add(bytes))); } /// Release bytes recorded by [`gc_note_external_transient_alloc`]. +#[cfg(feature = "regex-engine")] pub(crate) fn gc_note_external_transient_free(bytes: usize) { GC_EXTERNAL_SIDE_LIVE_BYTES.with(|c| c.set(c.get().saturating_sub(bytes))); } diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 80375e0e2c..a9f8e57457 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -410,7 +410,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 #11549: `gc/policy.rs` gains `gc_note_external_transient_alloc`/`_free`, which only add to and subtract from the thread-local live external-byte counter and never collect, plus comment edits; no mark/sweep control flow changes and nothing 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 #11549: `gc/policy.rs` gains `gc_note_external_transient_alloc`/`_free`, which only add to and subtract from the thread-local live external-byte counter and never collect (compiled only with `regex-engine`, their one user), plus comment edits; no mark/sweep control flow changes and nothing runs inside the mark-complete to sweep-entry window.", "window": { "start": { "file": "crates/perry-runtime/src/gc/census.rs", @@ -428,7 +428,7 @@ "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/policy.rs": "d137fdfcc17f07396802c67cfacfc6fc4ee8b7180380ca609d740f2b77fc230f", + "crates/perry-runtime/src/gc/policy.rs": "2301d1b473aa26362d6314026c60243441cbefb9d49ac2fef7537475e5e52333", "crates/perry-runtime/src/gc/progress.rs": "a5ad3971bbe4047229ca57325234780daa85921dbc778e1c08dff4ad07ccfb96" } } From 7ab09654f8d5652dc0f15f1b69e8a37a0bbe5a4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 2 Oct 2026 05:15:33 +0200 Subject: [PATCH 4/4] Separate held regex accounting and nursery pacing from #11643 --- .../11643-regex-scratch-gc-pressure.md | 73 ------- crates/perry-runtime/src/gc/policy.rs | 25 +-- crates/perry-runtime/src/gc/tenuring.rs | 180 +++--------------- .../src/gc/tests/copying/adaptive_tenuring.rs | 23 +-- .../tests/runtime_roots/perex_construction.rs | 31 --- .../gc/tests/runtime_roots/perex_execution.rs | 54 ------ crates/perry-runtime/src/gc/tests/triggers.rs | 5 +- .../perry-runtime/src/regex/perex_memory.rs | 20 +- .../perry-runtime/src/regex/perex_runtime.rs | 34 +--- docs/src/internals/garbage-collector.md | 8 +- scripts/gc_runtime_root_holders.json | 10 +- ..._gap_11549_regex_large_register_scratch.ts | 55 ------ 12 files changed, 53 insertions(+), 465 deletions(-) delete mode 100644 changelog.d/11643-regex-scratch-gc-pressure.md delete mode 100644 test-files/test_gap_11549_regex_large_register_scratch.ts diff --git a/changelog.d/11643-regex-scratch-gc-pressure.md b/changelog.d/11643-regex-scratch-gc-pressure.md deleted file mode 100644 index f45985d31f..0000000000 --- a/changelog.d/11643-regex-scratch-gc-pressure.md +++ /dev/null @@ -1,73 +0,0 @@ -perf(regex,gc): regex match scratch no longer drives full collections, and the nursery powers on small (#11549). - -A pattern with more than 32 match registers, such as dotenv's 42-register line -pattern, could not use the thread's lent scratch cell, which held a fixed 32 -registers. Every search of it built owned `MatchBuffers` and freed them again, -and each `perex_memory::Buffer` reported its bytes through -`gc_note_external_side_alloc` / `gc_note_external_side_free`. The free side -feeds `GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL`, the released-bytes term of -old-reclaim pressure, so a hot loop of such searches accumulated phantom -pressure that matched no garbage on the heap and kept scheduling full -collections. - -- The lent cell's registers are a growable per-thread `Vec`, sized to the - largest program the thread has searched (capped at 4096 registers, 32 KiB). - A search that fits no longer constructs or frees anything. Like the cell's - frames and undo entries, this memory is the thread's and is not reported to - the collector. -- `perex_memory::Buffer`, the operation-scoped compile and match scratch that - the owned path still uses (nested searches, programs over the cap), is - accounted as transient: new `gc_note_external_transient_alloc` / `_free` - move only the live external-byte term. The bytes are still visible to - pressure while they exist, but they no longer step the allocation-churn - trigger or count as released pressure, and noting them never collects. - `Reservation` and the replacement span/piece vectors, whose size follows the - subject, keep the ordinary external accounting. - -That alone was measured and not shipped before, because once the phantom -fulls stop, the young generation runs to its 16 MB cap before collecting and -peak RSS rose (+23% dotenv, +21% moment here). The second half keeps RSS flat: - -- The scavenge nursery cap now **powers on at a quarter of the base** (4 MB at - the default 16 MB, `NURSERY_CAP_SHRINK_SHIFT`), grows back through the - existing debounced rule when survivor influx exceeds 4% of the cap (to the - base within four minors, then the ×2/×4 scale as before), and shrinks back to - the floor while influx stays under 1%. A copying minor is O(survivors), so a - small Eden costs little where little survives, and peak RSS is set by the - first nurseries: a shrink that only engaged later could not lower it - (measured: 68.5 → 66.5 MB on dotenv). The tenured-proportional term - (`old / 2`) is unchanged, so a large old generation still gets a large Eden. -- The #8122 allocation census now seeds at half the power-on cap rather than - half the base, so it still runs before the first minor it exists to precede. - -Measured (`callgrind` instructions per iteration fitted over n=2,500→10,000; -peak RSS by `getrusage` at 5k / 20k / 80k iterations; x86-64, -`PERRY_NO_AUTO_OPTIMIZE=1`; output identical to Node): - -| | main instr | branch instr | main RSS (MB) | branch RSS (MB) | -|---|---:|---:|---|---| -| dotenv/parse | 3,293,191 | 2,259,517 (**−31.4%**) | 55.2 / 55.3 / 55.1 | 52.1 / 52.0 / 52.1 | -| moment/parse_format | 2,506,824 | 1,994,023 (**−20.5%**) | 65.2 / 65.2 / 101.6 | 62.7 / 69.6 / 98.0 | - -A retaining program pays a few extra early minors while the cap climbs back -to the base. On a loop that keeps one object in ten, total instructions were -27% and 22% below main at 500k and 1M iterations and within +0.5% to +1.5% of -it at 2M and 4M, with peak RSS within ±5%. The climb stays debounced on -purpose: a program's first minor sees its start-up data survive (about 10% of -a 4 MB nursery on dotenv), and an undebounced jump to the base on that one -reading put dotenv's peak RSS back at 62 MB. - -Not fixed here, and not new: under the generational collector moment's RSS -still climbs with N on main as well as here (flat at 62 MB with -`PERRY_GEN_GC=0`), so something native is released only by full collections. -The phantom fulls used to hide part of it; it wants its own issue. - -Tests: `a_search_over_thirty_two_registers_borrows_the_lent_scratch` (64 of 64 -searches took the owned path before; 0 now), -`regex_scratch_buffers_do_not_count_as_released_external_pressure` (a freed -8 KiB buffer added 8,192 bytes of released pressure before; 0 now) and -`the_nursery_starts_at_a_floor_and_follows_survivor_influx`, each -sabotage-checked against the unfixed code; five existing tenuring/trigger tests -updated to the new power-on cap with their intent kept; and -`test_gap_11549_regex_large_register_scratch` (large, nested and interleaved -programs; byte-identical to Node). No version bump. diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index d3ad29c0e6..7a7ba38d96 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -145,7 +145,7 @@ fn young_scavenge_cap_probe(old_reclaimable: impl FnOnce() -> usize) -> YoungSca }; // #8122: before the first copying minor has measured survivors, denominate // the FIRST cap in this program's objects too (one header walk, once per - // process, halfway to the power-on cap). Not while a collection is in + // process, halfway to the base cap). Not while a collection is in // progress or a budgeted cycle is active — the young generation is being // rewritten then and the walk would read forwarding stubs. // @@ -786,29 +786,6 @@ pub(crate) fn gc_note_external_side_free(bytes: usize) { GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL.with(|c| c.set(c.get().saturating_add(bytes))); } -/// Record `bytes` of operation-scoped native scratch that its own operation -/// frees before returning (#11549), such as regex match and compile buffers. -/// -/// Only the live term moves. The collector can never reclaim this memory, so -/// it does not count toward the allocation-churn step and its release does not -/// add to [`external_side_old_reclaim_pressure_bytes`]. Through -/// [`gc_note_external_side_alloc`]/[`gc_note_external_side_free`], a 42-register -/// regex in a loop (dotenv's line pattern) fed 336+ bytes per call into that -/// released-bytes term. The term never matched any garbage on the heap, and it -/// alone drove the loop into repeated full collections. -/// -/// Never collects, so callers may hold raw heap views across it. -#[cfg(feature = "regex-engine")] -pub(crate) fn gc_note_external_transient_alloc(bytes: usize) { - GC_EXTERNAL_SIDE_LIVE_BYTES.with(|c| c.set(c.get().saturating_add(bytes))); -} - -/// Release bytes recorded by [`gc_note_external_transient_alloc`]. -#[cfg(feature = "regex-engine")] -pub(crate) fn gc_note_external_transient_free(bytes: usize) { - GC_EXTERNAL_SIDE_LIVE_BYTES.with(|c| c.set(c.get().saturating_sub(bytes))); -} - /// The external side-buffer term of OLD-RECLAIM pressure. /// /// Live bytes plus all reported releases since the last full baseline. The diff --git a/crates/perry-runtime/src/gc/tenuring.rs b/crates/perry-runtime/src/gc/tenuring.rs index 1a742305ab..eb6b5f50af 100644 --- a/crates/perry-runtime/src/gc/tenuring.rs +++ b/crates/perry-runtime/src/gc/tenuring.rs @@ -198,17 +198,6 @@ const RAISE_DEBOUNCE_CYCLES: u8 = 2; /// adaptive threshold has eliminated the re-copying). const NURSERY_CAP_SCALE_MAX: u8 = 4; -/// How far below the base the cap may shrink, as a right shift: 2 = ÷4, a -/// 4 MB nursery at the default 16 MB base (#11549). -/// -/// A copying minor is O(survivors), so on a workload whose survivor influx is -/// a sliver of the cap, a smaller Eden costs a few more cheap collections and -/// saves the rest of the Eden in resident memory. Measured on dotenv/parse -/// (1-2 KB of influx per 12-16 MB minor): the ÷4 nursery took peak RSS from -/// 68.5 MB to 52.1 MB for +0.4% instructions. The floor stops the collection -/// count from running away where the fixed per-minor cost is what matters. -const NURSERY_CAP_SHRINK_SHIFT_MAX: u8 = 2; - crate::perry_thread_local! { /// Power-on threshold. This is `OCCUPANCY_MIN_SURVIVALS`, not the ceiling: /// see `SURVIVOR_ROUND_MEASURED`. Starting at the ceiling is a claim that @@ -231,20 +220,6 @@ crate::perry_thread_local! { /// Influx-driven multiplier (1, 2, or 4) applied to the scavenge nursery /// cap. Power of two; grows/shrinks one step at a time, debounced. static NURSERY_CAP_SCALE: super::TriggerInput = const { super::TriggerInput::new(1) }; - /// Shift below the base cap, `0..=NURSERY_CAP_SHRINK_SHIFT_MAX`, taken only - /// while `NURSERY_CAP_SCALE` is 1 (#11549). Grows and shrinks one step at - /// a time, debounced like the scale. - /// - /// Power-on is the floor, for the reason `TENURING_SURVIVALS` powers on at - /// its floor: a full-size first nursery is a claim that young objects live - /// long, made before any have had the chance to die, and it is the - /// expensive direction of that claim. For resident memory it is also - /// irreversible, because peak RSS is set by the first nurseries and a - /// later shrink cannot lower a high-water mark. Survivor influx above 4% - /// of the cap walks it back up in two cycles per step, so a workload that - /// retains reaches the base within four minors. - static NURSERY_CAP_SHRINK_SHIFT: super::TriggerInput = - const { super::TriggerInput::new(NURSERY_CAP_SHRINK_SHIFT_MAX) }; static CAP_GROW_STREAK: Cell = const { Cell::new(0) }; static CAP_SHRINK_STREAK: Cell = const { Cell::new(0) }; /// #7929: mean size of the objects the last copying minor moved. Seeded at @@ -382,8 +357,7 @@ pub(super) fn scavenge_nursery_cap_effective_bytes() -> usize { /// as the two-term policy it is. pub(super) fn influx_driven_nursery_cap_bytes() -> usize { let constant_band = gc_scavenge_nursery_cap_bytes() - .saturating_mul(NURSERY_CAP_SCALE.with(TriggerInput::get) as usize) - >> NURSERY_CAP_SHRINK_SHIFT.with(TriggerInput::get); + .saturating_mul(NURSERY_CAP_SCALE.with(TriggerInput::get) as usize); // The multiply is done in u64 deliberately. `usize::saturating_mul` on an // ILP32 target (watchOS/visionOS are 32-bit) would saturate a 64 MB band // against a 1000-per-mille factor at `u32::MAX` and the following divide @@ -503,11 +477,10 @@ pub(super) fn note_surviving_object_census(moved_bytes: usize, moved_objects: us /// /// # What it does /// -/// Halfway to the power-on cap (2 MB of from-space by default: the 16 MB base -/// at its ÷4 power-on floor, #11549) — a point every program that will ever -/// reach the cap passes exactly once — hop the young generation's headers -/// (`arena::young_allocation_census`, ~1M instructions per 8 MB of small -/// objects, paid ONCE per process) and install +/// Halfway to the configured base cap (8 MB of from-space by default) — a +/// point every program that will ever reach the cap passes exactly once — hop +/// the young generation's headers (`arena::young_allocation_census`, ~1M +/// instructions for 8 MB of small objects, paid ONCE per process) and install /// `bytes / objects` as the mean. The first minor is then object-denominated /// like every later one, and a smaller representation no longer buys the /// collector a bigger first trace. The one-sided clamp still applies: a mean @@ -515,7 +488,7 @@ pub(super) fn note_surviving_object_census(moved_bytes: usize, moved_objects: us /// allocation stream cannot raise the cap. The collector's survivor census /// overwrites this seed at the first minor, so steady state is unchanged. /// -/// Returns without walking when the power-on cap is not yet half full, when a +/// Returns without walking when the base cap is not yet half full, when a /// census (either kind) already exists, or when the nursery is empty. /// Has the object denomination been seeded, by an allocation census or by a /// copying minor's survivor census? Once true it stays true for the process @@ -526,13 +499,10 @@ pub(super) fn object_census_seeded() -> bool { } /// The young-generation occupancy at which the one-time allocation census is -/// taken: halfway to the power-on cap. Shared with the trigger watermark (#10698), +/// taken: halfway to the base cap. Shared with the trigger watermark (#10698), /// which must not let a fast-path answer carry past the reading that seeds. pub(super) fn object_census_seed_point_bytes() -> usize { - // Half the POWER-ON cap, not the base: the nursery starts at the shrink - // floor (#11549), and a seed point past the first cap would let the first - // minor run before the census it exists to precede. - (super::policy::gc_scavenge_nursery_cap_bytes() >> NURSERY_CAP_SHRINK_SHIFT_MAX) / 2 + super::policy::gc_scavenge_nursery_cap_bytes() / 2 } pub(super) fn maybe_seed_object_census_from_allocation(from_space_in_use_bytes: usize) { @@ -741,55 +711,29 @@ pub(super) fn retune_after_scavenge( /// Shrink one step when influx falls below 1% for two consecutive cycles. /// The 4%/1% band is wide enough that the scale cannot oscillate on a /// steady workload (growing halves the observed ratio, 4%/2 = 2% > 1%). -/// -/// #11549: the shrink continues below the base, to `base >> 2`, while the -/// influx stays under 1% (see [`NURSERY_CAP_SHRINK_SHIFT_MAX`]), and a grow -/// undoes that shift, one debounced step at a time, before it raises the -/// scale. Each shrink step at most doubles the observed ratio, 1% to 2%, still -/// under the 4% that grows it back, so the same band keeps the extension from -/// oscillating. -/// -/// The climb is debounced like every other step, not a jump back to the base -/// on the first retaining minor: a program's first minor sees its start-up -/// data survive (dotenv/parse: ~10% of a 4 MB nursery), and a jump on that one -/// reading put its peak RSS straight back at the 16 MB nursery's (52 → 62 MB). -/// A genuinely retaining program pays a few extra early minors on the climb, -/// which measured within ±1.5% of main in total instructions from 2M -/// iterations of a keep-one-in-ten loop and 22–27% below it at 0.5–1M. fn retune_nursery_cap_scale(eden_live_bytes: usize) { let cap = scavenge_nursery_cap_effective_bytes(); let scale = NURSERY_CAP_SCALE.with(TriggerInput::get); - let shift = NURSERY_CAP_SHRINK_SHIFT.with(TriggerInput::get); if eden_live_bytes > cap / 25 { CAP_SHRINK_STREAK.with(|s| s.set(0)); - if shift > 0 || scale < NURSERY_CAP_SCALE_MAX { + if scale < NURSERY_CAP_SCALE_MAX { let streak = CAP_GROW_STREAK.with(|s| s.get()).saturating_add(1); if streak >= RAISE_DEBOUNCE_CYCLES { CAP_GROW_STREAK.with(|s| s.set(0)); - if shift > 0 { - NURSERY_CAP_SHRINK_SHIFT.with(|s| s.set(shift - 1)); - diag_cap_shift(shift, shift - 1, eden_live_bytes); - } else { - NURSERY_CAP_SCALE.with(|s| s.set(scale * 2)); - diag_cap_scale(scale, scale * 2, eden_live_bytes); - } + NURSERY_CAP_SCALE.with(|s| s.set(scale * 2)); + diag_cap_scale(scale, scale * 2, eden_live_bytes); } else { CAP_GROW_STREAK.with(|s| s.set(streak)); } } } else if eden_live_bytes < cap / 100 { CAP_GROW_STREAK.with(|s| s.set(0)); - if scale > 1 || shift < NURSERY_CAP_SHRINK_SHIFT_MAX { + if scale > 1 { let streak = CAP_SHRINK_STREAK.with(|s| s.get()).saturating_add(1); if streak >= RAISE_DEBOUNCE_CYCLES { CAP_SHRINK_STREAK.with(|s| s.set(0)); - if scale > 1 { - NURSERY_CAP_SCALE.with(|s| s.set(scale / 2)); - diag_cap_scale(scale, scale / 2, eden_live_bytes); - } else { - NURSERY_CAP_SHRINK_SHIFT.with(|s| s.set(shift + 1)); - diag_cap_shift(shift, shift + 1, eden_live_bytes); - } + NURSERY_CAP_SCALE.with(|s| s.set(scale / 2)); + diag_cap_scale(scale, scale / 2, eden_live_bytes); } else { CAP_SHRINK_STREAK.with(|s| s.set(streak)); } @@ -800,16 +744,6 @@ fn retune_nursery_cap_scale(eden_live_bytes: usize) { } } -fn diag_cap_shift(from: u8, to: u8, eden_live_bytes: usize) { - if crate::gc::gc_diag_enabled() { - eprintln!( - "[gc-tenuring] nursery cap shrink 1/{} -> 1/{} (eden_live_bytes={eden_live_bytes})", - 1u32 << from, - 1u32 << to - ); - } -} - /// Minimum Eden survival rate, in tenths, for a mark-sweep to seed the /// promote-on-first-copy lock: ≥90% of the Eden bytes the sweep classified /// must have been live. That is the "the aging round would filter nothing" @@ -923,7 +857,6 @@ pub(super) fn reset_for_test() { UNLOCK_STREAK.with(|s| s.set(0)); PREV_COPIED_BYTES.with(|c| c.set(0)); NURSERY_CAP_SCALE.with(|s| s.set(1)); - NURSERY_CAP_SHRINK_SHIFT.with(|s| s.set(NURSERY_CAP_SHRINK_SHIFT_MAX)); CAP_GROW_STREAK.with(|s| s.set(0)); CAP_SHRINK_STREAK.with(|s| s.set(0)); MEAN_SURVIVING_OBJECT_BYTES.with(|s| s.set(NURSERY_CAP_REFERENCE_OBJECT_BYTES)); @@ -1014,12 +947,11 @@ mod tests { #[test] fn census_carries_forward_across_a_cycle_that_moved_nothing() { reset_for_test(); - // The power-on floor (#11549) is the band this test scales. - let base = gc_scavenge_nursery_cap_bytes() / 4; + let base = gc_scavenge_nursery_cap_bytes(); assert_eq!( influx_driven_nursery_cap_bytes(), base, - "an unmeasured process paces on the unscaled power-on band" + "unmeasured process must pace exactly as the pre-#7929 collector did" ); note_surviving_object_census(56 * 1000, 1000); @@ -1038,7 +970,7 @@ mod tests { } /// #8122: the allocation census seeds the mean ONCE, only past half the - /// power-on cap, and never after a census of either kind exists. + /// base cap, and never after a census of either kind exists. /// /// Pure-policy half. The walk itself (a real nursery, real headers) is /// driven in `gc::tests::copying::adaptive_tenuring`; this pins the gate @@ -1047,17 +979,13 @@ mod tests { fn allocation_census_seed_is_gated_and_one_shot() { reset_for_test(); let base = gc_scavenge_nursery_cap_bytes(); - // Half the ÷4 power-on cap (#11549), as a literal so a drifted seed - // point fails here rather than moving with the code. - let seed_point = base / 8; - assert_eq!(object_census_seed_point_bytes(), seed_point); assert!(!object_census_seeded_for_test()); - // Below half the power-on cap: nothing happens, the seed stays armed. - maybe_seed_object_census_from_allocation(seed_point - 1); + // Below half the base cap: nothing happens, the seed stays armed. + maybe_seed_object_census_from_allocation(base / 2 - 1); assert!( !object_census_seeded_for_test(), - "the probe must not fire below half the power-on cap" + "the probe must not fire below half the base cap" ); assert_eq!( mean_surviving_object_bytes(), @@ -1115,8 +1043,6 @@ mod tests { #[test] fn drops_immediately_and_rises_debounced() { reset_for_test(); - // A threshold test, not a cap test: hold `desired` still (#11549). - start_at_base_for_test(); let desired = desired_survivor_bytes(); // Power-on is the FLOOR now, not the ceiling (startup follow-up): the // ladder may not claim a lifetime in either direction without evidence. @@ -1180,54 +1106,6 @@ mod tests { reset_for_test(); } - /// #11549: the nursery powers on at the `base / 4` floor; a heavy influx - /// walks it up, one debounced step at a time, undoing the shrink before the - /// scale may grow past the base; a sustained sliver of influx walks it back - /// down to the floor and no further. - /// - /// The expected caps are literals, not `base >> NURSERY_CAP_SHRINK_SHIFT_MAX`, - /// which would pass with the floor set to 0 and the rule doing nothing. - /// Sabotage: power-on at the base fails the first assertion (16 MB); an - /// undebounced climb fails "one heavy cycle alone does not move it" (a - /// program's first minor sees its start-up data survive); dropping the - /// shrink leaves the cap at the base after the quiet phase. - #[test] - fn the_nursery_starts_at_a_floor_and_follows_survivor_influx() { - reset_for_test(); - let base = gc_scavenge_nursery_cap_bytes(); - let cap = influx_driven_nursery_cap_bytes; - assert_eq!(cap(), base / 4, "power-on is the floor"); - for _ in 0..8 { - retune_nursery_cap_scale(1024); - } - assert_eq!(cap(), base / 4, "a tiny influx never goes below the floor"); - - let heavy = base; - retune_nursery_cap_scale(heavy); - assert_eq!(cap(), base / 4, "one heavy cycle alone does not move it"); - retune_nursery_cap_scale(heavy); - assert_eq!(cap(), base / 2); - retune_nursery_cap_scale(heavy); - retune_nursery_cap_scale(heavy); - assert_eq!(cap(), base); - retune_nursery_cap_scale(heavy); - retune_nursery_cap_scale(heavy); - assert_eq!(cap(), base * 2, "past the base, the scale grows as before"); - - // Quiet again: the scale comes down first, then the shrink. - retune_nursery_cap_scale(1024); - retune_nursery_cap_scale(1024); - assert_eq!(cap(), base); - retune_nursery_cap_scale(1024); - retune_nursery_cap_scale(1024); - assert_eq!(cap(), base / 2); - for _ in 0..8 { - retune_nursery_cap_scale(1024); - } - assert_eq!(cap(), base / 4); - reset_for_test(); - } - /// #9851, both halves of the rule in one test, in the #7909 two-phase shape /// so the decline is ATTRIBUTED rather than merely absent. /// @@ -1248,8 +1126,6 @@ mod tests { #[test] fn occupancy_alone_never_claims_promote_on_first_copy_but_the_lock_still_can() { reset_for_test(); - // A threshold test, not a cap test: hold `desired` still (#11549). - start_at_base_for_test(); let d = desired_survivor_bytes(); // Phase 1: influx 16x the desired survivor size — the occupancy formula @@ -1410,8 +1286,6 @@ mod tests { #[test] fn survival_rate_lock_breaks_a_saturated_pipeline() { reset_for_test(); - // A threshold test, not a cap test: hold `desired` still (#11549). - start_at_base_for_test(); let d = desired_survivor_bytes(); // tree.ts steady state: influx sits JUST under desired (occupancy // alone settles at S=2), the survivor space holds 3 cohorts, and @@ -1447,17 +1321,9 @@ mod tests { reset_for_test(); } - /// Put the cap at the base, as a workload whose influx has walked it up - /// from the power-on floor would have (#11549). For tests of the ladder - /// ABOVE the base; the floor has its own test. - fn start_at_base_for_test() { - NURSERY_CAP_SHRINK_SHIFT.with(|s| s.set(0)); - } - #[test] fn cap_scale_grows_on_heavy_influx_and_shrinks_when_quiet() { reset_for_test(); - start_at_base_for_test(); let base = gc_scavenge_nursery_cap_bytes(); assert_eq!(scavenge_nursery_cap_effective_bytes(), base); // Influx above 4% of the cap: one debounce cycle, then a ×2 step. @@ -1706,16 +1572,14 @@ mod tests { // Honest about its limits: on a quiescent test thread old-gen is // ~empty, so this cannot distinguish the two terms by value — it only // proves the accessor and the policy agree on the live inputs, and - // that the #7377 floor survives whatever old-gen happens to be. That - // floor is the influx term's own, now the ÷4 power-on floor (#11549): - // bounded well away from the near-zero cap #7377 fixed. + // that the #7377 floor survives whatever old-gen happens to be. reset_for_test(); let expected = scavenge_nursery_cap_from( influx_driven_nursery_cap_bytes(), old_gen_reclaimable_pressure_bytes(), ); assert_eq!(scavenge_nursery_cap_effective_bytes(), expected); - assert!(scavenge_nursery_cap_effective_bytes() >= gc_scavenge_nursery_cap_bytes() / 4); + assert!(scavenge_nursery_cap_effective_bytes() >= gc_scavenge_nursery_cap_bytes()); reset_for_test(); } } diff --git a/crates/perry-runtime/src/gc/tests/copying/adaptive_tenuring.rs b/crates/perry-runtime/src/gc/tests/copying/adaptive_tenuring.rs index 1f21b6c865..b8259420ac 100644 --- a/crates/perry-runtime/src/gc/tests/copying/adaptive_tenuring.rs +++ b/crates/perry-runtime/src/gc/tests/copying/adaptive_tenuring.rs @@ -133,7 +133,7 @@ fn copying_minor_feeds_the_object_denomination_census() { } /// #8122: BEFORE any copying minor has run, once the young generation is -/// half-way to the power-on cap, one header walk seeds the object denomination +/// half-way to the base cap, one header walk seeds the object denomination /// with the mean size of what was actually allocated — so the FIRST minor is /// object-denominated too, and a smaller representation stops buying the /// collector a bigger first trace. @@ -159,17 +159,11 @@ fn allocation_census_seeds_the_first_cap_before_any_minor() { ); assert!(!crate::gc::tenuring::object_census_seeded_for_test()); - // Fill Eden past the seed point (half the power-on cap, #11549) with - // two-field object literals — the representation whose shrink motivated - // this. Unrooted is fine: nothing collects here, and an allocation census - // counts dead objects too. - let seed_point = crate::gc::tenuring::object_census_seed_point_bytes(); - assert!( - seed_point < base / 4, - "the census must run before the first (power-on) cap is reached" - ); + // Fill Eden past half the base cap with two-field object literals — the + // representation whose shrink motivated this. Unrooted is fine: nothing + // collects here, and an allocation census counts dead objects too. let (mut allocated_bytes, mut allocated_objects) = (0usize, 0usize); - while crate::arena::copying_from_space_in_use_bytes() < seed_point + 64 * 1024 { + while crate::arena::copying_from_space_in_use_bytes() < base / 2 + 64 * 1024 { for _ in 0..1024 { let obj = crate::object::js_object_alloc(0, 2); let header = unsafe { crate::value::addr_class::try_read_gc_header(obj as usize) } @@ -188,7 +182,7 @@ fn allocation_census_seeds_the_first_cap_before_any_minor() { let _ = crate::gc::policy::young_scavenge_cap_due(); assert!( crate::gc::tenuring::object_census_seeded_for_test(), - "half-way to the power-on cap the allocation census must have run" + "half-way to the base cap the allocation census must have run" ); let seeded = crate::gc::tenuring::mean_surviving_object_bytes(); // The nursery may hold a few pre-existing objects from the guard's own @@ -202,11 +196,10 @@ fn allocation_census_seeds_the_first_cap_before_any_minor() { seeded, crate::gc::tenuring::NURSERY_CAP_REFERENCE_OBJECT_BYTES ); - // ...and the first cap already reflects it — before any collection. The - // first cap is the ÷4 power-on floor (#11549). + // ...and the first cap already reflects it — before any collection. assert_eq!( crate::gc::tenuring::influx_driven_nursery_cap_bytes(), - base / 4 * crate::gc::tenuring::nursery_cap_object_scale_permille(seeded) / 1000 + base * crate::gc::tenuring::nursery_cap_object_scale_permille(seeded) / 1000 ); // One-shot: a different population allocated afterwards does not move diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_construction.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_construction.rs index b4dea5d494..6f85ca2fce 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_construction.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_construction.rs @@ -70,37 +70,6 @@ fn the_pre_search_poll_runs_on_one_search_in_sixty_four() { ); } -/// Operation-owned regex scratch is transient: while it lives it counts as -/// live external bytes, and releasing it adds nothing to the released-bytes -/// pressure that schedules full collections (#11549). -/// -/// Sabotage: noting a `Buffer` through `gc_note_external_side_alloc`/`_free` -/// as before makes `drained` grow by the buffer's size. -#[test] -fn regex_scratch_buffers_do_not_count_as_released_external_pressure() { - use crate::gc::policy::GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL; - use crate::regex::perex_memory::{Buffer, MemoryBudget}; - - let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); - let live = external_side_live_bytes(); - let drained = GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL.with(TriggerInput::get); - let memory = MemoryBudget::new(1 << 20); - { - let buffer = Buffer::::new(&memory, 1024).unwrap(); - assert!( - external_side_live_bytes() >= live + 1024 * std::mem::size_of::(), - "a live buffer must still be visible as live external bytes" - ); - drop(buffer); - } - assert_eq!(external_side_live_bytes(), live); - assert_eq!( - GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL.with(TriggerInput::get), - drained, - "a freed scratch buffer must not count as released pressure" - ); -} - fn construct<'s>(scope: &'s RuntimeHandleScope, pattern: &[u8], flags: &[u8]) -> RuntimeHandle<'s> { let p = text(scope, pattern); let f = text(scope, flags); diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_execution.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_execution.rs index 0d66afdb7e..572fa5b9f6 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_execution.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_execution.rs @@ -37,60 +37,6 @@ fn compile<'s>( BoundProgram::new(program, &mut budget).unwrap() } -/// dotenv's line pattern: 42 match registers, over the 32 the lent cell held. -const DOTENV_LINE: &str = r#"(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)"#; - -/// A program with more than 32 registers borrows the thread's lent scratch -/// instead of building and freeing owned buffers on every search (#11549). -/// -/// Sabotage: restoring the fixed 32-register cell sends every one of these -/// searches down the owned path, and `owned` reads 64. -#[test] -fn a_search_over_thirty_two_registers_borrows_the_lent_scratch() { - use crate::regex::perex_runtime::OWNED_SEARCHES_RUN; - - let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); - let scope = RuntimeHandleScope::new(); - let program = compile(&scope, DOTENV_LINE, "m"); - let registers = program - .with_view(|program| program.register_count()) - .unwrap(); - assert!( - registers > 32, - "fixture must need more registers than the old cell held, got {registers}" - ); - let input = subject(&scope, b"KEY_1=value_1 # trailing comment\n"); - let memory = MemoryBudget::new(1 << 20); - let search = || { - let mut budget = Budget::new(usize::MAX); - host::find( - &program, - &input, - 0, - CaptureMode::All, - &mut budget, - &memory, - usize::MAX, - &mut host::poll, - ) - .unwrap() - .expect("fixture: every search must match") - .full - }; - // Warm-up sizes the cell. A search that outgrows its frames or undo - // entries grows them for the next call, which can take a few calls. - for _ in 0..8 { - search(); - } - let before = OWNED_SEARCHES_RUN.with(std::cell::Cell::get); - for _ in 0..64 { - assert_eq!(search(), Span::new(0, 32).unwrap()); - } - let owned = OWNED_SEARCHES_RUN.with(std::cell::Cell::get) - before; - assert_eq!(owned, 0, "no search may fall back to owned buffers"); - assert_eq!(memory.live_bytes(), 0); -} - #[test] fn perex_host_buffers_account_overlap_failure_and_unwind() { let _guard = CopyingNurseryTestGuard::new(0); diff --git a/crates/perry-runtime/src/gc/tests/triggers.rs b/crates/perry-runtime/src/gc/tests/triggers.rs index db957f8bf2..8fd1fc2515 100644 --- a/crates/perry-runtime/src/gc/tests/triggers.rs +++ b/crates/perry-runtime/src/gc/tests/triggers.rs @@ -753,10 +753,7 @@ fn test_effective_arena_trigger_respects_armed_values() { // the cap's own basis. let nursery_capped = gc_moving_loop_polls_enabled(); let ceiling = gc_trigger_absolute_ceiling_bytes(); - // The clamp is the EFFECTIVE cap, which powers on at the ÷4 floor rather - // than the configured base (#11549). - let nursery_cap = gc_scavenge_nursery_cap_bytes() - .min(super::super::tenuring::scavenge_nursery_cap_effective_bytes()); + let nursery_cap = gc_scavenge_nursery_cap_bytes(); let prev_trigger = GC_NEXT_TRIGGER_BYTES.with(|c| c.get()); let prev_armed = GC_TRIGGER_ARMED.with(|c| c.get()); diff --git a/crates/perry-runtime/src/regex/perex_memory.rs b/crates/perry-runtime/src/regex/perex_memory.rs index b49dc9ca74..5a72bbedcc 100644 --- a/crates/perry-runtime/src/regex/perex_memory.rs +++ b/crates/perry-runtime/src/regex/perex_memory.rs @@ -1,5 +1,5 @@ //! Operation-owned native scratch, charged to Perry's external-byte budget. -//! No GC pointer may be stored in these buffers. `Reservation` accounting can +//! No GC pointer may be stored in these buffers. Allocation/accounting can //! collect, so callers must release all program/subject views first. use std::cell::Cell; @@ -97,10 +97,9 @@ impl Drop for Reservation<'_> { } } -/// Stable initialized native allocation, freed by the operation that made it. -/// Its bytes are live external bytes while it exists, but they are transient: -/// they neither step the allocation-churn trigger nor count as released -/// pressure when dropped (#11549), and noting them never collects. +/// Stable initialized native allocation. Its accounting owner is established +/// before notifying the collector, so a collecting/unwinding notification +/// cannot strand a buffer or leave its bytes charged. pub(crate) struct Buffer<'a, T: Copy + Default> { data: Vec, budget: &'a MemoryBudget, @@ -129,9 +128,10 @@ impl<'a, T: Copy + Default> Buffer<'a, T> { }; budget.live.set(live); budget.peak.set(budget.peak.get().max(live)); - // Transient (#11549): the operation frees this before it returns and - // the collector can never reclaim it, so it must not pace collections. - crate::gc::gc_note_external_transient_alloc(bytes); + if bytes != 0 { + crate::exception::catch_js_throw(|| crate::gc::gc_note_external_side_alloc(bytes)) + .map_err(|value| StorageError::Abrupt(value.to_bits()))?; + } Ok(owned) } } @@ -152,6 +152,8 @@ impl DerefMut for Buffer<'_, T> { impl Drop for Buffer<'_, T> { fn drop(&mut self) { self.budget.live.set(self.budget.live.get() - self.bytes); - crate::gc::gc_note_external_transient_free(self.bytes); + if self.bytes != 0 { + crate::gc::gc_note_external_side_free(self.bytes); + } } } diff --git a/crates/perry-runtime/src/regex/perex_runtime.rs b/crates/perry-runtime/src/regex/perex_runtime.rs index e6e3873a76..f2b8f9a6cf 100644 --- a/crates/perry-runtime/src/regex/perex_runtime.rs +++ b/crates/perry-runtime/src/regex/perex_runtime.rs @@ -201,14 +201,10 @@ impl std::ops::DerefMut for Slots<'_, T, N> { /// fewer: `/^[a-z]+_[0-9]+$/` needs 2. Frames and undo entries start empty and /// only grow through `rebuffer`, so they are never inline. const INLINE_REGISTERS: usize = 8; -/// Most registers the lent cell grows to. The cell is allocated once per -/// thread, not per call, and keeps the size of the largest program it has -/// served, so this caps that memory at 32 KiB per thread. -/// -/// It was a fixed 32 (#11549). Every search of a larger program, such as -/// dotenv's 42-register line pattern, then took the owned path and built and -/// freed its buffers on every call. Real patterns stay well under this cap. -const LENT_REGISTERS_MAX: usize = 4096; +/// Registers the lent cell holds. This array is allocated once per thread, not +/// per call, so it is sized for the programs a search may bring rather than +/// for what is cheap to move. +const LENT_REGISTERS: usize = 32; /// Capture spans an `exec` result can have and still be read without /// allocating. const INLINE_CAPTURES: usize = 16; @@ -257,7 +253,7 @@ impl ScratchOwner for MatchBuffers<'_> { /// frames and undo entries are the engine's own opaque scratch, exactly as in /// the owned buffers this replaces (see this module's header). struct ScratchCell { - registers: Vec, + registers: [usize; LENT_REGISTERS], frames: Vec, undo: Vec, } @@ -274,7 +270,7 @@ crate::perry_thread_local! { /// costing a `_tlv_get_addr` call — the opposite of what this change is for. static LENT_SCRATCH: std::cell::RefCell = const { std::cell::RefCell::new(ScratchCell { - registers: Vec::new(), + registers: [0; LENT_REGISTERS], frames: Vec::new(), undo: Vec::new(), }) @@ -310,14 +306,6 @@ crate::perry_thread_local! { const { std::cell::Cell::new(0) }; } -#[cfg(test)] -crate::perry_thread_local! { - /// Test-only: how many searches built their own `MatchBuffers` instead of - /// borrowing the lent cell, so a test can assert which path ran (#11549). - pub(crate) static OWNED_SEARCHES_RUN: std::cell::Cell = - const { std::cell::Cell::new(0) }; -} - /// Run the pre-search poll on one call in `PRE_SEARCH_POLL_STRIDE`. #[inline] fn poll_on_stride(poll: &mut impl FnMut() -> Result<(), EngineError>) -> Result<(), EngineError> { @@ -410,12 +398,6 @@ fn find_near_lent<'mem, S: ImmutableSubject>( return Ok(Lent::Fallback); }; let cell = &mut *cell; - if cell.registers.len() < registers { - // Grows a handful of times per thread, then never again. The - // cell's memory is the thread's, like `frames` and `undo`, so it - // is not noted to the collector (#11549). - cell.registers.resize(registers.max(32), 0); - } // Charged exactly like the owner it replaces: the operation's limit // sees the slots a search may use, whether or not they were allocated // for it. The thread keeps the memory; the operation only borrows it. @@ -556,7 +538,7 @@ pub(crate) fn find_near<'mem, S: ImmutableSubject>( // Lend the thread's scratch first: a search that fits it constructs and // moves nothing (#10166). Anything the cell cannot serve falls through to // the owned buffers below with the budget it entered on. - if registers <= LENT_REGISTERS_MAX { + if registers <= LENT_REGISTERS { let entry = *budget; match find_near_lent( &resources, registers, start, near, mode, budget, memory, quantum, poll, @@ -566,8 +548,6 @@ pub(crate) fn find_near<'mem, S: ImmutableSubject>( } } - #[cfg(test)] - OWNED_SEARCHES_RUN.with(|n| n.set(n.get() + 1)); poll()?; let buffers = MatchBuffers::new(memory, size)?; // A failed run reports the work it left (perex 0.1.10), so the budget diff --git a/docs/src/internals/garbage-collector.md b/docs/src/internals/garbage-collector.md index e2c3735ec6..69006a2d45 100644 --- a/docs/src/internals/garbage-collector.md +++ b/docs/src/internals/garbage-collector.md @@ -136,13 +136,7 @@ direct minor. `PERRY_GC_SCAVENGE_NURSERY_MB` tunes its base high-water cap, tenuring feedback may grow the effective cap by up to 4× on live-set-bound workloads, where a fixed cap would multiply the per-collection -fixed cost by an enormous collection count. In the other direction, the cap -powers on at a quarter of the base, a right shift of 2 -, -and returns there while survivor influx stays under 1% of it. A copying minor -costs O(survivors), so a small Eden is cheap where little survives, and peak -resident memory is set by the first nurseries. Influx above 4% walks -the cap back up to the base within four minors. Generated write barriers are also on +fixed cost by an enormous collection count. Generated write barriers are also on by default. Turning them off makes generational minors unsound, so the runtime deliberately falls back to full mark-sweep. diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 7f23e71ac3..a41e67f7cf 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -52,12 +52,6 @@ "scanner": "object::scan_object_cache_roots_mut -> closure::shape::scan_function_prototype_roots_mut", "why": "The per-agent Function.prototype OBJECT (the prototype the base Function ShapeId names), read by the shape-proven fn.bind/call/apply path. Visited by `closure::shape::scan_function_prototype_roots_mut` from the registered `object::scan_object_cache_roots_mut`, which keeps it alive and rewrites the slot when it moves. Uncovered here only because that registered scanner is in another file (same pattern as NULL_STUB_SLOT)." }, - { - "file": "crates/perry-runtime/src/gc/tenuring.rs", - "name": "NURSERY_CAP_SHRINK_SHIFT", - "verdict": "not_a_gc_pointer", - "why": "TriggerInput right shift applied to the scavenge nursery cap below its base, 0..=2 (#11549). A small integer policy dial like its sibling NURSERY_CAP_SCALE, never an address." - }, { "file": "crates/perry-runtime/src/map.rs", "name": "COLD_LOOKUPS", @@ -386,7 +380,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 (charter step 5, P0): the field-representation census adds `mod census_field_repr;` to `gc/mod.rs` and, in `census.rs`, one Rust-owned accumulator fed from `visit_object` inside `take_census`, which runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED; it reads slots and the per-object layout maps (try_borrow), allocates nothing on the JS heap and alters no mark/sweep control flow. The 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-27 (class constructors as function objects, #11414): `gc/mod.rs` gains one `reg_scanner!` registration (`object::class_value::scan_class_value_roots_mut`, the per-agent class function-object table, visited and rewritten) \u2014 a root-scanner registration that alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-29 after #11659 added `verify::verify_array_hole_tails_at_collection()` to `gc/mod.rs` and `gc/policy.rs`. Every call sits at a collection or budgeted-cycle ENTRY, beside `roots::ensure_stack_maps_built()`, i.e. before mark begins and so before `census_pass1_if_armed` opens the window. The call is a read-only walk of array headers (debug builds, or release with PERRY_GC_VERIFY_ARRAY_HOLES): it neither allocates GC memory, relocates, runs JS, nor reads or writes PASS1_MARKED, and it panics rather than continuing on a violation. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Pin re-audited 2026-09-29 after PR #11646 added one call inside that window, in `cycle.rs` beside `census_take_if_armed_at_full_sweep_start`: `object::shapes::store_kind::audit_heap_at_full_sweep_start()`. It compiles to nothing unless the `shape-fact-audit` feature is on; when on it walks the arena read-only (`gc::for_each_live_object_at_sweep_start`, in `gc/verify.rs`) and reads each marked object header and its shape record. It allocates no GC object, moves nothing, runs no JS callback and holds no address past the walk, so PASS1_MARKED stays valid across it; a disagreement panics rather than continuing. Re-audited 2026-09-29 after the pinned-roots fix touched `gc/cycle.rs` and `gc/mod.rs`: the block-persistence live-block predicate now reads GC_FLAG_MARKED alone (pinned objects are marked as roots, so the set of live blocks is unchanged), and `gc/mod.rs` adds one `reg_scanner!` registration. Neither relocates, runs JS, or moves the mark-complete to sweep-entry window. Re-audited again 2026-09-29 (same fix, copying-minor follow-up): `BlockPersistCycleState`'s force-mark in `gc/cycle.rs` no longer skips an unmarked pinned header, so it marks and pushes it like any other object of a live block. That is marking inside the budgeted cycle's persistence step: nothing relocates, no JS runs, and the budgeted path still skips both census boundaries. The window is unchanged. Re-audited 2026-09-29 for this-as-a-parameter stage 3: `gc/mod.rs` registers `scan_dispatch_binding_roots_mut` in place of `scan_implicit_this_roots_mut` (the implicit-`this` cell is deleted; the same scanner body keeps `new.target`, the static-`this` override and the static private-owner stack) and rewords its comment; 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 the JsFunctionInfo lane: `gc/census.rs`'s `side_tables()` loses its `closure_registry_census()` row (the closure-body registry is deleted); a census report row, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-29 for the read-site holder entry: `gc/mod.rs` adds one `reg_scanner!` registration, `read_holder::scan_read_holder_roots_mut`, which visits the holder and hop words of registered read-site caches during ROOT SCAN, before mark propagation completes. It rewrites root slots only through the visitor (as every registered scanner does), runs no JS, and nothing it does executes between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start`. The window is unchanged. Re-audited 2026-09-28 for #11549: `gc/policy.rs` gains `gc_note_external_transient_alloc`/`_free`, which only add to and subtract from the thread-local live external-byte counter and never collect (compiled only with `regex-engine`, their one user), plus comment edits; no mark/sweep control flow changes and nothing 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 (charter step 5, P0): the field-representation census adds `mod census_field_repr;` to `gc/mod.rs` and, in `census.rs`, one Rust-owned accumulator fed from `visit_object` inside `take_census`, which runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED; it reads slots and the per-object layout maps (try_borrow), allocates nothing on the JS heap and alters no mark/sweep control flow. The 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-27 (class constructors as function objects, #11414): `gc/mod.rs` gains one `reg_scanner!` registration (`object::class_value::scan_class_value_roots_mut`, the per-agent class function-object table, visited and rewritten) \u2014 a root-scanner registration that alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-29 after #11659 added `verify::verify_array_hole_tails_at_collection()` to `gc/mod.rs` and `gc/policy.rs`. Every call sits at a collection or budgeted-cycle ENTRY, beside `roots::ensure_stack_maps_built()`, i.e. before mark begins and so before `census_pass1_if_armed` opens the window. The call is a read-only walk of array headers (debug builds, or release with PERRY_GC_VERIFY_ARRAY_HOLES): it neither allocates GC memory, relocates, runs JS, nor reads or writes PASS1_MARKED, and it panics rather than continuing on a violation. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Pin re-audited 2026-09-29 after PR #11646 added one call inside that window, in `cycle.rs` beside `census_take_if_armed_at_full_sweep_start`: `object::shapes::store_kind::audit_heap_at_full_sweep_start()`. It compiles to nothing unless the `shape-fact-audit` feature is on; when on it walks the arena read-only (`gc::for_each_live_object_at_sweep_start`, in `gc/verify.rs`) and reads each marked object header and its shape record. It allocates no GC object, moves nothing, runs no JS callback and holds no address past the walk, so PASS1_MARKED stays valid across it; a disagreement panics rather than continuing. Re-audited 2026-09-29 after the pinned-roots fix touched `gc/cycle.rs` and `gc/mod.rs`: the block-persistence live-block predicate now reads GC_FLAG_MARKED alone (pinned objects are marked as roots, so the set of live blocks is unchanged), and `gc/mod.rs` adds one `reg_scanner!` registration. Neither relocates, runs JS, or moves the mark-complete to sweep-entry window. Re-audited again 2026-09-29 (same fix, copying-minor follow-up): `BlockPersistCycleState`'s force-mark in `gc/cycle.rs` no longer skips an unmarked pinned header, so it marks and pushes it like any other object of a live block. That is marking inside the budgeted cycle's persistence step: nothing relocates, no JS runs, and the budgeted path still skips both census boundaries. The window is unchanged. Re-audited 2026-09-29 for this-as-a-parameter stage 3: `gc/mod.rs` registers `scan_dispatch_binding_roots_mut` in place of `scan_implicit_this_roots_mut` (the implicit-`this` cell is deleted; the same scanner body keeps `new.target`, the static-`this` override and the static private-owner stack) and rewords its comment; 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 the JsFunctionInfo lane: `gc/census.rs`'s `side_tables()` loses its `closure_registry_census()` row (the closure-body registry is deleted); a census report row, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-29 for the read-site holder entry: `gc/mod.rs` adds one `reg_scanner!` registration, `read_holder::scan_read_holder_roots_mut`, which visits the holder and hop words of registered read-site caches during ROOT SCAN, before mark propagation completes. It rewrites root slots only through the visitor (as every registered scanner does), runs no JS, and nothing it does executes between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start`. The window is unchanged.", "window": { "start": { "file": "crates/perry-runtime/src/gc/census.rs", @@ -404,7 +398,7 @@ "crates/perry-runtime/src/gc/census.rs": "aed9e85c2c5cf17869f2e50408ca2dcaddb052aedea6d019431a9b57904dbda7", "crates/perry-runtime/src/gc/cycle.rs": "9b992d27700382c84758f6f4a5574115a9862234bed2e18e0c5a3cca25c25236", "crates/perry-runtime/src/gc/mod.rs": "4f52b4b204f13ef144994ea5ace0372615e68bb920c9f76ba05bd12a53b17ced", - "crates/perry-runtime/src/gc/policy.rs": "4597e0e596638197f583d9f8ecb5d16fb0f8d5e0a3be29a4c7ee48c234f8f9c4", + "crates/perry-runtime/src/gc/policy.rs": "84a869e0aa09e932a4d2b6601186260129ba851b4482f29579047713c60fc95e", "crates/perry-runtime/src/gc/progress.rs": "a5ad3971bbe4047229ca57325234780daa85921dbc778e1c08dff4ad07ccfb96" } } diff --git a/test-files/test_gap_11549_regex_large_register_scratch.ts b/test-files/test_gap_11549_regex_large_register_scratch.ts deleted file mode 100644 index 0fd2ed6ac7..0000000000 --- a/test-files/test_gap_11549_regex_large_register_scratch.ts +++ /dev/null @@ -1,55 +0,0 @@ -// #11549: a pattern with more than 32 match registers borrows the thread's -// lent scratch instead of building and freeing owned buffers per call, and -// regex scratch no longer counts as released external GC pressure. Results -// must be unchanged, including for nested searches (a replacer callback that -// runs another large pattern while the outer search holds the lent cell) and -// across programs of different sizes on one thread. - -// dotenv's line pattern: 42 registers. -const LINE = /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/mg; - -let doc = "# generated\nexport NODE_ENV=production\nPORT=8080\n"; -doc += 'MULTI="line one\nline two"\n'; -doc += "SINGLE='single # not a comment'\n"; -doc += "EMPTY=\n SPACED = spaced value \n"; -for (let k = 0; k < 6; k++) doc += "KEY_" + k + "=value_" + k + " # trailing " + k + "\n"; - -function parse(src: string): Record { - const out: Record = {}; - LINE.lastIndex = 0; - let m: RegExpExecArray | null; - while ((m = LINE.exec(src)) != null) out[m[1]] = (m[2] || "").trim(); - return out; -} - -// Many iterations with churn in between, so collections run mid-loop. -let sig = 0; -let last = ""; -for (let i = 0; i < 3000; i++) { - const parsed = parse(doc); - const junk: string[] = []; - for (let j = 0; j < 20; j++) junk.push("x" + i + "_" + j); - sig = (sig + Object.keys(parsed).length + junk.length) % 1000003; - last = JSON.stringify(parsed); -} -console.log(sig, last); - -// A wide alternation: more registers than the old 32-slot cell. -const WIDE = new RegExp(Array.from({ length: 40 }, (_, i) => "(w" + i + ")").join("|")); -console.log(["w0", "w17", "w39", "w40"].map((s) => (WIDE.exec(s) || []).filter((x) => x).join("/"))); - -// Nested: the outer search holds the lent cell while the callback searches. -const nested = "a=1\nb=2\nc=3\n".replace(/^(\w)=(\d)$/gm, (_all, k, v) => { - const inner = parse(k.toUpperCase() + "_X=" + v + " # c\n"); - return k + "->" + JSON.stringify(inner); -}); -console.log(nested); - -// Alternate small and large programs on the same thread. -const SMALL = /(\d+)-(\d+)/; -const out: string[] = []; -for (let i = 0; i < 5; i++) { - out.push((SMALL.exec("x" + i + "-" + (i * 3) + "y") || []).slice(1).join(":")); - out.push(String(Object.keys(parse("K" + i + "=v" + i + "\n")).length)); -} -console.log(out.join(","));