diff --git a/.github/workflows/cc-parity.yml b/.github/workflows/cc-parity.yml new file mode 100644 index 0000000000..0e92bcc7ef --- /dev/null +++ b/.github/workflows/cc-parity.yml @@ -0,0 +1,105 @@ +# Opt-in bundle-scale parity (#9346). Non-required until maintainers promote it. +name: cc-parity + +on: + pull_request: + types: [opened, synchronize, reopened, labeled] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: cc-parity-${{ github.event_name }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + changes: + if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-cc-parity') + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + outputs: + relevant: ${{ steps.filter.outputs.relevant }} + steps: + - id: filter + env: + GH_TOKEN: ${{ github.token }} + EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" != pull_request ]; then + echo 'relevant=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + cc_files=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename') + # An empty listing must not quietly turn a requested gate green. + if [ -z "$cc_files" ] || grep -E '^(crates/|Cargo\.(toml|lock)$|rust-toolchain|\.cargo/|\.github/workflows/cc-parity\.yml$|scripts/cc_parity_gate\.py$|tests/(test_cc_parity_gate\.py$|cc-parity/))' <<< "$cc_files" > /dev/null; then + echo 'relevant=true' >> "$GITHUB_OUTPUT" + else + echo 'relevant=false' >> "$GITHUB_OUTPUT" + fi + + cc-parity: + needs: changes + if: needs.changes.outputs.relevant == 'true' + # The bundle's IR construction exceeds the ARM runner's 7 GB RAM. + runs-on: macos-15-intel + timeout-minutes: 90 + env: + CARGO_BUILD_JOBS: '4' + CARGO_INCREMENTAL: '0' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Test the gate's failure paths + run: python3 -m unittest discover -s tests -p test_cc_parity_gate.py -v + + - name: Set scratch work directory + run: echo "CC_PARITY_WORK=$RUNNER_TEMP/cc-parity" >> "$GITHUB_ENV" + + # This job uses the macOS SDK, not the preinstalled simulator images. + # simctl unmounts runtime images before deleting their backing storage. + - name: Free simulator runtime disk space + run: | + sudo xcrun simctl runtime delete all + df -h / + + - name: Fetch and verify the pinned bundle + run: python3 scripts/cc_parity_gate.py prepare --work-dir "$CC_PARITY_WORK" + + - name: Install LLVM 22 + run: | + set -euo pipefail + brew install llvm@22 2>/dev/null || brew install llvm + cc_llvm_prefix="$(brew --prefix llvm@22 2>/dev/null || brew --prefix llvm)" + "$cc_llvm_prefix/bin/llvm-config" --version | grep -q '^22\.' + echo "LLVM_SYS_221_PREFIX=$cc_llvm_prefix" >> "$GITHUB_ENV" + + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + shared-key: cc-parity-wasm-host + save-if: ${{ github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' }} + + - name: Build compiler, then all runtime archives together + run: python3 scripts/cc_parity_gate.py build --work-dir "$CC_PARITY_WORK" + + - name: Compile the pinned bundle natively + run: python3 scripts/cc_parity_gate.py compile --timeout 4500 --work-dir "$CC_PARITY_WORK" --perry "$GITHUB_WORKSPACE/target/perry-dev/perry" + + - name: Check help and version offline against golden bytes + run: python3 scripts/cc_parity_gate.py check --work-dir "$CC_PARITY_WORK" + + - name: Upload compiler logs and parity results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: cc-parity-results + path: ${{ runner.temp }}/cc-parity/logs/ + if-no-files-found: warn + retention-days: 7 diff --git a/changelog.d/9762-forward-const-tdz.md b/changelog.d/9762-forward-const-tdz.md new file mode 100644 index 0000000000..0b6175860b --- /dev/null +++ b/changelog.d/9762-forward-const-tdz.md @@ -0,0 +1,2 @@ +### Fixes +- Preserve closure initializers that earlier closures capture, fixing false temporal-dead-zone errors in mutually recursive `const` functions. Genuine TDZ errors now name the source binding, including captured reads and updates. Fixes #9721. diff --git a/changelog.d/9770-macos-http-link.md b/changelog.d/9770-macos-http-link.md new file mode 100644 index 0000000000..0702eae09a --- /dev/null +++ b/changelog.d/9770-macos-http-link.md @@ -0,0 +1,5 @@ +Add a source-free installation regression for #8907, the macOS arm64 +v0.5.1220 `node:http` link failure previously fixed by #5983. Exercise the +full prebuilt runtime, stdlib, and HTTP wrapper in both default and +`PERRY_NO_AUTO_OPTIMIZE=1` modes, checking that the minimal server links, +listens on an ephemeral port, closes, and exits successfully. diff --git a/changelog.d/9772-idle-compaction-block-selection.md b/changelog.d/9772-idle-compaction-block-selection.md new file mode 100644 index 0000000000..a9b71ae94b --- /dev/null +++ b/changelog.d/9772-idle-compaction-block-selection.md @@ -0,0 +1 @@ +fix(gc): the idle old-generation compaction now selects whole BLOCKS, so the bytes it predicts are the bytes it can return (#9772). Old-gen memory is released a block at a time (`old_arena_reclaim_selected_dead_blocks`), but selection ranked individual 4 KB pages by fragmentation, and the emptied pages were scattered across blocks that kept other live occupants. Measured on the compiled claude-code TUI: the pass chose 10,740 pages, predicted 44 MB of "releasable block bytes", ran for 228 ms and released **nothing**; in a controlled two-arm run the same selection predicted 44.4 MB, spent 516 ms and released 0, because a page-granular prediction is not achievable by a block-granular reclaim. Selection now groups pages by their containing block (`arena::old_arena_block_ranges`), skips blocks holding pinned bytes, ranks the rest cheapest-to-empty and takes whole blocks, so every selected block ends the pass with no live occupant. Same workload, same binary, one env var apart: **released 46.6 MB of 52.4 MB predicted (`kept_promise=true`, 50 of 50 targeted blocks, `has_live=0`), old-gen occupancy 120.4 MB -> 73.8 MB**, against **0 MB released of 44.4 MB predicted** for the old selection. Two counters make a barren pass visible instead of silent: `[gc-old-block-reclaim]` reports targeted/released/kept-by-reason, and `[gc-idle-compact] done` now carries `predicted=` and `kept_promise=`, summarised as `broken_promises=` in the exit line. Two defects that only became visible once the pass could be judged against its own prediction are fixed with it: a pass that declines to evacuate no longer drops the excluded pages' holes first (it was destroying 40.7 MB of reusable free list and returning nothing), and `IDLE_COMPACT_MOVE_BUDGET_BYTES` is 8 MiB -> 1 MiB, which moves ~15 blocks per pass instead of ~50. Three interleaved pairs show the pause is unchanged by that (1,070 ms mean against the old selection's 1,044 ms, spread 515-1,375 ms tracking machine load), so the pass is dominated by fixed per-pass cost rather than by moving: the measured claim is that this returns ~15 MB per pass for the same pause the barren pass already spent, not that it made the pass cheaper. Kill switch `PERRY_GC_IDLE_COMPACT_BLOCKS=0`. diff --git a/changelog.d/9773-typed-feedback-profile-replay.md b/changelog.d/9773-typed-feedback-profile-replay.md new file mode 100644 index 0000000000..64290649e4 --- /dev/null +++ b/changelog.d/9773-typed-feedback-profile-replay.md @@ -0,0 +1 @@ +Add opt-in typed-feedback profile replay for guarded numeric array reads, with versioned capture catalogs, exact freshness checks, deterministic selection and rejection diagnostics, native-region verifier checks, and explain-lowering evidence. Profiles remain advisory and retain the runtime guard and boxed fallback. diff --git a/changelog.d/9777-build-cache-concat-site-cache.md b/changelog.d/9777-build-cache-concat-site-cache.md new file mode 100644 index 0000000000..fa6c1dedf7 --- /dev/null +++ b/changelog.d/9777-build-cache-concat-site-cache.md @@ -0,0 +1,10 @@ +**`PERRY_CONCAT_SITE_CACHE` is now a build-cache input.** #9514's per-site +concat cache reads the variable as a build-time kill switch — setting it to +`0` removes the lowering lane entirely — but it was registered neither in +`BUILD_CACHE_ENV_VARS` nor as a justified exclusion. A build with the switch +flipped could therefore be served a cached object produced with it in the +other state. + +`codegen_env_vars_are_build_cache_inputs` caught this by scanning +`crates/perry-codegen/src` for every `env::var("PERRY_…")`, which is why the +check scans the source instead of trusting a hand-maintained list. diff --git a/changelog.d/9793-cc-parity-gate.md b/changelog.d/9793-cc-parity-gate.md new file mode 100644 index 0000000000..33f88d749f --- /dev/null +++ b/changelog.d/9793-cc-parity-gate.md @@ -0,0 +1 @@ +Add an opt-in `run-cc-parity` CI gate that compiles pinned Claude Code 2.1.112 and checks native help/version output against offline Node goldens. diff --git a/changelog.d/9797-array-hole-inherited-setters.md b/changelog.d/9797-array-hole-inherited-setters.md new file mode 100644 index 0000000000..6f0b92eb2c --- /dev/null +++ b/changelog.d/9797-array-hole-inherited-setters.md @@ -0,0 +1 @@ +Fix numeric writes into array holes bypassing inherited setters and read-only properties on `Array.prototype` and `Object.prototype`. Existing own elements and arrays with unmodified prototype chains keep their fast path. diff --git a/changelog.d/9799-train125-gate-followups.md b/changelog.d/9799-train125-gate-followups.md new file mode 100644 index 0000000000..6d355290a0 --- /dev/null +++ b/changelog.d/9799-train125-gate-followups.md @@ -0,0 +1,28 @@ +**Gate follow-ups for train125.** Two of these are gates pinning a *spelling* +where the guarantee is a *property*; both were sabotage-checked after widening, +so the guarantee is unchanged. + +- `tdz_numeric_const_read_is_not_constant_folded` pinned + `call i64 @js_box_get_bits(...)`. #9721 routes the read through + `js_box_get_bits_named`, which additionally passes the binding name so the + thrown `ReferenceError` can identify it — a strictly better error, which the + test read as a lost guard. It now accepts either helper and additionally + asserts the read is NOT folded to the later value, which it never checked. +- `shape_descriptor_census` required `family_push_back` after + `slab_mut().insert`. #9768 added `family_append_fresh` — the same append minus + a membership scan that is dead work for an id `alloc_shape_id` just minted and + never reuses. The census now accepts either append and still enforces the + ordering: the by-id descriptor must exist before the reverse accelerator + points at it. +- `hot_diag.rs` and `alloc_census.rs` moved to `perry_thread_local!`, which also + made their holders visible to `gc_runtime_root_holders` (the #9740 design); + `CREDIT` and `LAST_IDLE_PREDICTED_RELEASE` are classified as counters. +- `intl/segmenter.rs`'s shared keys array is built inside `with_mut_ptr`, with + every use — including publication into `SEGMENT_RECORD_KEYS`, which #9769 + registers a scanner for — inside the scope. +- `PASS1_MARKED`'s window re-pinned after #9769 and #9771 touched pinned files. + Decisive: `census_take_if_armed_at_full_sweep_start` takes the snapshot out of + the thread-local BEFORE calling `take_census`, so #9771's feature-gated + Rust-heap dump inside it runs after the window has closed. +- Two `page_meta.rs` band literals are allowlisted as what they are: synthetic + block ranges inside a `#[test]`, not runtime classification. diff --git a/changelog.d/alloc-census-rust-heap.md b/changelog.d/alloc-census-rust-heap.md new file mode 100644 index 0000000000..d0e629b104 --- /dev/null +++ b/changelog.d/alloc-census-rust-heap.md @@ -0,0 +1 @@ +feat(runtime): `PERRY_ALLOC_CENSUS` — a sampling profiler for the Rust heap, behind the off-by-default `alloc-census` feature. The GC census accounts for the arena and the side tables; on the compiled claude-code TUI those two explain ~115 MB of a 300 MB idle footprint and ~430 MB of a 2 GB peak, and nothing in the runtime could say where the rest came from. The census wraps the `#[global_allocator]` and reports exact totals plus a power-of-two size-class histogram, and samples one call site per MiB allocated (raw `backtrace(3)` frames, symbolised offline with `atos`), subtracting a sampled pointer again when it is freed — so it reports *live* native bytes per call site, not just churn. Measured on a 400-character reply: **22.5 GB allocated in 31.7 M calls, peak live 1.70 GB**, of which the largest single owners are the GC's own side-table scanners rebuilding their hash maps inside every copying minor (`descriptor_state::scan_descriptor_roots_mut` 222 MB, `shapes::scan_shape_table_rekey_mut` 75 MB, `restore_surviving_dirty_coverage` 29 MB) and regex program construction (~127 MB). Dumped with the heap census on `SIGUSR2`, alongside `mi_stats_print`. diff --git a/changelog.d/keystroke-segmenter-shared-shape.md b/changelog.d/keystroke-segmenter-shared-shape.md new file mode 100644 index 0000000000..2394b1b422 --- /dev/null +++ b/changelog.d/keystroke-segmenter-shared-shape.md @@ -0,0 +1,15 @@ +### Fixed + +- `Intl.Segmenter` segment records now share one shape. They were built + property-by-property with `set_field`, which allocates a fresh key string + per call and clones the object's key list before each write, so every record + got its own keys array — and, because the shape table is keyed on that + array's address, its own ShapeId. That made every read of `.segment` / + `.index` / `.input` a guaranteed inline-cache miss and added one descriptor + to the shape table per record. Grapheme-aware text measurement segments + every string a terminal UI renders: one 400-character reply in the compiled + claude-code TUI produces 175,797 segment records, and `PERRY_IC_DIAG` + attributes 175,797 of that turn's 2,589,696 IC misses to the `.segment` read + site alone. The two record shapes (with and without `isWordLike`) now share + one `GC_FLAG_SHAPE_SHARED` keys array each, built at most twice per thread — + the same construction #7564 used for `{ value, done }` iterator results. diff --git a/changelog.d/keystroke-shape-family-append.md b/changelog.d/keystroke-shape-family-append.md new file mode 100644 index 0000000000..af11fad955 --- /dev/null +++ b/changelog.d/keystroke-shape-family-append.md @@ -0,0 +1,14 @@ +### Fixed + +- Interning a shape descriptor no longer scans the keys array's whole + descriptor history. `ShapeTableInner::family_push_back` / `facts_push_back` + answer "is this id already here?" with a linear scan of the family, and a + family accumulates every descriptor ever created for one keys array — so + interning the *n*-th descriptor for a keys array cost O(n) and a render that + keeps bumping a shape's semantic generation paid quadratic time. The two + interning sites append ids that `alloc_shape_id` has just handed out, and + that allocator never reuses a value, so the scan was provably dead work: + they now use `IdList::append_unchecked`. On the compiled claude-code TUI + `IdList::contains` was 6.2 % of main-thread leaf samples during a streamed + reply and 5.9 % in the window after it, 95 % of it under + `family_push_back`. diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index ba4899a9ef..dc7c2b43b9 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -205,6 +205,7 @@ mod hoisted_callback_method_tests; mod index_method_clone_tests; mod indexed_method_artifacts; mod ordinary_method_artifacts; +mod tdz_names; // `pub(crate)` so `crate::linker` can read the inline-hot-small policy // (`inline_hot_small_enabled` / `inline_hot_small_hint_threshold`). pub(crate) mod helpers; @@ -475,6 +476,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // becomes part of every emitted global so multi-module programs // don't collide on `.str.0.handle`. let mut strings = StringPool::with_prefix(module_prefix.clone()); + strings.tdz_binding_names = tdz_names::collect(hir); // #5247: install per-module source-location context for the dynamic // call-dispatch throw path, but only under `--debug-symbols` (which sets // `opts.debug_locations` + `opts.module_source`). Off by default — no @@ -3662,6 +3664,8 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> progress.phase(1, "lowering complete; finalizing generated IR"); crate::root_reload::apply_to_module(&mut llmod); + crate::typed_feedback_profile::finish_module(&mut llmod.native_rep_records); + let verify_native_regions = opts.verify_native_regions || std::env::var("PERRY_VERIFY_NATIVE_REGIONS").ok().as_deref() == Some("1"); if verify_native_regions { diff --git a/crates/perry-codegen/src/codegen/tdz_names.rs b/crates/perry-codegen/src/codegen/tdz_names.rs new file mode 100644 index 0000000000..b640a83f37 --- /dev/null +++ b/crates/perry-codegen/src/codegen/tdz_names.rs @@ -0,0 +1,184 @@ +//! Preserve source names for checked reads of forward lexical boxes. +use std::collections::{HashMap, HashSet}; + +use perry_hir::{Expr, Function, Module, Stmt}; + +#[derive(Default)] +struct Names { + bindings: HashMap, + tdz: HashSet, +} + +pub(super) fn collect(module: &Module) -> HashMap { + let mut names = Names::default(); + names.stmts(&module.init); + for function in &module.functions { + names.function(function); + } + for class in &module.classes { + for function in class + .methods + .iter() + .chain(&class.static_methods) + .chain(class.getters.iter().map(|(_, f)| f)) + .chain(class.setters.iter().map(|(_, f)| f)) + .chain(class.constructor.iter()) + .chain(class.computed_members.iter().map(|member| &member.function)) + { + names.function(function); + } + for field in class.fields.iter().chain(&class.static_fields) { + for expr in field.init.iter().chain(&field.key_expr) { + names.expr(expr); + } + } + } + for global in &module.globals { + if let Some(init) = &global.init { + names.expr(init); + } + } + names.bindings.retain(|id, _| names.tdz.contains(id)); + names.bindings +} + +impl Names { + fn function(&mut self, function: &Function) { + self.stmts(&function.body); + for param in &function.params { + if let Some(default) = ¶m.default { + self.expr(default); + } + } + } + + fn expr(&mut self, expr: &Expr) { + if let Expr::Closure { body, .. } = expr { + self.stmts(body); + } + perry_hir::walker::walk_expr_children(expr, &mut |child| self.expr(child)); + } + + fn stmts(&mut self, stmts: &[Stmt]) { + for stmt in stmts { + match stmt { + Stmt::Let { id, name, init, .. } => { + self.bindings.insert(*id, name.clone()); + if let Some(init) = init { + self.expr(init); + } + } + Stmt::PreallocateTdzBoxes(ids) => self.tdz.extend(ids), + Stmt::Expr(expr) | Stmt::Throw(expr) => self.expr(expr), + Stmt::Return(expr) => { + if let Some(expr) = expr { + self.expr(expr); + } + } + Stmt::If { + condition, + then_branch, + else_branch, + } => { + self.expr(condition); + self.stmts(then_branch); + if let Some(branch) = else_branch { + self.stmts(branch); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { condition, body } => { + self.expr(condition); + self.stmts(body); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(init) = init { + self.stmts(std::slice::from_ref(init)); + } + for expr in condition.iter().chain(update) { + self.expr(expr); + } + self.stmts(body); + } + Stmt::Labeled { body, .. } => self.stmts(std::slice::from_ref(body)), + Stmt::Try { + body, + catch, + finally, + } => { + self.stmts(body); + if let Some(catch) = catch { + self.stmts(&catch.body); + } + if let Some(finally) = finally { + self.stmts(finally); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + self.expr(discriminant); + for case in cases { + if let Some(test) = &case.test { + self.expr(test); + } + self.stmts(&case.body); + } + } + Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::PreallocateBoxes(_) + | Stmt::ReleaseBoxes(_) => {} + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use perry_hir::types::Type; + + #[test] + fn collects_nested_lexical_names_without_naming_ordinary_boxes() { + let local = |id, name: &str| Stmt::Let { + id, + name: name.into(), + ty: Type::Any, + mutable: true, + init: None, + }; + let mut hir = Module::new("names"); + hir.init = vec![ + Stmt::PreallocateBoxes(vec![0]), + Stmt::PreallocateTdzBoxes(vec![1]), + local(0, "ordinary"), + local(1, "later"), + Stmt::Expr(Expr::Closure { + func_id: 0, + params: Vec::new(), + return_type: Type::Any, + body: vec![Stmt::PreallocateTdzBoxes(vec![2]), local(2, "nested")], + captures: Vec::new(), + mutable_captures: Vec::new(), + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: true, + is_async: false, + is_generator: false, + is_strict: true, + }), + ]; + let mut names = super::collect(&hir).into_values().collect::>(); + names.sort(); + assert_eq!(names, ["later", "nested"]); + } +} diff --git a/crates/perry-codegen/src/codegen/trusted_box_callback_tests.rs b/crates/perry-codegen/src/codegen/trusted_box_callback_tests.rs index 06700bf2e6..03bbccdb48 100644 --- a/crates/perry-codegen/src/codegen/trusted_box_callback_tests.rs +++ b/crates/perry-codegen/src/codegen/trusted_box_callback_tests.rs @@ -186,9 +186,18 @@ fn select(closures: Vec<(u32, Expr)>, direct: impl IntoIterator) -> } fn emit(direct_literal: bool) -> String { + emit_with_tdz(direct_literal, false) +} + +fn emit_with_tdz(direct_literal: bool, tdz: bool) -> String { let mut module = Module::new("trusted_box_callback.ts"); module.init_kind = ModuleInitKind::Eager; module.functions = vec![consume_function(), outer_function(direct_literal)]; + if tdz { + module.functions[1] + .body + .insert(0, Stmt::PreallocateTdzBoxes(vec![COUNT])); + } module.init.push(Stmt::Expr(Expr::Call { callee: Box::new(Expr::FuncRef(3)), args: Vec::new(), @@ -260,6 +269,23 @@ fn named_block_body<'a>(function: &'a str, prefix: &str) -> String { .join("\n") } +#[test] +fn named_tdz_reads_reach_public_and_trusted_callbacks() { + let ir = emit_with_tdz(true, true); + let public = function_body(&ir, "perry_closure_trusted_box_callback_ts__99"); + let trusted = function_body( + &ir, + "perry_closure_trusted_box_callback_ts__99$trusted_boxes", + ); + assert!(public.contains("@js_box_get_bits_named("), "{public}"); + let cold = named_block_body(&trusted, "trusted_box.tdz"); + assert!(cold.contains("@js_box_get_bits_trusted_named("), "{cold}"); + assert!( + ir.contains("c\"count\\00\""), + "binding name must be in the string pool" + ); +} + #[test] fn direct_arrow_gets_a_private_body_but_keeps_the_public_validation_path() { let ir = emit(true); diff --git a/crates/perry-codegen/src/expr/index_get/guarded_array.rs b/crates/perry-codegen/src/expr/index_get/guarded_array.rs index a7fdc91289..ff889c781a 100644 --- a/crates/perry-codegen/src/expr/index_get/guarded_array.rs +++ b/crates/perry-codegen/src/expr/index_get/guarded_array.rs @@ -190,6 +190,20 @@ pub(super) fn lower_guarded_array_index_get( coerce_numeric_fallback: bool, receiver_slot: Option<&str>, ) -> Result { + let site_id = ctx.typed_feedback_site_id(ctx.ic_site_counter); + crate::typed_feedback_profile::register_site( + site_id, + &ctx.func.name, + "array_element", + "array[index]", + ); + let replay_fact = + crate::typed_feedback_profile::select_numeric_array(site_id, require_numeric_layout); + // Preserve the original consumer's coercion contract. A replay hint can + // select representation handling, but cannot turn a JS-value read into + // a numeric-context read. + let coerce_numeric_fallback = require_numeric_layout && coerce_numeric_fallback; + let require_numeric_layout = require_numeric_layout || replay_fact.is_some(); let contract = if require_numeric_layout { TypedFeedbackContract::numeric_array_get_index() } else { @@ -201,6 +215,10 @@ pub(super) fn lower_guarded_array_index_get( "array[index]", contract, ); + // Replay selects the existing numeric tier, including its full inline + // receiver/layout/bounds checks and cold runtime guard. The observation + // itself never admits a load or suppresses a check. + let inline_guard = !typed_feedback_emission_enabled(); let fast_idx = ctx.new_block(&format!("{}.fast", block_prefix)); let fallback_idx = ctx.new_block(&format!("{}.fallback", block_prefix)); // A non-negative ordinary-array index at or above `length` has no own @@ -210,7 +228,7 @@ pub(super) fn lower_guarded_array_index_get( // properties, that result is `undefined` without consulting the generic // polymorphic getter. Sparse-set membership tests hit exactly this arm for // absent ids, so keep it separate from the in-bounds raw-load block. - let inline_oob_idx = if !typed_feedback_emission_enabled() { + let inline_oob_idx = if inline_guard { Some(ctx.new_block(&format!("{}.guard.oob", block_prefix))) } else { None @@ -226,7 +244,7 @@ pub(super) fn lower_guarded_array_index_get( let mut inline_fast_handle: Option<(String, String)> = None; let mut runtime_fast_handle: Option<(String, String)> = None; - if !typed_feedback_emission_enabled() { + if inline_guard { // Normal builds do not collect feedback. Inline the plain-array // structural guard instead of paying an out-of-line call merely to // rediscover the same header facts before the direct slot load below. @@ -535,7 +553,10 @@ pub(super) fn lower_guarded_array_index_get( ], false, false, - Vec::new(), + replay_fact + .as_ref() + .map(|fact| vec![format!("typed_feedback_replay_fallback={}", fact.fact_id)]) + .unwrap_or_default(), ); } @@ -617,16 +638,27 @@ pub(super) fn lower_guarded_array_index_get( None, None, None, - vec![raw_f64_layout_fact( - None, - "consumed", - "numeric_array_index_get_guard", - None, - )], + { + let mut facts = vec![raw_f64_layout_fact( + None, + "consumed", + "numeric_array_index_get_guard", + None, + )]; + if let Some(fact) = &replay_fact { + facts.push(fact.clone()); + } + facts + }, Vec::new(), false, false, - Vec::new(), + replay_fact + .as_ref() + .map(|_| { + vec!["typed_feedback_replay_selected=fresh_numeric_array_observation".into()] + }) + .unwrap_or_default(), ); } diff --git a/crates/perry-codegen/src/expr/literals_vars.rs b/crates/perry-codegen/src/expr/literals_vars.rs index eea5d38544..a1d125ec12 100644 --- a/crates/perry-codegen/src/expr/literals_vars.rs +++ b/crates/perry-codegen/src/expr/literals_vars.rs @@ -24,6 +24,29 @@ use super::{ TrustedBoxCapturePtr, }; +/// Only TDZ-capable source bindings need a named accessor. Ordinary boxes +/// retain their existing ABI; trusted inline loads pass the name only on +/// their cold TDZ arm. Names come from permanent, GC-rooted string globals. +fn emit_box_read(ctx: &mut FnCtx<'_>, id: u32, ptr: &str, trusted: bool) -> String { + let base = if trusted { + "js_box_get_bits_trusted" + } else { + "js_box_get_bits" + }; + if let Some(name) = ctx.strings.tdz_binding_names.get(&id).cloned() { + let index = ctx.strings.intern(&name); + let global = format!("@{}", ctx.strings.entry(index).handle_global); + let name = ctx.block().load(DOUBLE, &global); + ctx.block().call( + I64, + &format!("{base}_named"), + &[(I64, ptr), (DOUBLE, &name)], + ) + } else { + ctx.block().call(I64, base, &[(I64, ptr)]) + } +} + /// Load the current value from a compiler-proven raw box capture. /// /// The exact-arrow resolver has already validated `capture.ptr`, so the hot @@ -31,7 +54,11 @@ use super::{ /// to the existing trusted accessor only for the reserved sentinel; that /// helper owns both ReferenceError construction and Perry's internal TDZ /// suppression window semantics. -fn load_trusted_box_capture_bits(ctx: &mut FnCtx<'_>, capture: &TrustedBoxCapturePtr) -> String { +fn load_trusted_box_capture_bits( + ctx: &mut FnCtx<'_>, + id: u32, + capture: &TrustedBoxCapturePtr, +) -> String { let bits = ctx.block().load(I64, &capture.ptr); let is_tdz = ctx.block().icmp_eq(I64, &bits, crate::nanbox::TAG_TDZ_I64); let slow_idx = ctx.new_block("trusted_box.tdz"); @@ -47,9 +74,7 @@ fn load_trusted_box_capture_bits(ctx: &mut FnCtx<'_>, capture: &TrustedBoxCaptur // before entering that observable cold arm, just like a PIC miss or // dynamic `+` fallback. crate::expr::emit_versioned_loop_callback_deopt(ctx); - let slow_bits = ctx - .block() - .call(I64, "js_box_get_bits_trusted", &[(I64, &capture.bits)]); + let slow_bits = emit_box_read(ctx, id, &capture.bits, true); let slow_end = ctx.block().label.clone(); ctx.block().br(&merge_label); @@ -474,22 +499,16 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // and deref via js_box_get_bits. if ctx.boxed_vars.contains(id) { if let Some(capture) = ctx.trusted_box_capture_ptrs.get(id).cloned() { - let bits = load_trusted_box_capture_bits(ctx, &capture); + let bits = load_trusted_box_capture_bits(ctx, *id, &capture); let value = ctx.block().bitcast_i64_to_double(&bits); demote_extracted_string_binding(ctx, *id, &value); return Ok(value); } let closure_ptr = super::current_closure_ptr_value(ctx, "captured boxed local")?; - let getter = if ctx.trusted_box_captures { - "js_box_get_bits_trusted" - } else { - "js_box_get_bits" - }; let box_ptr = load_closure_capture_bits_inline(ctx, &closure_ptr, capture_idx); - let blk = ctx.block(); - let bits = blk.call(I64, getter, &[(I64, &box_ptr)]); - let value = blk.bitcast_i64_to_double(&bits); + let bits = emit_box_read(ctx, *id, &box_ptr, ctx.trusted_box_captures); + let value = ctx.block().bitcast_i64_to_double(&bits); demote_extracted_string_binding(ctx, *id, &value); return Ok(value); } @@ -519,8 +538,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { if let Some(slot) = ctx.locals.get(id).cloned() { let blk = ctx.block(); let box_ptr = blk.load(I64, &slot); - let bits = blk.call(I64, "js_box_get_bits", &[(I64, &box_ptr)]); - let value = blk.bitcast_i64_to_double(&bits); + let bits = emit_box_read(ctx, *id, &box_ptr, false); + let value = ctx.block().bitcast_i64_to_double(&bits); demote_extracted_string_binding(ctx, *id, &value); return Ok(value); } @@ -959,7 +978,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // nested user frame `coerce_old`/`step_new` may enter. if ctx.boxed_vars.contains(id) { if let Some(capture) = ctx.trusted_box_capture_ptrs.get(id).cloned() { - let old_bits = load_trusted_box_capture_bits(ctx, &capture); + let old_bits = load_trusted_box_capture_bits(ctx, *id, &capture); let old = ctx.block().bitcast_i64_to_double(&old_bits); if needs_numeric_coerce && ctx.versioned_loop_deopt_context.is_some() { let is_number = crate::stmt::emit_js_value_is_number(ctx, &old); @@ -1016,11 +1035,6 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } let closure_ptr = super::current_closure_ptr_value(ctx, "captured boxed local update")?; - let getter = if ctx.trusted_box_captures { - "js_box_get_bits_trusted" - } else { - "js_box_get_bits" - }; let setter = if ctx.trusted_box_captures { "js_box_set_bits_trusted_no_barrier" } else { @@ -1032,7 +1046,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "js_closure_get_capture_bits", &[(I64, &closure_ptr), (I32, &idx_str)], ); - let old_bits = blk.call(I64, getter, &[(I64, &box_ptr)]); + let old_bits = emit_box_read(ctx, *id, &box_ptr, ctx.trusted_box_captures); + let blk = ctx.block(); let old = blk.bitcast_i64_to_double(&old_bits); let old = coerce_old(blk, &old); let new = step_new(blk, &old); @@ -1086,7 +1101,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { if let Some(slot) = ctx.locals.get(id).cloned() { let blk = ctx.block(); let box_ptr = blk.load(I64, &slot); - let old_bits = blk.call(I64, "js_box_get_bits", &[(I64, &box_ptr)]); + let old_bits = emit_box_read(ctx, *id, &box_ptr, false); + let blk = ctx.block(); let old = blk.bitcast_i64_to_double(&old_bits); let old = coerce_old(blk, &old); let new = step_new(blk, &old); diff --git a/crates/perry-codegen/src/expr/typed_feedback.rs b/crates/perry-codegen/src/expr/typed_feedback.rs index 78aff0e2c6..13b5f669a5 100644 --- a/crates/perry-codegen/src/expr/typed_feedback.rs +++ b/crates/perry-codegen/src/expr/typed_feedback.rs @@ -322,6 +322,7 @@ pub(crate) fn emit_typed_feedback_register_site( let local_site_id = ctx.ic_site_counter; ctx.ic_site_counter += 1; let site_id = ctx.typed_feedback_site_id(local_site_id); + crate::typed_feedback_profile::register_site(site_id, &ctx.func.name, kind.label(), operation); // Default build: skip the no-op registration call (and its byte globals) // but keep the site-id stable for the guard call. if !typed_feedback_emission_enabled() { diff --git a/crates/perry-codegen/src/gc_call_effects.rs b/crates/perry-codegen/src/gc_call_effects.rs index 9e0e2948c6..d2d4508d7b 100644 --- a/crates/perry-codegen/src/gc_call_effects.rs +++ b/crates/perry-codegen/src/gc_call_effects.rs @@ -872,7 +872,12 @@ mod tests { /// symbol can be admitted; this one cannot. #[test] fn the_tdz_capable_box_getter_stays_a_safepoint() { - for name in ["js_box_get_bits", "js_box_get_bits_trusted"] { + for name in [ + "js_box_get_bits", + "js_box_get_bits_trusted", + "js_box_get_bits_named", + "js_box_get_bits_trusted_named", + ] { assert_eq!( classify_direct_callee(name), GcCallEffect::Unknown, diff --git a/crates/perry-codegen/src/lib.rs b/crates/perry-codegen/src/lib.rs index 8186dcb47f..af644d78bb 100644 --- a/crates/perry-codegen/src/lib.rs +++ b/crates/perry-codegen/src/lib.rs @@ -68,6 +68,7 @@ pub(crate) mod type_analysis; pub(crate) mod type_analysis_class_fields; pub(crate) mod type_analysis_facts; pub(crate) mod type_analysis_net; +pub mod typed_feedback_profile; pub(crate) mod typed_shape; pub mod types; diff --git a/crates/perry-codegen/src/native_value/verify.rs b/crates/perry-codegen/src/native_value/verify.rs index 30679a3d09..7cf235543b 100644 --- a/crates/perry-codegen/src/native_value/verify.rs +++ b/crates/perry-codegen/src/native_value/verify.rs @@ -26,6 +26,7 @@ use raw_f64::{ pub(crate) fn verify_native_rep_records(records: &[NativeRepRecord]) -> Result<()> { let mut errors = Vec::new(); + crate::typed_feedback_profile::verify_records(records, &mut errors); for record in records { if let Some(expected_ty) = expected_llvm_type(&record.native_rep) { if record.llvm_ty != expected_ty { diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index c4a02e2aba..204cdbe4f0 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -1079,8 +1079,10 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { // both inc() and get() in a returned object literal). module.declare_function("js_box_alloc_bits", I64, &[I64]); module.declare_function("js_box_get_bits", I64, &[I64]); + module.declare_function("js_box_get_bits_named", I64, &[I64, DOUBLE]); module.declare_function("js_box_set_bits", VOID, &[I64, I64]); module.declare_function("js_box_get_bits_trusted", I64, &[I64]); + module.declare_function("js_box_get_bits_trusted_named", I64, &[I64, DOUBLE]); module.declare_function("js_box_set_bits_trusted_no_barrier", VOID, &[I64, I64]); module.declare_function("js_box_alloc", I64, &[DOUBLE]); module.declare_function("js_box_get", DOUBLE, &[I64]); diff --git a/crates/perry-codegen/src/strings.rs b/crates/perry-codegen/src/strings.rs index 20467e44a3..532e7b1393 100644 --- a/crates/perry-codegen/src/strings.rs +++ b/crates/perry-codegen/src/strings.rs @@ -81,6 +81,9 @@ pub struct StringPool { /// Ordered list of unique entries; the index in this Vec is the /// interned index referenced by `interned`. entries: Vec, + /// Module-wide names for TDZ-capable bindings, including outer bindings + /// read from closure bodies. Kept separately from per-function aliases. + pub(crate) tdz_binding_names: HashMap, /// #5247: source-location context for the dynamic call-dispatch throw /// path. Set once per module after construction (only when the CLI /// `--debug-symbols` flag is on). `None` in the default build so codegen @@ -141,6 +144,7 @@ impl StringPool { module_prefix, interned: HashMap::new(), entries: Vec::new(), + tdz_binding_names: HashMap::new(), debug_location_ctx: None, debug_source_line_offset: 0, pending_call_offset: std::cell::Cell::new(0), diff --git a/crates/perry-codegen/src/typed_feedback_profile.rs b/crates/perry-codegen/src/typed_feedback_profile.rs new file mode 100644 index 0000000000..1aba20c68c --- /dev/null +++ b/crates/perry-codegen/src/typed_feedback_profile.rs @@ -0,0 +1,478 @@ +//! Versioned, advisory typed-feedback replay. No observation is a runtime proof. +//! +//! The driver supplies source/compiler/configuration identity; this module matches +//! exact sites during lowering. State is scoped to one synchronous codegen call on +//! a rayon worker, and restored on every exit (including errors and unwinding). +use std::cell::RefCell; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; +use std::sync::Mutex; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::native_value::{NativeFactUse, NativeRepRecord}; +use crate::{compile_module, CompileOptions}; + +pub fn effective_target(opts: &CompileOptions) -> String { + opts.target + .clone() + .unwrap_or_else(crate::codegen::helpers::default_target_triple) +} + +pub const SCHEMA_VERSION: u32 = 1; +pub const NUMERIC_ARRAY_ELEMENT: &str = "numeric_array_element"; +pub(crate) const NUMERIC_GUARD: &str = "numeric_array_index_get_guard"; +pub(crate) const ARRAY_FALLBACK: &str = "js_typed_feedback_array_index_get_fallback_boxed"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Profile { + pub schema_version: u32, + /// Exact compiler executable SHA-256, including same-version development builds. + pub compiler: String, + pub modules: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModuleIdentity { + pub module: String, + pub source_hash: String, + pub hir_hash: String, + pub lowering_hash: String, + pub target: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModuleProfile { + pub identity: ModuleIdentity, + pub sites: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Site { + pub site_id: u64, + pub function: String, + pub kind: String, + pub operation: String, + /// Only numeric_array_element is currently supported. Captured catalogs use + /// unobserved until joined with a runtime trace by the capture utility. + pub observation_kind: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct Decision { + pub module: String, + pub site_id: Option, + pub function: String, + pub accepted: bool, + pub reason: String, +} + +pub struct Session { + compiler: String, + profile: Option, + captured: Mutex>, + decisions: Mutex>, +} + +impl Session { + pub fn new(compiler: String, profile: Option) -> Self { + Self { + compiler, + profile, + captured: Mutex::new(BTreeMap::new()), + decisions: Mutex::new(Vec::new()), + } + } + + /// Strict parsing for explicit input; unknown schema/observation versions are + /// well-formed but rejected later, with an explanation for every entry. + pub fn read_profile(path: &Path) -> Result { + let bytes = std::fs::read(path) + .with_context(|| format!("cannot read --typed-feedback-profile {}", path.display()))?; + let diagnostic = || { + format!("invalid --typed-feedback-profile {}: expected a versioned replay profile; create one with scripts/typed-feedback-profile.py", path.display()) + }; + let value: serde_json::Value = serde_json::from_slice(&bytes).with_context(diagnostic)?; + // A future schema may use a different body. Its version is sufficient + // to reject the whole profile without interpreting unknown fields. + if let Some(version) = value + .get("schema_version") + .and_then(serde_json::Value::as_u64) + .and_then(|v| u32::try_from(v).ok()) + .filter(|v| *v != SCHEMA_VERSION) + { + return Ok(serde_json::from_value(value).unwrap_or_else(|_| Profile { + schema_version: version, + compiler: String::new(), + modules: Vec::new(), + })); + } + serde_json::from_value(value).with_context(diagnostic) + } + + pub fn compile_module( + &self, + hir: &perry_hir::Module, + opts: CompileOptions, + identity: ModuleIdentity, + ) -> Result> { + let state = ModuleState::new(&self.compiler, self.profile.as_ref(), identity); + let previous = ACTIVE.with(|active| active.replace(Some(state))); + let _scope = Scope(previous); + let result = compile_module(hir, opts); + let state = ACTIVE + .with(|active| active.borrow_mut().take()) + .expect("feedback scope"); + if result.is_ok() { + self.decisions.lock().unwrap().extend(state.decisions); + self.captured.lock().unwrap().insert( + state.identity.module.clone(), + ModuleProfile { + identity: state.identity, + sites: state.sites.into_values().collect(), + }, + ); + } + result + } + + /// Call after all modules finish, before explain-lowering reads artifacts. + pub fn finish(&self, catalog_path: Option<&Path>) -> Result> { + let captured = self.captured.lock().unwrap(); + if let Some(path) = catalog_path { + let catalog = Profile { + schema_version: SCHEMA_VERSION, + compiler: self.compiler.clone(), + modules: captured.values().cloned().collect(), + }; + std::fs::write( + path, + format!("{}\n", serde_json::to_string_pretty(&catalog)?), + ) + .with_context(|| { + format!( + "cannot write typed-feedback site catalog {}", + path.display() + ) + })?; + } + let mut decisions = self.decisions.lock().unwrap().clone(); + let mut unmatched = Vec::new(); + if let Some(profile) = &self.profile { + // Even an empty incompatible profile needs a profile-level diagnostic. + if let Some(reason) = profile_rejection(&self.compiler, profile) { + let decision = Decision { + module: "".into(), + site_id: None, + function: String::new(), + accepted: false, + reason: reason.into(), + }; + unmatched.push(rejection_record(&decision)); + decisions.push(decision); + } + for module in &profile.modules { + if !captured.contains_key(&module.identity.module) { + let reason = + profile_rejection(&self.compiler, profile).unwrap_or("unknown_module"); + if module.sites.is_empty() { + let decision = module_rejection(&module.identity.module, reason); + unmatched.push(rejection_record(&decision)); + decisions.push(decision); + } + for site in &module.sites { + let decision = rejected(&module.identity.module, site, reason); + unmatched.push(rejection_record(&decision)); + decisions.push(decision); + } + } + } + } + if !unmatched.is_empty() { + crate::native_value::write_native_rep_artifact_if_enabled( + "typed_feedback_profile", + &unmatched, + )?; + } + decisions.sort(); + Ok(decisions) + } +} + +fn profile_rejection(compiler: &str, profile: &Profile) -> Option<&'static str> { + if profile.schema_version != SCHEMA_VERSION { + Some("schema_mismatch") + } else if profile.compiler != compiler { + Some("compiler_mismatch") + } else { + None + } +} + +fn identity_rejection(expected: &ModuleIdentity, actual: &ModuleIdentity) -> Option<&'static str> { + if expected.source_hash != actual.source_hash { + Some("source_hash_mismatch") + } else if expected.target != actual.target { + Some("target_mismatch") + } else if expected.hir_hash != actual.hir_hash { + Some("hir_hash_mismatch") + } else if expected.lowering_hash != actual.lowering_hash { + Some("lowering_inputs_mismatch") + } else { + None + } +} + +struct ModuleState { + compiler: String, + identity: ModuleIdentity, + sites: BTreeMap, + pending: BTreeMap, + decisions: Vec, +} + +impl ModuleState { + fn new(compiler: &str, profile: Option<&Profile>, identity: ModuleIdentity) -> Self { + let mut state = Self { + compiler: compiler.into(), + identity, + sites: BTreeMap::new(), + pending: BTreeMap::new(), + decisions: Vec::new(), + }; + if let Some(profile) = profile { + let modules: Vec<_> = profile + .modules + .iter() + .filter(|m| m.identity.module == state.identity.module) + .collect(); + for module in &modules { + let reason = profile_rejection(compiler, profile) + .or_else(|| (modules.len() != 1).then_some("duplicate_module")) + .or_else(|| identity_rejection(&module.identity, &state.identity)); + if module.sites.is_empty() { + if let Some(reason) = reason { + state + .decisions + .push(module_rejection(&state.identity.module, reason)); + } + } + let mut seen = BTreeSet::new(); + let duplicates: BTreeSet<_> = module + .sites + .iter() + .filter_map(|s| (!seen.insert(s.site_id)).then_some(s.site_id)) + .collect(); + for site in &module.sites { + let reason = reason + .or_else(|| { + duplicates + .contains(&site.site_id) + .then_some("duplicate_site") + }) + .or_else(|| { + (site.observation_kind != NUMERIC_ARRAY_ELEMENT) + .then_some("unsupported_observation_kind") + }); + if let Some(reason) = reason { + state + .decisions + .push(rejected(&state.identity.module, site, reason)); + } else { + state.pending.insert(site.site_id, site.clone()); + } + } + } + } + state + } +} + +thread_local! { + static ACTIVE: RefCell> = const { RefCell::new(None) }; +} +struct Scope(Option); +impl Drop for Scope { + fn drop(&mut self) { + ACTIVE.with(|active| { + active.replace(self.0.take()); + }); + } +} + +pub(crate) fn register_site(site_id: u64, function: &str, kind: &str, operation: &str) { + ACTIVE.with(|active| { + if let Some(state) = active.borrow_mut().as_mut() { + state.sites.insert( + site_id, + Site { + site_id, + function: function.into(), + kind: kind.into(), + operation: operation.into(), + observation_kind: "unobserved".into(), + }, + ); + } + }); +} + +/// The sole selection seam: called only for an existing plain, checked array +/// read. The caller must emit the full numeric guard (inline or runtime) and +/// boxed fallback. +pub(crate) fn select_numeric_array(site_id: u64, already_numeric: bool) -> Option { + ACTIVE.with(|active| { + let mut active = active.borrow_mut(); + let state = active.as_mut()?; + let observed = state.pending.remove(&site_id)?; + let site = state.sites.get(&site_id)?; + let reason = if observed.function != site.function || observed.kind != site.kind || observed.operation != site.operation { + Some("site_identity_mismatch") + } else if already_numeric { + Some("already_specialized") + } else { + None + }; + if let Some(reason) = reason { + state.decisions.push(rejected(&state.identity.module, &observed, reason)); + return None; + } + state.decisions.push(Decision { module: state.identity.module.clone(), site_id: Some(site_id), function: site.function.clone(), accepted: true, reason: "fresh_numeric_array_observation".into() }); + Some(NativeFactUse { + fact_id: format!("typed_feedback_replay:{}:{site_id}", state.identity.module), + kind: "typed_feedback_replay".into(), local_id: None, state: "consumed".into(), + detail: format!("fresh_numeric_array_observation;schema_version={};compiler={};source_hash={};hir_hash={};lowering_hash={};target={};advisory=true", SCHEMA_VERSION, state.compiler, state.identity.source_hash, state.identity.hir_hash, state.identity.lowering_hash, state.identity.target), + reason: None, + }) + }) +} + +pub(crate) fn finish_module(records: &mut Vec) { + ACTIVE.with(|active| { + if let Some(state) = active.borrow_mut().as_mut() { + for (id, site) in std::mem::take(&mut state.pending) { + let reason = if state.sites.contains_key(&id) { + "unsupported_site" + } else { + "unknown_site" + }; + state + .decisions + .push(rejected(&state.identity.module, &site, reason)); + } + state.decisions.sort(); + records.extend( + state + .decisions + .iter() + .filter(|d| !d.accepted) + .map(rejection_record), + ); + } + }); +} + +fn module_rejection(module: &str, reason: &str) -> Decision { + Decision { + module: module.into(), + site_id: None, + function: String::new(), + accepted: false, + reason: reason.into(), + } +} + +fn rejected(module: &str, site: &Site, reason: &str) -> Decision { + Decision { + module: module.into(), + site_id: Some(site.site_id), + function: site.function.clone(), + accepted: false, + reason: reason.into(), + } +} + +fn rejection_record(decision: &Decision) -> NativeRepRecord { + // Reuse the ordinary decision-record representation, with replay's own + // discriminator and facts (not a typed-clone decision). + let mut record = crate::native_value::typed_clone_rejection_record( + &decision.function, + "typed_feedback_profile", + &decision.reason, + Vec::new(), + ); + record.expr_kind = "TypedFeedbackReplayDecision".into(); + record.notes = vec![ + format!("typed_feedback_replay_rejected={}", decision.reason), + format!("profile_module={}", decision.module), + ]; + record.rejected_facts.push(NativeFactUse { + fact_id: format!( + "typed_feedback_replay:{}:{}", + decision.module, + decision + .site_id + .map(|id| id.to_string()) + .unwrap_or_else(|| "profile".into()) + ), + kind: "typed_feedback_replay".into(), + local_id: None, + state: "rejected".into(), + detail: decision.reason.clone(), + reason: None, + }); + record +} + +#[cfg(test)] +mod tests; + +/// Replay claims are valid only when tied to a consumed, fresh observation, +/// the runtime numeric-layout/bounds proof, and the emitted boxed side exit. +pub(crate) fn verify_records(records: &[NativeRepRecord], errors: &mut Vec) { + use crate::native_value::{BoundsState, BufferAccessMode, MaterializationReason}; + for record in records { + let claims_selection = record + .notes + .iter() + .any(|n| n.starts_with("typed_feedback_replay_selected=")); + let facts: Vec<_> = record + .consumed_facts + .iter() + .filter(|f| f.kind == "typed_feedback_replay") + .collect(); + if !claims_selection && facts.is_empty() { + continue; + } + let valid_fact = claims_selection + && facts.len() == 1 + && facts[0].state == "consumed" + && facts[0] + .detail + .starts_with("fresh_numeric_array_observation;"); + let valid_guard = record.expr_kind == "NumericArrayIndexGet" + && record.consumer == "js_array_numeric_get_f64_unboxed" + && record.native_rep == crate::native_value::NativeRep::F64 + && matches!(&record.bounds_state, Some(BoundsState::Guarded { guard_id }) if guard_id == NUMERIC_GUARD) + && record.access_mode == Some(BufferAccessMode::CheckedNative) + && record.consumed_facts.iter().any(|f| { + f.kind == "raw_f64_layout" && f.state == "consumed" && f.detail == NUMERIC_GUARD + }); + let valid_fallback = valid_fact + && records.iter().any(|fallback| { + fallback.function == record.function + && fallback.block_label != record.block_label + && fallback.consumer == ARRAY_FALLBACK + && fallback.access_mode == Some(BufferAccessMode::DynamicFallback) + && fallback.materialization_reason == Some(MaterializationReason::RuntimeApi) + && fallback.notes.contains(&format!( + "typed_feedback_replay_fallback={}", + facts[0].fact_id + )) + }); + if !valid_fact || !valid_guard || !valid_fallback { + errors.push(format!("{}:{} profile-directed specialization requires a consumed fresh replay fact, matching runtime guard, and explicit fallback/materialization record", record.function, record.block_label)); + } + } +} diff --git a/crates/perry-codegen/src/typed_feedback_profile/tests.rs b/crates/perry-codegen/src/typed_feedback_profile/tests.rs new file mode 100644 index 0000000000..39b6c49497 --- /dev/null +++ b/crates/perry-codegen/src/typed_feedback_profile/tests.rs @@ -0,0 +1,202 @@ +use super::*; + +fn identity() -> ModuleIdentity { + ModuleIdentity { + module: "main.ts".into(), + source_hash: "source".into(), + hir_hash: "hir".into(), + lowering_hash: "opts".into(), + target: "x86_64-unknown-linux-gnu".into(), + } +} +fn site() -> Site { + Site { + site_id: 42, + function: "read".into(), + kind: "array_element".into(), + operation: "array[index]".into(), + observation_kind: NUMERIC_ARRAY_ELEMENT.into(), + } +} +fn profile() -> Profile { + Profile { + schema_version: SCHEMA_VERSION, + compiler: "compiler".into(), + modules: vec![ModuleProfile { + identity: identity(), + sites: vec![site()], + }], + } +} +fn enter(profile: &Profile) -> Scope { + Scope(ACTIVE.with(|active| { + active.replace(Some(ModuleState::new( + "compiler", + Some(profile), + identity(), + ))) + })) +} +fn register() { + register_site(42, "read", "array_element", "array[index]"); +} + +#[test] +fn exact_freshness_and_duplicate_rejections() { + let cases: &[(&str, fn(&mut Profile))] = &[ + ("schema_mismatch", |p| p.schema_version += 1), + ("compiler_mismatch", |p| p.compiler.push('x')), + ("source_hash_mismatch", |p| { + p.modules[0].identity.source_hash.push('x') + }), + ("target_mismatch", |p| { + p.modules[0].identity.target.push('x') + }), + ("hir_hash_mismatch", |p| { + p.modules[0].identity.hir_hash.push('x') + }), + ("lowering_inputs_mismatch", |p| { + p.modules[0].identity.lowering_hash.push('x') + }), + ("unsupported_observation_kind", |p| { + p.modules[0].sites[0].observation_kind = "shape_address".into() + }), + ("duplicate_module", |p| p.modules.push(p.modules[0].clone())), + ("duplicate_site", |p| p.modules[0].sites.push(site())), + ]; + for (reason, mutate) in cases { + let mut profile = profile(); + mutate(&mut profile); + let _scope = enter(&profile); + register(); + assert!(select_numeric_array(42, false).is_none(), "{reason}"); + let mut records = Vec::new(); + finish_module(&mut records); + assert!(!records.is_empty(), "{reason}"); + assert!(records.iter().all(|r| r + .notes + .contains(&format!("typed_feedback_replay_rejected={reason}")))); + } +} + +#[test] +fn site_matching_requires_identity_and_supported_lowering() { + for reason in [ + "site_identity_mismatch", + "unknown_site", + "unsupported_site", + "already_specialized", + ] { + let mut profile = profile(); + if reason == "site_identity_mismatch" { + profile.modules[0].sites[0].function = "other".into(); + } + let _scope = enter(&profile); + if reason != "unknown_site" { + register(); + } + if matches!(reason, "site_identity_mismatch" | "already_specialized") { + assert!(select_numeric_array(42, reason == "already_specialized").is_none()); + } + let mut records = Vec::new(); + finish_module(&mut records); + assert_eq!(records[0].rejected_facts[0].detail, reason); + } +} + +#[test] +fn unknown_module_and_empty_stale_profile_are_explained() { + let session = Session::new("compiler".into(), Some(profile())); + assert_eq!(session.finish(None).unwrap()[0].reason, "unknown_module"); + let mut profile = profile(); + profile.modules.clear(); + profile.schema_version += 1; + let session = Session::new("compiler".into(), Some(profile)); + assert_eq!(session.finish(None).unwrap()[0].reason, "schema_mismatch"); +} + +#[test] +fn fresh_fact_is_consumed_once_and_scope_restores_on_unwind() { + let _scope = enter(&profile()); + register(); + let fact = select_numeric_array(42, false).unwrap(); + assert_eq!(fact.state, "consumed"); + assert!(fact.detail.contains("advisory=true")); + assert!(select_numeric_array(42, false).is_none()); + let result = std::panic::catch_unwind(|| { + let _inner = enter(&profile()); + panic!("scope sabotage"); + }); + assert!(result.is_err()); + assert!( + select_numeric_array(42, false).is_none(), + "outer consumed state must be restored" + ); +} + +fn valid_records() -> Vec { + use crate::native_value::{BoundsState, BufferAccessMode, MaterializationReason}; + let _scope = enter(&profile()); + register(); + let fact = select_numeric_array(42, false).unwrap(); + let mut fast = rejection_record(&rejected("main.ts", &site(), "unused")); + fast.expr_kind = "NumericArrayIndexGet".into(); + fast.consumer = "js_array_numeric_get_f64_unboxed".into(); + fast.native_rep = crate::native_value::NativeRep::F64; + fast.notes = vec!["typed_feedback_replay_selected=fresh_numeric_array_observation".into()]; + fast.bounds_state = Some(BoundsState::Guarded { + guard_id: NUMERIC_GUARD.into(), + }); + fast.access_mode = Some(BufferAccessMode::CheckedNative); + fast.consumed_facts = vec![ + fact.clone(), + NativeFactUse { + fact_id: "layout".into(), + kind: "raw_f64_layout".into(), + local_id: None, + state: "consumed".into(), + detail: NUMERIC_GUARD.into(), + reason: None, + }, + ]; + let mut fallback = fast.clone(); + fallback.block_label = "fallback".into(); + fallback.consumed_facts.clear(); + fallback.notes = vec![format!("typed_feedback_replay_fallback={}", fact.fact_id)]; + fallback.consumer = ARRAY_FALLBACK.into(); + fallback.access_mode = Some(BufferAccessMode::DynamicFallback); + fallback.materialization_reason = Some(MaterializationReason::RuntimeApi); + vec![fast, fallback] +} + +#[test] +fn verifier_rejects_replay_claims_without_each_required_proof() { + let mut errors = Vec::new(); + verify_records(&valid_records(), &mut errors); + assert!(errors.is_empty(), "{errors:?}"); + let sabotages: &[fn(&mut Vec)] = &[ + |r| r[0].consumed_facts.remove(0).state.clear(), + |r| r[0].consumed_facts[0].state = "rejected".into(), + |r| r[0].consumed_facts[0].detail = "stale".into(), + |r| r[0].notes.clear(), + |r| r[0].bounds_state = None, + |r| r[0].consumed_facts[1].detail = "wrong_guard".into(), + |r| { + r.pop(); + }, + |r| r[1].notes.clear(), + |r| r[1].function = "different_function".into(), + |r| r[1].materialization_reason = None, + |r| r[1].consumer = "wrong_fallback".into(), + ]; + for sabotage in sabotages { + let mut records = valid_records(); + sabotage(&mut records); + let mut errors = Vec::new(); + verify_records(&records, &mut errors); + assert!( + !errors.is_empty(), + "verifier accepted sabotaged replay record" + ); + } +} diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index 4644c57f92..5772017e36 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -7479,10 +7479,22 @@ fn tdz_numeric_const_read_is_not_constant_folded() { ]; let ir = String::from_utf8(compile_module(&fixture, empty_opts()).unwrap()).unwrap(); - assert!( - ir.contains("call i64 @js_box_get_bits(i64 "), + // The property under test is that the read goes through a BOX — which is + // what carries the TDZ check — not which helper spells it. #9721 added + // `js_box_get_bits_named`, which additionally passes the binding name so + // the thrown ReferenceError can identify it. Both variants perform the same + // check, so pinning only the older spelling made a strictly better error + // message look like a lost guard. + assert!( + ir.contains("call i64 @js_box_get_bits(i64 ") + || ir.contains("call i64 @js_box_get_bits_named(i64 "), "the pre-declaration read must retain the TDZ box check:\n{ir}" ); + // And the read must not be folded to the value the later `Let` installs. + assert!( + !ir.contains("double 4.200000e+01"), + "the pre-declaration read must NOT be constant-folded to its later value:\n{ir}" + ); } #[test] diff --git a/crates/perry-codegen/tests/typed_feedback.rs b/crates/perry-codegen/tests/typed_feedback.rs index f2a19fb0b9..d4ded4521a 100644 --- a/crates/perry-codegen/tests/typed_feedback.rs +++ b/crates/perry-codegen/tests/typed_feedback.rs @@ -1331,3 +1331,139 @@ fn typed_feedback_guards_computed_numeric_array_index_hot_path() { assert!(!ir.contains("call double @js_array_numeric_get_f64_unboxed")); assert!(ir.contains("load double")); } + +#[test] +fn profile_replay_selects_numeric_read_with_guard_fallback_and_deterministic_ir() { + use perry_codegen::typed_feedback_profile::{ModuleIdentity, Profile, Session}; + let _lock = env_lock(); + let _feedback = EnvVarGuard::set("PERRY_TYPED_FEEDBACK", None); + let _trace = EnvVarGuard::set("PERRY_TYPED_FEEDBACK_TRACE", None); + let dir = std::env::temp_dir().join(format!("perry-replay-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let source = module( + "replay.ts", + vec![param(1, "xs", Type::Array(Box::new(Type::Any)))], + Type::Any, + vec![Stmt::Return(Some(Expr::IndexGet { + object: Box::new(Expr::LocalGet(1)), + index: Box::new(Expr::Number(0.0)), + }))], + ); + let identity = ModuleIdentity { + module: source.name.clone(), + source_hash: "source".into(), + hir_hash: "hir".into(), + lowering_hash: "opts".into(), + target: "host".into(), + }; + let mut opts = empty_opts(); + opts.verify_native_regions = true; + let catalog = Session::new("compiler".into(), None); + let baseline = catalog + .compile_module(&source, opts.clone(), identity.clone()) + .unwrap(); + catalog.finish(Some(&dir.join("sites.json"))).unwrap(); + let mut profile: Profile = Session::read_profile(&dir.join("sites.json")).unwrap(); + let sites = &mut profile.modules[0].sites; + sites.retain(|site| site.kind == "array_element" && site.operation == "array[index]"); + assert!( + !sites.is_empty(), + "fixture must reach a supported array read" + ); + for site in sites { + site.observation_kind = "numeric_array_element".into(); + } + let _reps = EnvVarGuard::set("PERRY_NATIVE_REPS", Some("1")); + let _reps_dir = EnvVarGuard::set("PERRY_NATIVE_REPS_DIR", Some(dir.to_str().unwrap())); + let replay = Session::new("compiler".into(), Some(profile.clone())); + let selected = replay + .compile_module(&source, opts.clone(), identity.clone()) + .unwrap(); + let decisions = replay.finish(None).unwrap(); + assert!(decisions.iter().any(|d| d.accepted), "{decisions:?}"); + let ir = String::from_utf8(selected.clone()).unwrap(); + assert!(ir.contains("call i32 @js_typed_feedback_numeric_array_index_get_guard")); + assert!(ir.contains("call double @js_typed_feedback_array_index_get_fallback_boxed")); + assert!(ir.contains("br i1")); + assert_ne!(baseline, selected); + let replay2 = Session::new("compiler".into(), Some(profile.clone())); + assert_eq!( + selected, + replay2 + .compile_module(&source, opts.clone(), identity.clone()) + .unwrap() + ); + assert_eq!(decisions, replay2.finish(None).unwrap()); + // Every well-formed mismatch must leave lowering byte-for-byte identical. + let cases: &[(&str, fn(&mut Profile))] = &[ + ("source_hash_mismatch", |p| { + p.modules[0].identity.source_hash.push('x') + }), + ("hir_hash_mismatch", |p| { + p.modules[0].identity.hir_hash.push('x') + }), + ("lowering_inputs_mismatch", |p| { + p.modules[0].identity.lowering_hash.push('x') + }), + ("target_mismatch", |p| { + p.modules[0].identity.target.push('x') + }), + ("compiler_mismatch", |p| p.compiler.push('x')), + ("schema_mismatch", |p| p.schema_version += 1), + ("unknown_module", |p| p.modules[0].identity.module.push('x')), + ("unknown_site", |p| { + for s in &mut p.modules[0].sites { + s.site_id += 1000; + } + }), + ("site_identity_mismatch", |p| { + for s in &mut p.modules[0].sites { + s.function.push('x'); + } + }), + ("unsupported_observation_kind", |p| { + for s in &mut p.modules[0].sites { + s.observation_kind = "method_address".into(); + } + }), + ]; + for (reason, mutate) in cases { + let mut stale_profile = profile.clone(); + mutate(&mut stale_profile); + let stale = Session::new("compiler".into(), Some(stale_profile)); + assert_eq!( + baseline, + stale + .compile_module(&source, opts.clone(), identity.clone()) + .unwrap(), + "{reason}" + ); + let rejected = stale.finish(None).unwrap(); + assert!(!rejected.is_empty(), "{reason}"); + assert!( + rejected.iter().all(|d| !d.accepted && d.reason == *reason), + "{rejected:?}" + ); + } + let artifacts: Vec = std::fs::read_dir(&dir) + .unwrap() + .filter_map(|entry| { + let path = entry.unwrap().path(); + (path.extension().and_then(|s| s.to_str()) == Some("json") + && path.file_name().unwrap() != "sites.json") + .then(|| serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap()) + }) + .collect(); + assert!(artifacts + .iter() + .any(|a| a["records"] + .as_array() + .unwrap() + .iter() + .any(|r| r["consumed_facts"] + .as_array() + .unwrap() + .iter() + .any(|f| f["kind"] == "typed_feedback_replay")))); + std::fs::remove_dir_all(&dir).unwrap(); +} diff --git a/crates/perry-runtime/Cargo.toml b/crates/perry-runtime/Cargo.toml index bf0d85eeba..5cd54b43f5 100644 --- a/crates/perry-runtime/Cargo.toml +++ b/crates/perry-runtime/Cargo.toml @@ -49,6 +49,14 @@ keepalive-anchors = [] # leaves it off and falls back to the system allocator. The #6882 VM-tag # retag rides the same gate (it only exists to label mimalloc's mappings). alloc-mimalloc = ["dep:mimalloc", "dep:libmimalloc-sys"] + +# `PERRY_ALLOC_CENSUS` — a sampling profiler for the Rust heap +# (`alloc_census`). OFF by default and compiled out entirely: the census wraps +# the `#[global_allocator]`, and `gc_malloc` runs ~1M times/sec, so even the +# one relaxed load its disabled state costs has no business in a shipped +# build. Enable it to attribute native-heap bytes to call sites; see the +# module docs. +alloc-census = [] # DIAGNOSTIC ONLY — never in `default`, never in a shipping build. # # Builds mimalloc in secure + debug mode to hunt heap corruption that is diff --git a/crates/perry-runtime/src/alloc_census.rs b/crates/perry-runtime/src/alloc_census.rs new file mode 100644 index 0000000000..eb13b010c7 --- /dev/null +++ b/crates/perry-runtime/src/alloc_census.rs @@ -0,0 +1,433 @@ +//! `PERRY_ALLOC_CENSUS` — a sampling profiler for the *Rust* heap. +//! +//! The GC census (`gc::census`) accounts for the arena and the side tables. +//! On the compiled claude-code TUI those two together explain ~115 MB of a +//! 300 MB idle footprint and ~430 MB of a 2 GB peak: everything else is +//! ordinary Rust-heap memory allocated through the `#[global_allocator]`, +//! which nothing in the runtime could attribute. This module wraps that +//! allocator so a run can answer "which call site owns these dirty pages". +//! +//! Off unless `PERRY_ALLOC_CENSUS=` is set, and the enable flag is read +//! once at `gc_init` rather than per allocation. +//! +//! Two kinds of number are recorded: +//! * exact totals and a power-of-two size-class histogram (allocated bytes, +//! freed bytes, live bytes) — every allocation counts; +//! * sampled call sites — one sample per `PERRY_ALLOC_CENSUS_INTERVAL` +//! bytes allocated (default 1 MiB). A sample records raw return addresses +//! (`backtrace(3)`, no symbolication, no allocation) and the sampled +//! pointer, so a later `dealloc` of that pointer can subtract it again. +//! What remains at dump time is *live* memory attributed to a call site. +//! +//! The dump is one JSON document per signal, plus the main image's load +//! address so the frames can be symbolised offline with `atos -o -l`. + +use std::alloc::{GlobalAlloc, Layout}; +use std::cell::Cell; +use std::collections::HashMap; +use std::sync::atomic::{AtomicI64, AtomicU64, AtomicU8, Ordering}; +use std::sync::Mutex; + +const CLASSES: usize = 48; +const FRAMES: usize = 20; +/// Presence filter: one saturating counter per 16-byte-aligned pointer hash. +/// A `dealloc` only takes the site lock when its slot is non-zero, so the +/// unsampled path costs one relaxed byte load. +const FILTER_BITS: usize = 20; +const FILTER_LEN: usize = 1 << FILTER_BITS; + +static ENABLED: AtomicU8 = AtomicU8::new(0); +static SAMPLE_INTERVAL: AtomicU64 = AtomicU64::new(1 << 20); + +static TOTAL_ALLOC_BYTES: AtomicU64 = AtomicU64::new(0); +static TOTAL_ALLOC_COUNT: AtomicU64 = AtomicU64::new(0); +static TOTAL_FREE_BYTES: AtomicU64 = AtomicU64::new(0); +static TOTAL_FREE_COUNT: AtomicU64 = AtomicU64::new(0); +static LIVE_BYTES: AtomicI64 = AtomicI64::new(0); +static PEAK_LIVE_BYTES: AtomicI64 = AtomicI64::new(0); + +#[allow(clippy::declare_interior_mutable_const)] +const ZERO_U64: AtomicU64 = AtomicU64::new(0); +#[allow(clippy::declare_interior_mutable_const)] +const ZERO_I64: AtomicI64 = AtomicI64::new(0); +#[allow(clippy::declare_interior_mutable_const)] +const ZERO_U8: AtomicU8 = AtomicU8::new(0); + +static CLASS_ALLOC_BYTES: [AtomicU64; CLASSES] = [ZERO_U64; CLASSES]; +static CLASS_ALLOC_COUNT: [AtomicU64; CLASSES] = [ZERO_U64; CLASSES]; +static CLASS_LIVE_BYTES: [AtomicI64; CLASSES] = [ZERO_I64; CLASSES]; +static FILTER: [AtomicU8; FILTER_LEN] = [ZERO_U8; FILTER_LEN]; + +struct SiteStats { + alloc_bytes: u64, + alloc_count: u64, + live_bytes: i64, + live_count: i64, +} + +#[derive(Default)] +struct Sites { + /// frames -> site id + ids: HashMap<[usize; FRAMES], u32>, + /// site id -> frames (for the dump) + frames: Vec<[usize; FRAMES]>, + stats: Vec, + /// sampled live pointers -> (site id, size) + live: HashMap, +} + +static SITES: Mutex> = Mutex::new(None); + +crate::perry_thread_local! { + /// Bytes still to allocate before the next sample. Const-initialised so + /// the TLS access itself never allocates. + static CREDIT: Cell = const { Cell::new(1 << 20) }; + /// Re-entrancy guard: the sampler's own allocations are not sampled. + static IN_SAMPLER: Cell = const { Cell::new(false) }; +} + +unsafe extern "C" { + fn backtrace(array: *mut *mut core::ffi::c_void, size: core::ffi::c_int) -> core::ffi::c_int; +} + +#[inline] +fn class_of(size: usize) -> usize { + (usize::BITS - size.max(1).leading_zeros()) as usize % CLASSES +} + +#[inline] +fn filter_slot(ptr: usize) -> usize { + // The low four bits are always zero for mimalloc's alignment; mix the rest. + let h = (ptr >> 4).wrapping_mul(0x9E37_79B9_7F4A_7C15); + (h >> (64 - FILTER_BITS)) & (FILTER_LEN - 1) +} + +#[inline] +fn enabled() -> bool { + match ENABLED.load(Ordering::Relaxed) { + 2 => true, + 1 => false, + _ => init_from_env(), + } +} + +/// Read the switch with `getenv(3)` rather than `std::env::var`, so the very +/// first allocation of the process can decide: `std::env::var` allocates, and +/// an allocator that allocates to answer "am I recording?" recurses. Startup +/// is exactly where the interesting retention is, so waiting for `gc_init` +/// would leave the biggest table unattributed. +#[cold] +fn init_from_env() -> bool { + unsafe extern "C" { + fn getenv(name: *const core::ffi::c_char) -> *const core::ffi::c_char; + } + const NAME: &[u8] = b"PERRY_ALLOC_CENSUS\0"; + const INTERVAL: &[u8] = b"PERRY_ALLOC_CENSUS_INTERVAL\0"; + // SAFETY: both names are NUL-terminated literals; `getenv` returns a + // borrowed pointer into the environment block and allocates nothing. + let on = unsafe { !getenv(NAME.as_ptr() as *const core::ffi::c_char).is_null() }; + if on { + // SAFETY: as above; the value is a NUL-terminated C string. + let raw = unsafe { getenv(INTERVAL.as_ptr() as *const core::ffi::c_char) }; + if !raw.is_null() { + let mut n: u64 = 0; + let mut i = 0isize; + loop { + // SAFETY: walking a NUL-terminated C string. + let c = unsafe { *raw.offset(i) } as u8; + if !c.is_ascii_digit() { + break; + } + n = n.saturating_mul(10).saturating_add((c - b'0') as u64); + i += 1; + } + if n >= 4096 { + SAMPLE_INTERVAL.store(n, Ordering::Relaxed); + let _ = CREDIT.try_with(|c| c.set(n as i64)); + } + } + } + ENABLED.store(if on { 2 } else { 1 }, Ordering::Relaxed); + on +} + +/// Called from `gc_init` so a run that never allocates before then still +/// reports a decided state; the allocator decides for itself otherwise. +pub(crate) fn alloc_census_init() { + let _ = enabled(); +} + +pub fn alloc_census_path() -> Option { + std::env::var("PERRY_ALLOC_CENSUS").ok() +} + +#[inline] +fn record_alloc(ptr: *mut u8, size: usize) { + let c = class_of(size); + CLASS_ALLOC_BYTES[c].fetch_add(size as u64, Ordering::Relaxed); + CLASS_ALLOC_COUNT[c].fetch_add(1, Ordering::Relaxed); + CLASS_LIVE_BYTES[c].fetch_add(size as i64, Ordering::Relaxed); + TOTAL_ALLOC_BYTES.fetch_add(size as u64, Ordering::Relaxed); + TOTAL_ALLOC_COUNT.fetch_add(1, Ordering::Relaxed); + let live = LIVE_BYTES.fetch_add(size as i64, Ordering::Relaxed) + size as i64; + PEAK_LIVE_BYTES.fetch_max(live, Ordering::Relaxed); + let due = CREDIT + .try_with(|c| { + let v = c.get() - size as i64; + if v <= 0 { + c.set(SAMPLE_INTERVAL.load(Ordering::Relaxed) as i64); + true + } else { + c.set(v); + false + } + }) + .unwrap_or(false); + if due { + sample(ptr, size); + } +} + +#[cold] +fn sample(ptr: *mut u8, size: usize) { + if IN_SAMPLER.try_with(Cell::get).unwrap_or(true) { + return; + } + let _ = IN_SAMPLER.try_with(|g| g.set(true)); + let mut raw = [core::ptr::null_mut::(); FRAMES + 4]; + // SAFETY: `backtrace(3)` fills a caller-owned array of at most `len` + // frame pointers and returns how many it wrote. + let n = unsafe { backtrace(raw.as_mut_ptr(), (FRAMES + 4) as core::ffi::c_int) }; + let mut frames = [0usize; FRAMES]; + // Drop the profiler's own frames (this fn + record_alloc + alloc). + let skip = 3usize; + let n = n.max(0) as usize; + for i in 0..FRAMES { + frames[i] = if i + skip < n { + raw[i + skip] as usize + } else { + 0 + }; + } + if let Ok(mut guard) = SITES.lock() { + { + let s = guard.get_or_insert_with(Sites::default); + let next = s.frames.len() as u32; + let id = *s.ids.entry(frames).or_insert(next); + if id == next { + s.frames.push(frames); + s.stats.push(SiteStats { + alloc_bytes: 0, + alloc_count: 0, + live_bytes: 0, + live_count: 0, + }); + } + let st = &mut s.stats[id as usize]; + st.alloc_bytes += size as u64; + st.alloc_count += 1; + st.live_bytes += size as i64; + st.live_count += 1; + s.live.insert(ptr as usize, (id, size)); + } + } + let slot = filter_slot(ptr as usize); + let cur = FILTER[slot].load(Ordering::Relaxed); + if cur < u8::MAX { + FILTER[slot].store(cur + 1, Ordering::Relaxed); + } + let _ = IN_SAMPLER.try_with(|g| g.set(false)); +} + +#[inline] +fn record_free(ptr: *mut u8, size: usize) { + let c = class_of(size); + CLASS_LIVE_BYTES[c].fetch_sub(size as i64, Ordering::Relaxed); + TOTAL_FREE_BYTES.fetch_add(size as u64, Ordering::Relaxed); + TOTAL_FREE_COUNT.fetch_add(1, Ordering::Relaxed); + LIVE_BYTES.fetch_sub(size as i64, Ordering::Relaxed); + let slot = filter_slot(ptr as usize); + if FILTER[slot].load(Ordering::Relaxed) == 0 { + return; + } + unsample(ptr, slot); +} + +#[cold] +fn unsample(ptr: *mut u8, slot: usize) { + if IN_SAMPLER.try_with(Cell::get).unwrap_or(true) { + return; + } + let _ = IN_SAMPLER.try_with(|g| g.set(true)); + if let Ok(mut guard) = SITES.lock() { + if let Some(s) = guard.as_mut() { + if let Some((id, size)) = s.live.remove(&(ptr as usize)) { + let st = &mut s.stats[id as usize]; + st.live_bytes -= size as i64; + st.live_count -= 1; + let cur = FILTER[slot].load(Ordering::Relaxed); + if cur > 0 && cur < u8::MAX { + FILTER[slot].store(cur - 1, Ordering::Relaxed); + } + } + } + } + let _ = IN_SAMPLER.try_with(|g| g.set(false)); +} + +/// The `#[global_allocator]` wrapper. When the census is off (the only state +/// a shipped program is ever in unless the env var is set) every method is +/// the inner allocator's plus one relaxed byte load. +pub struct CensusAlloc(pub A); + +unsafe impl GlobalAlloc for CensusAlloc { + #[inline] + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let p = unsafe { self.0.alloc(layout) }; + if enabled() && !p.is_null() { + record_alloc(p, layout.size()); + } + p + } + #[inline] + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + let p = unsafe { self.0.alloc_zeroed(layout) }; + if enabled() && !p.is_null() { + record_alloc(p, layout.size()); + } + p + } + #[inline] + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + if enabled() && !ptr.is_null() { + record_free(ptr, layout.size()); + } + unsafe { self.0.dealloc(ptr, layout) } + } + #[inline] + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + if enabled() && !ptr.is_null() { + record_free(ptr, layout.size()); + } + let p = unsafe { self.0.realloc(ptr, layout, new_size) }; + if enabled() && !p.is_null() { + record_alloc(p, new_size); + } + p + } +} + +fn main_image_load_address() -> usize { + unsafe extern "C" { + fn _dyld_get_image_header(index: u32) -> *const core::ffi::c_void; + } + // SAFETY: image 0 is the main executable; the call takes no pointer. + (unsafe { _dyld_get_image_header(0) }) as usize +} + +/// Append one JSON document to `PERRY_ALLOC_CENSUS`. +pub(crate) fn alloc_census_dump(label: &str) { + if !enabled() { + return; + } + let Some(path) = alloc_census_path() else { + return; + }; + let _ = IN_SAMPLER.try_with(|g| g.set(true)); + let mut out = String::with_capacity(1 << 16); + out.push_str("{\"perry_alloc_census\":1,\"label\":\""); + out.push_str(label); + out.push_str("\",\"load_address\":"); + out.push_str(&main_image_load_address().to_string()); + out.push_str(",\"sample_interval\":"); + out.push_str(&SAMPLE_INTERVAL.load(Ordering::Relaxed).to_string()); + out.push_str(",\"totals\":{\"alloc_bytes\":"); + out.push_str(&TOTAL_ALLOC_BYTES.load(Ordering::Relaxed).to_string()); + out.push_str(",\"alloc_count\":"); + out.push_str(&TOTAL_ALLOC_COUNT.load(Ordering::Relaxed).to_string()); + out.push_str(",\"free_bytes\":"); + out.push_str(&TOTAL_FREE_BYTES.load(Ordering::Relaxed).to_string()); + out.push_str(",\"free_count\":"); + out.push_str(&TOTAL_FREE_COUNT.load(Ordering::Relaxed).to_string()); + out.push_str(",\"live_bytes\":"); + out.push_str(&LIVE_BYTES.load(Ordering::Relaxed).to_string()); + out.push_str(",\"peak_live_bytes\":"); + out.push_str(&PEAK_LIVE_BYTES.load(Ordering::Relaxed).to_string()); + out.push_str("},\"classes\":["); + for c in 0..CLASSES { + let ab = CLASS_ALLOC_BYTES[c].load(Ordering::Relaxed); + let lb = CLASS_LIVE_BYTES[c].load(Ordering::Relaxed); + if ab == 0 && lb == 0 { + continue; + } + if !out.ends_with('[') { + out.push(','); + } + out.push_str("{\"class\":"); + out.push_str(&c.to_string()); + out.push_str(",\"alloc_bytes\":"); + out.push_str(&ab.to_string()); + out.push_str(",\"alloc_count\":"); + out.push_str(&CLASS_ALLOC_COUNT[c].load(Ordering::Relaxed).to_string()); + out.push_str(",\"live_bytes\":"); + out.push_str(&lb.to_string()); + out.push('}'); + } + out.push_str("],\"sites\":["); + if let Ok(guard) = SITES.lock() { + if let Some(s) = guard.as_ref() { + let mut order: Vec = (0..s.stats.len()).collect(); + order.sort_by_key(|&i| -(s.stats[i].live_bytes.max(s.stats[i].alloc_bytes as i64))); + for i in order.into_iter().take(400) { + let st = &s.stats[i]; + if !out.ends_with('[') { + out.push(','); + } + out.push_str("{\"alloc_bytes\":"); + out.push_str(&st.alloc_bytes.to_string()); + out.push_str(",\"alloc_count\":"); + out.push_str(&st.alloc_count.to_string()); + out.push_str(",\"live_bytes\":"); + out.push_str(&st.live_bytes.to_string()); + out.push_str(",\"live_count\":"); + out.push_str(&st.live_count.to_string()); + out.push_str(",\"frames\":["); + for (k, f) in s.frames[i].iter().enumerate() { + if *f == 0 { + break; + } + if k > 0 { + out.push(','); + } + out.push_str(&f.to_string()); + } + out.push_str("]}"); + } + } + } + out.push_str("]}\n"); + use std::io::Write; + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + { + let _ = f.write_all(out.as_bytes()); + let _ = f.flush(); + } + let _ = IN_SAMPLER.try_with(|g| g.set(false)); +} + +/// mimalloc's own view of the process, appended to the run's stderr. Answers +/// "is this memory in use, or free and unpurged" — the census above cannot, +/// because it only sees what the program asked for. +pub(crate) fn mimalloc_stats_print() { + #[cfg(all(target_pointer_width = "64", feature = "alloc-mimalloc"))] + { + unsafe extern "C" { + fn mi_stats_print(out: *mut core::ffi::c_void); + } + // SAFETY: mimalloc's own reporting entry; a null sink means stderr. + unsafe { mi_stats_print(core::ptr::null_mut()) }; + } +} diff --git a/crates/perry-runtime/src/arena/mod.rs b/crates/perry-runtime/src/arena/mod.rs index 7c263b7437..9ba5401b19 100644 --- a/crates/perry-runtime/src/arena/mod.rs +++ b/crates/perry-runtime/src/arena/mod.rs @@ -140,13 +140,14 @@ pub(crate) use stats::{old_gen_in_use_bytes_recomputed, old_gen_in_use_bytes_res pub(crate) use page_meta::{ arena_header_is_object_start, classify_heap_generation, classify_heap_space, classify_heap_space_in_range, generation_page_for_addr, materialize_all_promoted_page_runs, - old_arena_page_index_remove_object, old_arena_source_blocks_for_pages, - old_arena_walk_objects_on_pages, old_object_page_overlaps, old_page_account_dirty_slot, - old_page_account_dirty_slots, old_page_account_promoted_object, old_page_account_swept_object, - old_page_clear_dirty, old_page_mark_dirty, old_page_meta_snapshot, old_page_summary, - old_pages_begin_gc_cycle, old_pages_reset_sweep_accounting, record_arena_object_start, - unregister_old_object_pages, HeapGeneration, HeapSpace, OldArenaPageObjectCursor, - OldArenaSourceBlockSelection, OldPageMeta, OldPageSummary, + old_arena_block_range_index, old_arena_block_ranges, old_arena_page_index_remove_object, + old_arena_source_blocks_for_pages, old_arena_walk_objects_on_pages, old_object_page_overlaps, + old_page_account_dirty_slot, old_page_account_dirty_slots, old_page_account_promoted_object, + old_page_account_swept_object, old_page_clear_dirty, old_page_mark_dirty, + old_page_meta_snapshot, old_page_summary, old_pages_begin_gc_cycle, + old_pages_reset_sweep_accounting, record_arena_object_start, unregister_old_object_pages, + HeapGeneration, HeapSpace, OldArenaPageObjectCursor, OldArenaSourceBlockSelection, OldPageMeta, + OldPageSummary, }; #[cfg(test)] diff --git a/crates/perry-runtime/src/arena/page_meta.rs b/crates/perry-runtime/src/arena/page_meta.rs index e1116911f4..bc6984dff3 100644 --- a/crates/perry-runtime/src/arena/page_meta.rs +++ b/crates/perry-runtime/src/arena/page_meta.rs @@ -1538,6 +1538,45 @@ fn normalize_dirty_slots_for_epoch(mut page_meta: OldPageMeta, current_epoch: u6 page_meta } +/// Address ranges of the live old-generation blocks, as +/// `(base, end_exclusive, global_block_index, size)` sorted by base. +/// +/// Old-gen memory is released a BLOCK at a time (`old_arena_reclaim_*`), so a +/// pass that wants its bytes back has to reason in blocks. `OldPageMeta` is +/// page-granular and carries no block identity, which is why #9772's selection +/// could predict 44 MB of "releasable block bytes" from page granules and +/// release nothing. +pub(crate) fn old_arena_block_ranges() -> Vec<(usize, usize, usize, usize)> { + let old_block_start = longlived_end(); + OLD_ARENA.with(|arena| { + let arena = unsafe { &*arena.get() }; + let mut out: Vec<(usize, usize, usize, usize)> = arena + .blocks + .iter() + .enumerate() + .filter_map(|(i, block)| { + if block.data.is_null() || block.size == 0 { + return None; + } + let base = block.data as usize; + Some((base, base + block.size, old_block_start + i, block.size)) + }) + .collect(); + out.sort_unstable_by_key(|r| r.0); + out + }) +} + +/// Index into [`old_arena_block_ranges`] output for the block containing +/// `addr`, or `None` when the address is not in a live old-gen block. +pub(crate) fn old_arena_block_range_index( + ranges: &[(usize, usize, usize, usize)], + addr: usize, +) -> Option { + let idx = ranges.partition_point(|r| r.0 <= addr).checked_sub(1)?; + (addr < ranges[idx].1).then_some(idx) +} + pub(crate) fn old_arena_source_blocks_for_pages( selected_pages: &crate::fast_hash::PtrHashSet, ) -> OldArenaSourceBlockSelection { @@ -1896,3 +1935,31 @@ pub(crate) fn page_meta_census() -> Vec { }); rows } + +#[cfg(test)] +mod block_range_tests { + use super::old_arena_block_range_index; + + /// `old_arena_block_range_index` is the whole reason #9772's selection can + /// group pages by block, so it gets a test that can fail: gaps between + /// blocks must not be attributed to the block below them. + #[test] + fn block_range_lookup_respects_gaps_and_ends() { + // Two 1 MiB blocks with a 1 MiB hole between them. + let ranges = vec![ + (0x1000_0000, 0x1010_0000, 7, 0x10_0000), + (0x1020_0000, 0x1030_0000, 9, 0x10_0000), + ]; + assert_eq!(old_arena_block_range_index(&ranges, 0x1000_0000), Some(0)); + assert_eq!(old_arena_block_range_index(&ranges, 0x100F_FFFF), Some(0)); + // One past the end of block 0 is the gap, not block 0. + assert_eq!(old_arena_block_range_index(&ranges, 0x1010_0000), None); + assert_eq!(old_arena_block_range_index(&ranges, 0x1018_0000), None); + assert_eq!(old_arena_block_range_index(&ranges, 0x1020_0000), Some(1)); + assert_eq!(old_arena_block_range_index(&ranges, 0x102F_FFFF), Some(1)); + // Above every block, and below every block. + assert_eq!(old_arena_block_range_index(&ranges, 0x1030_0000), None); + assert_eq!(old_arena_block_range_index(&ranges, 0x0FFF_FFFF), None); + assert_eq!(old_arena_block_range_index(&[], 0x1000_0000), None); + } +} diff --git a/crates/perry-runtime/src/arena/reset.rs b/crates/perry-runtime/src/arena/reset.rs index 5414a6cebb..a5ba37fa86 100644 --- a/crates/perry-runtime/src/arena/reset.rs +++ b/crates/perry-runtime/src/arena/reset.rs @@ -533,6 +533,32 @@ enum GeneralResetSubphase { Done, } +/// Why a general-arena block was not released this cycle. Empty eden capacity +/// is the largest single piece of arena slack on the compiled claude-code TUI +/// (51-56 blocks holding 1-9 MB of objects), and `PERRY_GC_DIAG=1` could say +/// only that the blocks were still there. One counter per guard says which +/// guard is actually holding them. +#[derive(Clone, Copy, Default)] +struct GeneralDeallocDiag { + examined: usize, + no_snapshot: usize, + snapshot_moved: usize, + keep_window: usize, + has_live: usize, + in_use: usize, + aging: usize, + released: usize, +} + +enum DeallocReject { + NoSnapshot, + SnapshotMoved, + KeepWindow, + HasLive, + InUse, + Aging, +} + pub(crate) struct ArenaResetEmptyBlocksState { block_has_live: Vec, snapshots: Vec, @@ -542,6 +568,7 @@ pub(crate) struct ArenaResetEmptyBlocksState { reset_ranges: Vec<(usize, usize, usize)>, removed_ranges: Vec<(usize, usize)>, stats: ArenaResetStats, + diag: GeneralDeallocDiag, } impl ArenaResetEmptyBlocksState { @@ -555,6 +582,7 @@ impl ArenaResetEmptyBlocksState { reset_ranges: Vec::new(), removed_ranges: Vec::new(), stats: ArenaResetStats::default(), + diag: GeneralDeallocDiag::default(), } } @@ -649,38 +677,58 @@ impl ArenaResetEmptyBlocksState { &mut self, block_idx: usize, ) -> Option<(usize, usize, ArenaBlockRelease)> { + self.diag.examined += 1; + let outcome = self.dealloc_block_inner(block_idx); + match &outcome { + Ok(_) => self.diag.released += 1, + Err(DeallocReject::NoSnapshot) => self.diag.no_snapshot += 1, + Err(DeallocReject::SnapshotMoved) => self.diag.snapshot_moved += 1, + Err(DeallocReject::KeepWindow) => self.diag.keep_window += 1, + Err(DeallocReject::HasLive) => self.diag.has_live += 1, + Err(DeallocReject::InUse) => self.diag.in_use += 1, + Err(DeallocReject::Aging) => self.diag.aging += 1, + } + outcome.ok() + } + + fn dealloc_block_inner( + &mut self, + block_idx: usize, + ) -> Result<(usize, usize, ArenaBlockRelease), DeallocReject> { let snapshot = self.snapshots.get(block_idx).copied().unwrap_or_default(); if snapshot.data == 0 { - return None; + return Err(DeallocReject::NoSnapshot); } ARENA.with(|arena| unsafe { let arena = &mut *arena.get(); let current = arena.current; let keep_low = current.saturating_sub(4); - let block = arena.blocks.get_mut(block_idx)?; + let Some(block) = arena.blocks.get_mut(block_idx) else { + return Err(DeallocReject::NoSnapshot); + }; if block.data.is_null() || block.data as usize != snapshot.data || block.size != snapshot.size { - return None; + return Err(DeallocReject::SnapshotMoved); } if block_idx == current || (block_idx >= keep_low && block_idx <= current) { block.dead_cycles = 0; - return None; + return Err(DeallocReject::KeepWindow); } if self.block_has_live.get(block_idx).copied().unwrap_or(false) { block.dead_cycles = 0; - return None; + return Err(DeallocReject::HasLive); } if block.offset != 0 { block.dead_cycles = 0; - return None; + return Err(DeallocReject::InUse); } block.dead_cycles = block.dead_cycles.saturating_add(1); if block.dead_cycles < GENERAL_DEALLOC_DEAD_CYCLES { - return None; + return Err(DeallocReject::Aging); } let base = block.data as usize; @@ -694,7 +742,7 @@ impl ArenaResetEmptyBlocksState { block.offset = 0; block.dead_cycles = 0; self.changed = true; - Some((base, size, release)) + Ok((base, size, release)) }) } @@ -717,6 +765,21 @@ impl ArenaResetEmptyBlocksState { ..self.stats }; + if crate::gc::gc_diag_enabled() && self.diag.examined > 0 { + let d = self.diag; + eprintln!( + "[gc-general-reclaim] examined={} released={} rejected: no_snapshot={} \ + snapshot_moved={} keep_window={} has_live={} in_use={} aging={}", + d.examined, + d.released, + d.no_snapshot, + d.snapshot_moved, + d.keep_window, + d.has_live, + d.in_use, + d.aging, + ); + } if !self.changed { return; } @@ -1011,6 +1074,20 @@ impl SurvivorArenaReclaimDeadBlocksState { } } +/// #9772: a compaction that evacuates pages and then releases nothing is +/// indistinguishable, from the outside, from one that had nothing to do. These +/// count why each TARGETED old block survived its reclaim, so an unproductive +/// pass names its own obstacle instead of costing a pause silently. +#[derive(Clone, Copy, Default)] +struct OldReclaimDiag { + targeted: usize, + no_snapshot: usize, + snapshot_moved: usize, + has_live: usize, + released: usize, + released_bytes: usize, +} + pub(crate) struct OldArenaReclaimDeadBlocksState { block_has_live: Vec, snapshots: Vec, @@ -1019,6 +1096,8 @@ pub(crate) struct OldArenaReclaimDeadBlocksState { subphase: RegionReclaimSubphase, changed: bool, stats: ArenaResetStats, + diag: OldReclaimDiag, + targeted_mode: bool, } impl OldArenaReclaimDeadBlocksState { @@ -1042,11 +1121,13 @@ impl OldArenaReclaimDeadBlocksState { Self { block_has_live: block_has_live.to_vec(), snapshots: snapshots.to_vec(), + targeted_mode: selected_old_blocks.is_some(), selected_old_blocks, cursor: 0, subphase: RegionReclaimSubphase::Reclaim, changed: false, stats: ArenaResetStats::default(), + diag: OldReclaimDiag::default(), } } @@ -1067,6 +1148,22 @@ impl OldArenaReclaimDeadBlocksState { } RegionReclaimSubphase::Finish => { self.finish(); + if crate::gc::gc_diag_enabled() && self.targeted_mode { + let d = self.diag; + eprintln!( + "[gc-old-block-reclaim] targeted={} released={} released_bytes={} \ + kept: has_live={} snapshot_moved={} no_snapshot={} \ + pooled_bytes={} deallocated_bytes={}", + d.targeted, + d.released, + d.released_bytes, + d.has_live, + d.snapshot_moved, + d.no_snapshot, + self.stats.pooled_bytes, + self.stats.deallocated_bytes, + ); + } OLD_GEN_RECLAIM_REUSABLE_BYTES .with(|bytes| bytes.set(self.stats.reusable_bytes)); OLD_GEN_RECLAIM_POOLED_BYTES.with(|bytes| bytes.set(self.stats.pooled_bytes)); @@ -1096,15 +1193,22 @@ impl OldArenaReclaimDeadBlocksState { return; } + self.diag.targeted += 1; let snapshot = self.snapshots.get(block_idx).copied().unwrap_or_default(); if snapshot.data == 0 { + self.diag.no_snapshot += 1; return; } + let diag = &mut self.diag; + let block_has_live = &self.block_has_live; + let changed = &mut self.changed; + let stats = &mut self.stats; OLD_ARENA.with(|arena| unsafe { let arena = &mut *arena.get(); let original_current = arena.current; let Some(block) = arena.blocks.get_mut(local_idx) else { + diag.no_snapshot += 1; return; }; if block.data.is_null() @@ -1112,12 +1216,16 @@ impl OldArenaReclaimDeadBlocksState { || block.size != snapshot.size || block.offset != snapshot.offset { + diag.snapshot_moved += 1; return; } - if self.block_has_live.get(block_idx).copied().unwrap_or(false) { + if block_has_live.get(block_idx).copied().unwrap_or(false) { + diag.has_live += 1; block.dead_cycles = 0; return; } + diag.released += 1; + diag.released_bytes += block.size; let base = block.data as usize; let size = block.size; @@ -1131,16 +1239,16 @@ impl OldArenaReclaimDeadBlocksState { crate::gc::old_free_filter_range(base, size); if used != 0 { - self.stats.reset_blocks = self.stats.reset_blocks.saturating_add(1); + stats.reset_blocks = stats.reset_blocks.saturating_add(1); } block.clear_object_starts(); block.offset = 0; block.dead_cycles = 0; old_gen_in_use_bytes_sub(used); - self.changed = true; + *changed = true; if local_idx == original_current { - self.stats.reusable_bytes = self.stats.reusable_bytes.saturating_add(used); + stats.reusable_bytes = stats.reusable_bytes.saturating_add(used); return; } @@ -1152,7 +1260,7 @@ impl OldArenaReclaimDeadBlocksState { block.object_starts = Box::new([]); block.offset = 0; block.dead_cycles = 0; - self.stats.record_block_release(size, release); + stats.record_block_release(size, release); }); } diff --git a/crates/perry-runtime/src/array/indexing.rs b/crates/perry-runtime/src/array/indexing.rs index 6d128ebb52..530ac70e32 100644 --- a/crates/perry-runtime/src/array/indexing.rs +++ b/crates/perry-runtime/src/array/indexing.rs @@ -1027,14 +1027,15 @@ pub(crate) unsafe fn try_strict_dense_number_store( // proves there are no holes and keeps its bit-for-bit old hot path; every // other admitted layout proves ownership with the slot Perry is about to // overwrite. - // The process latch leads: an array can only have an inherited index when - // SOME array has been retargeted, so a program that never calls - // `Object.setPrototypeOf` on an array keeps this lane bit-for-bit as it was - // (one relaxed load of a static bool, and the slot is never read here). - // `new Array(n)` fills are holey and would otherwise all fall off the lane. + // #9787: the shared invalidation byte covers indexed properties on both + // default prototypes as well as retargeted arrays. Checking only whether + // an array was retargeted misses Array.prototype / Object.prototype + // descriptors and lets this lane create an own element over a setter. + // Ordinary `new Array(n)` fills still pay one relaxed load and never read + // the old slot while all three prototype conditions remain clear. let may_have_holes = flags & crate::gc::GC_ARRAY_RAW_F64_LAYOUT == 0 || flags & crate::gc::GC_ARRAY_RAW_F64_HOLES != 0; - if crate::object::prototype_chain::array_static_proto_recorded() + if PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED.load(Ordering::Relaxed) != 0 && may_have_holes && ptr::read(slot) == crate::value::TAG_HOLE { diff --git a/crates/perry-runtime/src/array/strict_store_tests.rs b/crates/perry-runtime/src/array/strict_store_tests.rs index 96f753086d..97ef10dbf2 100644 --- a/crates/perry-runtime/src/array/strict_store_tests.rs +++ b/crates/perry-runtime/src/array/strict_store_tests.rs @@ -130,10 +130,10 @@ fn strict_dense_number_store_fast_lane_matches_the_general_path() { // #9220: an in-bounds hole is not an own property. The number lane // must decline it so the strict entry can consult an inherited index - // setter / non-writable data descriptor before creating an element — - // but ONLY once some array has been retargeted. With the process latch - // clear (the overwhelmingly common case, including every `new Array(n)` - // fill) the lane keeps filling holes exactly as it did before #9220. + // setter / non-writable data descriptor before creating an element. + // #9787: a default-prototype descriptor invalidates this lane even + // without a retargeted array. With the summary byte clear, ordinary + // `new Array(n)` fills retain their fast path. // // Indices 4 and 5 are the SAME shape — two in-bounds holes on one // array — so the latch is the only variable between the two arms. @@ -142,15 +142,27 @@ fn strict_dense_number_store_fast_lane_matches_the_general_path() { assert!(!array_has_own_index(out, 5)); let latch_was = crate::object::prototype_chain::test_swap_array_static_proto_recorded(false); + let summary_was = super::test_swap_array_index_fast_path_invalidated(0); + struct RestorePrototypeFlags(bool, u8); + impl Drop for RestorePrototypeFlags { + fn drop(&mut self) { + crate::object::prototype_chain::test_swap_array_static_proto_recorded(self.0); + super::test_swap_array_index_fast_path_invalidated(self.1); + } + } + let _restore = RestorePrototypeFlags(latch_was, summary_was); assert!( lane(out, 4, 8.0), - "no recorded array prototype: the hole fill stays on the fast lane" + "unmodified prototype chains: the hole fill stays on the fast lane" ); assert!(array_has_own_index(out, 4)); - crate::object::prototype_chain::test_swap_array_static_proto_recorded(true); + super::test_swap_array_index_fast_path_invalidated(1); assert!(!lane(out, 5, 8.0), "hole slot requires the [[Set]] walk"); assert!(!array_has_own_index(out, 5)); - crate::object::prototype_chain::test_swap_array_static_proto_recorded(latch_was); + assert!( + lane(out, 4, 9.0), + "an existing own element still bypasses inherited descriptors" + ); } } diff --git a/crates/perry-runtime/src/box.rs b/crates/perry-runtime/src/box.rs index 802d6fbd24..dc65e4f791 100644 --- a/crates/perry-runtime/src/box.rs +++ b/crates/perry-runtime/src/box.rs @@ -970,6 +970,18 @@ pub fn scan_box_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { /// rather than dereferencing. See perry#393 for the failure mode. #[no_mangle] pub extern "C" fn js_box_get_bits(ptr: *mut Box) -> i64 { + box_get_bits_named(ptr, f64::from_bits(crate::value::TAG_UNDEFINED)) +} + +/// Checked lexical read with the source binding name supplied by codegen. +/// The name is consumed only on the TDZ error path, before any GC allocation. +#[no_mangle] +pub extern "C" fn js_box_get_bits_named(ptr: *mut Box, name: f64) -> i64 { + box_get_bits_named(ptr, name) +} + +#[inline] +fn box_get_bits_named(ptr: *mut Box, name: f64) -> i64 { unsafe { if !is_registered_box_ptr(ptr) { // perry#924: production services see these in tight bursts of @@ -1020,7 +1032,7 @@ pub extern "C" fn js_box_get_bits(ptr: *mut Box) -> i64 { if TDZ_SUPPRESS_DEPTH.with(|d| d.get()) > 0 { return crate::value::TAG_UNDEFINED as i64; } - crate::error::js_throw_reference_error_tdz(f64::from_bits(crate::value::TAG_UNDEFINED)); + crate::error::js_throw_reference_error_tdz(name); } bits as i64 } @@ -1073,12 +1085,21 @@ pub extern "C" fn js_box_capture_cell_ptr(bits: i64) -> i64 { #[no_mangle] pub unsafe extern "C" fn js_box_get_bits_trusted(ptr: *mut Box) -> i64 { + unsafe { js_box_get_bits_trusted_named(ptr, f64::from_bits(crate::value::TAG_UNDEFINED)) } +} + +/// Named counterpart of `js_box_get_bits_trusted`. +/// +/// # Safety +/// `ptr` must be a live box cell, as for `js_box_get_bits_trusted`. +#[no_mangle] +pub unsafe extern "C" fn js_box_get_bits_trusted_named(ptr: *mut Box, name: f64) -> i64 { let bits = unsafe { (*ptr).value }; if bits == crate::value::TAG_TDZ { if TDZ_SUPPRESS_DEPTH.with(|d| d.get()) > 0 { return crate::value::TAG_UNDEFINED as i64; } - crate::error::js_throw_reference_error_tdz(f64::from_bits(crate::value::TAG_UNDEFINED)); + crate::error::js_throw_reference_error_tdz(name); } bits as i64 } @@ -1415,6 +1436,13 @@ static KEEP_JS_BOX_GET_BITS_TRUSTED: unsafe extern "C" fn(*mut Box) -> i64 = js_box_get_bits_trusted; #[cfg(feature = "keepalive-anchors")] #[used] +static KEEP_JS_BOX_GET_BITS_NAMED: extern "C" fn(*mut Box, f64) -> i64 = js_box_get_bits_named; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_BOX_GET_BITS_TRUSTED_NAMED: unsafe extern "C" fn(*mut Box, f64) -> i64 = + js_box_get_bits_trusted_named; +#[cfg(feature = "keepalive-anchors")] +#[used] static KEEP_JS_BOX_SET_BITS_TRUSTED_NO_BARRIER: unsafe extern "C" fn(*mut Box, i64) = js_box_set_bits_trusted_no_barrier; #[cfg(feature = "keepalive-anchors")] diff --git a/crates/perry-runtime/src/error.rs b/crates/perry-runtime/src/error.rs index f3592bae06..e6a55d645f 100644 --- a/crates/perry-runtime/src/error.rs +++ b/crates/perry-runtime/src/error.rs @@ -1081,16 +1081,17 @@ fn throw_reference_error_message(message: &'static [u8]) -> ! { /// `class` binding is read, `typeof`-d, or compound-assigned before its /// declaration has been evaluated — i.e. while its box still holds the /// `TAG_TDZ` sentinel. `name` is the NaN-boxed binding name (or `undefined` -/// when codegen could not thread a name through, e.g. a captured box read). -/// Message matches V8/Node byte-for-byte: `Cannot access x before +/// for legacy unnamed box reads). +/// Message matches V8/Node byte-for-byte: `Cannot access 'x' before /// initialization`. #[no_mangle] pub extern "C" fn js_throw_reference_error_tdz(name: f64) -> f64 { + let unnamed = name.to_bits() == crate::value::TAG_UNDEFINED; let name = value_to_lossy_string(name); - let msg = if name.is_empty() { + let msg = if unnamed || name.is_empty() { "Cannot access uninitialized variable before initialization".to_string() } else { - format!("Cannot access {} before initialization", name) + format!("Cannot access '{}' before initialization", name) }; let msg_str = js_string_from_bytes(msg.as_ptr(), msg.len() as u32); let err_ptr = js_referenceerror_new(msg_str); diff --git a/crates/perry-runtime/src/gc/census.rs b/crates/perry-runtime/src/gc/census.rs index dc4c00c0d9..99827e8720 100644 --- a/crates/perry-runtime/src/gc/census.rs +++ b/crates/perry-runtime/src/gc/census.rs @@ -654,6 +654,13 @@ fn take_census(label: &str, pass1: Option>) { let Some(path) = census_path() else { return; }; + // The Rust-heap census first: the GC walk below allocates, and the point + // of that census is what was resident before this collection started. + #[cfg(feature = "alloc-census")] + { + crate::alloc_census::alloc_census_dump(label); + crate::alloc_census::mimalloc_stats_print(); + } let started = Instant::now(); let mut c = Census { pass1, diff --git a/crates/perry-runtime/src/gc/idle_compact.rs b/crates/perry-runtime/src/gc/idle_compact.rs index f054cd8d07..ae00f0aa6d 100644 --- a/crates/perry-runtime/src/gc/idle_compact.rs +++ b/crates/perry-runtime/src/gc/idle_compact.rs @@ -300,6 +300,15 @@ pub(super) fn maybe_compact(now: u64) -> bool { let after_occupancy = crate::arena::old_gen_in_use_bytes(); let after_residue = residue_bytes(); let released = before_occupancy.saturating_sub(after_occupancy); + // #9772: judge the pass against its OWN prediction. Selecting whole blocks + // makes `predicted` achievable by construction, so a pass that returns far + // less than it promised is a defect, not a quiet no-op — it has spent a + // mutator pause on a process already far above node's idle CPU. + let predicted = super::oldgen_defrag::last_idle_predicted_release_bytes(); + let kept_promise = predicted == 0 || released.saturating_mul(2) >= predicted; + if !kept_promise { + BROKEN_PROMISES.fetch_add(1, Ordering::Relaxed); + } let productive = released >= IDLE_COMPACT_PRODUCTIVE_MIN_BYTES; ATTEMPTS.fetch_add(1, Ordering::Relaxed); @@ -322,6 +331,7 @@ pub(super) fn maybe_compact(now: u64) -> bool { if gc_diag_enabled() { eprintln!( "[gc-idle-compact] done old_in_use={before_occupancy}->{after_occupancy} released={released} \ + predicted={predicted} kept_promise={kept_promise} \ reusable={before_residue}->{after_residue} freed={freed} pause_us={pause_us} \ productive={productive} backoff_shift={}", st.backoff_shift @@ -336,14 +346,24 @@ pub(super) fn maybe_compact(now: u64) -> bool { true } +/// Idle compactions that released less than half the block bytes their own +/// selection predicted (#9772). +static BROKEN_PROMISES: AtomicU64 = AtomicU64::new(0); + +/// See [`BROKEN_PROMISES`]. +pub fn idle_compact_broken_promises() -> u64 { + BROKEN_PROMISES.load(Ordering::Relaxed) +} + /// `PERRY_GC_DIAG=1` exit line. pub(super) fn emit_diag() { eprintln!( - "[gc-idle-compact] enabled={} attempts={} productive={} released_bytes={} \ + "[gc-idle-compact] enabled={} attempts={} productive={} broken_promises={} released_bytes={} \ pause_us_total={} pause_us_max={} wake_declined={} backoff_shift={}", idle_compact_enabled(), idle_compact_attempts(), idle_compact_productive(), + idle_compact_broken_promises(), idle_compact_released_bytes(), idle_compact_pause_us_total(), idle_compact_pause_us_max(), diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 2bbc530e0a..6a7424daca 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -941,6 +941,8 @@ pub fn gc_init() { // `PERRY_GC_CENSUS`: remember the main thread and install the SIGUSR2 // trigger. No-op (one OnceLock read) when the env var is unset. census::census_on_gc_init(); + #[cfg(feature = "alloc-census")] + crate::alloc_census::alloc_census_init(); reg_budgeted_scanner!( scan_runtime_handle_roots_mut, scan_runtime_handle_roots_mut_step, @@ -1099,6 +1101,9 @@ pub fn gc_init() { // REWRITES: an evacuating collection moves them like any other array, and // the thread-local slot is the only place the new address can be recorded. reg_scanner!(crate::iter_result::scan_iter_result_keys_roots_mut); + // Same shape as the line above: the shared keys arrays behind + // `Intl.Segmenter`'s segment records are referenced only by this cache. + reg_scanner!(crate::intl::segmenter::scan_segment_record_keys_roots_mut); reg_scanner!(small_int_cache_mutable_root_scanner); reg_scanner!(concat_memo_mutable_root_scanner); reg_scanner!(crate::builtins::scan_console_log_singleton_roots_mut); diff --git a/crates/perry-runtime/src/gc/oldgen.rs b/crates/perry-runtime/src/gc/oldgen.rs index 3dea59757f..2a331eaa89 100644 --- a/crates/perry-runtime/src/gc/oldgen.rs +++ b/crates/perry-runtime/src/gc/oldgen.rs @@ -1852,17 +1852,6 @@ pub(super) fn evacuate_selected_old_pages_collecting( // source blocks and evacuate the block all-or-nothing. Dead old objects // remain indexed until a full trace proves them dead, so conservatively // copying them here preserves the same minor-GC retention contract. - // Every hole on a page this pass is evacuating is unusable for the rest - // of it, and the block is released at the end. Drop them once, so the - // per-allocation exclusion scan in `old_free_take_exact` has nothing to - // walk: it is a linear scan of the size bucket, and with the fragmented - // pages excluded it used to fail over the whole bucket for every moved - // object. See `old_free_filter_pages` for the measurement. - let dropped_holes = crate::gc::old_free_filter_pages(excluded_pages); - if crate::gc::gc_diag_enabled() && dropped_holes > 0 { - eprintln!("[gc-old-page-defrag] dropped_excluded_holes_bytes={dropped_holes}"); - } - let mut source_headers = Vec::new(); crate::arena::old_arena_walk_objects_on_pages(excluded_pages, |header_ptr| { source_headers.push(header_ptr as *mut GcHeader); @@ -1880,9 +1869,26 @@ pub(super) fn evacuate_selected_old_pages_collecting( && !is_conservatively_pinned(header) }); if source_headers.is_empty() || !source_block_is_movable { + // #9772: a declined pass must not also DESTROY the free list. Dropping + // the excluded pages' holes is only justified by "this pass is about to + // empty and release these blocks"; doing it before the all-or-nothing + // movability check meant one immovable occupant anywhere in the + // selection cost the whole old-gen residue and returned nothing. + // Measured on the compiled claude-code TUI: `reusable` 40.7 MB -> + // 0.87 MB, `released=0`, 189 ms of pause, and the bytes were neither + // returned to the OS nor available to the next allocation. return evacuated; } + // Every hole on a page this pass is evacuating is unusable for the rest of + // it, and the block is released at the end. Drop them once, so the + // per-allocation exclusion scan in `old_free_take_exact` has nothing to + // walk (see `old_free_filter_pages` for the #9644 measurement). + let dropped_holes = crate::gc::old_free_filter_pages(excluded_pages); + if crate::gc::gc_diag_enabled() && dropped_holes > 0 { + eprintln!("[gc-old-page-defrag] dropped_excluded_holes_bytes={dropped_holes}"); + } + for header in source_headers { unsafe { let user_ptr = (header as *mut u8).add(GC_HEADER_SIZE); diff --git a/crates/perry-runtime/src/gc/oldgen_defrag.rs b/crates/perry-runtime/src/gc/oldgen_defrag.rs index c56313a9b0..4e0a0096c0 100644 --- a/crates/perry-runtime/src/gc/oldgen_defrag.rs +++ b/crates/perry-runtime/src/gc/oldgen_defrag.rs @@ -31,21 +31,41 @@ pub(super) fn old_page_defrag_skipped_for_pin(meta: crate::arena::OldPageMeta) - meta.allocated_bytes > 0 && meta.live_bytes > 0 && meta.dead_bytes > 0 && meta.pinned_bytes > 0 } -/// Live bytes one idle compaction will move before it stops selecting pages. +/// Live bytes one idle compaction will move before it stops selecting. /// -/// The pass is linear in moved objects once the free-list pathology is gone -/// (`gc/old_free.rs::old_free_filter_pages`): the #9644 fixture moved 9.4 MB -/// in 235,241 objects in 132 ms, i.e. ~0.56 us per object. A budget keeps the -/// pause bounded on a heap far larger than that fixture's — the candidate -/// pages are sorted most-fragmented-first, so the bytes this leaves behind are -/// the least profitable ones, and the next idle compaction takes them. -pub(super) const IDLE_COMPACT_MOVE_BUDGET_BYTES: usize = 8 * 1024 * 1024; +/// This bounds how much a single pass MOVES. 8 MiB came from the #9644 +/// fixture (9.4 MB in 235,241 objects in 132 ms once the free-list pathology +/// was gone, `gc/old_free.rs::old_free_filter_pages`). +/// +/// Measured on the compiled claude-code TUI, cutting it to 1 MiB moved the +/// selection from ~50 blocks to ~15 and left the pause UNCHANGED — three +/// interleaved pairs gave a 1,070 ms mean against the old selection's +/// 1,044 ms, with a 515-1,375 ms spread that tracks machine load rather than +/// the arm. So this pass is dominated by fixed per-pass cost (the old-page +/// meta snapshot, the walk over the selected blocks' pages, the sweep), not by +/// moving, and the budget's job is bounding the moved volume rather than +/// buying back pause. 1 MiB is enough to release ~15 MB of whole blocks per +/// pass; selection is cheapest-block-first, so what one pass leaves behind is +/// what the next one takes. Lowering the fixed cost is separate work. +pub(super) const IDLE_COMPACT_MOVE_BUDGET_BYTES: usize = 1024 * 1024; pub(super) fn select_old_page_defrag_pages_from_snapshot( snapshot: &[crate::arena::OldPageMeta], force: bool, ) -> OldPageDefragSelection { let mut selection = OldPageDefragSelection::default(); + // #9772: the idle compaction's release unit is a BLOCK, so selecting the + // globally most-fragmented PAGES predicts bytes it cannot return — the + // emptied pages are scattered over blocks that keep other live occupants, + // and `old_arena_reclaim_selected_dead_blocks` frees none of them. It + // picked 10,740 pages promising 44 MB, ran 228 ms and released 0 on the + // compiled claude-code TUI. Selecting whole blocks, cheapest-to-empty + // first, makes the prediction achievable by construction: every selected + // block ends the pass with no live occupant, which is exactly what the + // reclaim tests. + if idle_compact_armed() && idle_compact_block_selection_enabled() { + return select_whole_blocks(snapshot, selection); + } let mut candidates = Vec::new(); for &meta in snapshot { if old_page_defrag_skipped_for_pin(meta) { @@ -70,15 +90,8 @@ pub(super) fn select_old_page_defrag_pages_from_snapshot( .then_with(|| a.page_base.cmp(&b.page_base)) }); - // The idle compaction pays for its pass with a mutator pause, so it takes - // the most profitable pages and stops. Every other caller selects the - // whole candidate set as before. - let move_budget = idle_compact_armed().then_some(IDLE_COMPACT_MOVE_BUDGET_BYTES); + // Every non-idle caller takes the whole candidate set, as before. for meta in candidates { - if move_budget.is_some_and(|budget| selection.selected_live_bytes >= budget) { - selection.budget_stopped = true; - break; - } let page = crate::arena::generation_page_for_addr(meta.page_base); if selection.pages.insert(page) { selection.page_order.push(page); @@ -103,6 +116,22 @@ pub(super) fn select_old_page_defrag_pages_from_snapshot( crate::perry_thread_local! { /// Set for the duration of one `gc/idle_compact.rs` collection. static IDLE_COMPACT_ARMED: std::cell::Cell = const { std::cell::Cell::new(false) }; + /// Releasable block bytes the last idle selection promised (#9772). + static LAST_IDLE_PREDICTED_RELEASE: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// `PERRY_GC_IDLE_COMPACT_BLOCKS` — ON by default. `=0`/`off`/`false` restores +/// the pre-#9772 page-granular selection, which predicts releasable bytes it +/// cannot return. Present so the two selections can be compared in one binary. +fn idle_compact_block_selection_enabled() -> bool { + static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); + *ENABLED.get_or_init(|| crate::gc::env_default_on_enabled("PERRY_GC_IDLE_COMPACT_BLOCKS")) +} + +/// Block bytes the most recent idle-compaction selection predicted it could +/// hand back. `gc/idle_compact.rs` checks the pass against it. +pub(super) fn last_idle_predicted_release_bytes() -> usize { + LAST_IDLE_PREDICTED_RELEASE.with(|c| c.get()) } fn idle_compact_armed() -> bool { @@ -221,6 +250,12 @@ pub(super) fn select_old_page_defrag_pages(force: bool) -> OldPageDefragSelectio } let snapshot = crate::arena::old_page_meta_snapshot(); let selection = select_old_page_defrag_pages_from_snapshot(&snapshot, force); + if idle_compact_armed() { + // #9772: publish what this pass PROMISED, so the pass that consumes it + // can be judged against its own prediction instead of reporting a + // pause and no bytes. + LAST_IDLE_PREDICTED_RELEASE.with(|c| c.set(selection.selected_releasable_block_bytes)); + } if idle_compact_armed() && crate::gc::gc_diag_enabled() { let dead: usize = snapshot.iter().map(|m| m.dead_bytes).sum(); let live: usize = snapshot.iter().map(|m| m.live_bytes).sum(); @@ -337,3 +372,92 @@ mod tests { let _ = enabled; } } + +/// Block-granular selection for the idle compaction (#9772). +/// +/// Groups every old page with live bytes by its containing arena block, drops +/// blocks that hold pinned bytes (those can never be emptied), ranks the rest +/// by how much live data must move to empty them, and takes whole blocks until +/// [`IDLE_COMPACT_MOVE_BUDGET_BYTES`] of live bytes is committed. +/// `selected_releasable_block_bytes` is then the sum of the selected blocks' +/// sizes — memory the reclaim actually hands back — rather than a sum of page +/// granules nothing releases. +fn select_whole_blocks( + snapshot: &[crate::arena::OldPageMeta], + mut selection: OldPageDefragSelection, +) -> OldPageDefragSelection { + let ranges = crate::arena::old_arena_block_ranges(); + if ranges.is_empty() { + return selection; + } + #[derive(Default, Clone)] + struct BlockAcc { + live_bytes: usize, + dead_bytes: usize, + pinned: bool, + pages: Vec, + } + let mut blocks: Vec = vec![BlockAcc::default(); ranges.len()]; + for &meta in snapshot { + if meta.allocated_bytes == 0 { + continue; + } + let Some(bi) = crate::arena::old_arena_block_range_index(&ranges, meta.page_base) else { + continue; + }; + let acc = &mut blocks[bi]; + acc.live_bytes = acc.live_bytes.saturating_add(meta.live_bytes); + acc.dead_bytes = acc.dead_bytes.saturating_add(meta.dead_bytes); + acc.pinned |= meta.pinned_bytes > 0; + acc.pages + .push(crate::arena::generation_page_for_addr(meta.page_base)); + } + + let mut order: Vec = (0..blocks.len()) + .filter(|&i| { + let b = &blocks[i]; + // A block with no live occupant is already the ordinary sweep's + // job; a pinned one can never be emptied by moving. + !b.pinned && b.live_bytes > 0 && b.dead_bytes > 0 && !b.pages.is_empty() + }) + .collect(); + selection.candidate_pages = order.iter().map(|&i| blocks[i].pages.len()).sum(); + selection.skipped_pinned_pages = blocks + .iter() + .filter(|b| b.pinned) + .map(|b| b.pages.len()) + .sum(); + // Cheapest to empty first; among equals prefer the one that gives back the + // most dead bytes. + order.sort_unstable_by(|&a, &b| { + blocks[a] + .live_bytes + .cmp(&blocks[b].live_bytes) + .then_with(|| blocks[b].dead_bytes.cmp(&blocks[a].dead_bytes)) + .then_with(|| ranges[a].0.cmp(&ranges[b].0)) + }); + + for bi in order { + if selection.selected_live_bytes >= IDLE_COMPACT_MOVE_BUDGET_BYTES { + selection.budget_stopped = true; + break; + } + let acc = &blocks[bi]; + for &page in &acc.pages { + if selection.pages.insert(page) { + selection.page_order.push(page); + selection.selected_pages = selection.selected_pages.saturating_add(1); + } + } + selection.selected_live_bytes = + selection.selected_live_bytes.saturating_add(acc.live_bytes); + selection.selected_reclaimable_bytes = selection + .selected_reclaimable_bytes + .saturating_add(acc.dead_bytes); + // The whole block comes back once its live occupants are gone. + selection.selected_releasable_block_bytes = selection + .selected_releasable_block_bytes + .saturating_add(ranges[bi].3); + } + selection +} diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots.rs b/crates/perry-runtime/src/gc/tests/runtime_roots.rs index fc67e7a3bf..14d588ddfa 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots.rs @@ -14,6 +14,7 @@ mod native_module_name; mod old_defrag_contract; mod prototype_addr_cache; mod regexp_last_index; +mod segment_record_keys; mod side_table_scanners; mod string_normalize_form; mod string_slice; diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/segment_record_keys.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/segment_record_keys.rs new file mode 100644 index 0000000000..44b4b3471c --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/segment_record_keys.rs @@ -0,0 +1,179 @@ +//! The per-thread `{ segment, index, input(, isWordLike) }` keys arrays every +//! `Intl.Segmenter` segment record shares. +//! +//! Exactly the shape `iter_result_keys.rs` guards, for the same reason and +//! with the same failure mode: `scripts/gc_root_dominance_check.py` reads +//! emitted LLVM IR, so a thread-local holding a `*mut ArrayHeader` into the +//! heap is structurally invisible to it, and the runtime scanner is the only +//! thing between this cache and a use-after-free. Being a cache rather than a +//! register, it would go bad at collection #0 and stay bad — corrupting every +//! later segment record on the thread instead of failing intermittently. +//! +//! Marking alone is not enough: a marked but un-rewritten slot still hands out +//! a pre-move address after a copying minor, so there is a MARK test and a +//! REWRITE test, plus a registration check (a scanner a test can call directly +//! is a no-op in production until `gc_init` names it). + +use super::*; +use crate::array::ArrayHeader; +use crate::intl::segmenter::{SegmentRecordShape, SEGMENT_RECORD_SHAPE_LIST}; + +/// Empties the cache on entry and exit and pins the GC triggers for the body, +/// exactly as `IterResultKeysGuard` does. +struct SegmentRecordKeysGuard { + _triggers: GcTriggerThresholdTestGuard, +} + +impl SegmentRecordKeysGuard { + fn new() -> Self { + let triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + crate::intl::segmenter::reset_shared_segment_keys_for_test(); + Self { + _triggers: triggers, + } + } +} + +impl Drop for SegmentRecordKeysGuard { + fn drop(&mut self) { + crate::intl::segmenter::reset_shared_segment_keys_for_test(); + } +} + +fn evacuate_array(from: *mut ArrayHeader) -> *mut ArrayHeader { + let to = crate::arena::arena_alloc_gc_old(64, 8, GC_TYPE_ARRAY); + unsafe { + set_forwarding_address(header_from_user_ptr(from as *const u8), to); + } + to as *mut ArrayHeader +} + +/// MARK. The cache is the ONLY reference to these arrays — the records that +/// point at them are short-lived while the cache outlives them — so an +/// unmarked slot is a swept slot, and every later segment record installs a +/// freed keys array as its shape. +#[test] +fn segment_record_keys_cache_is_marked_by_the_collector() { + let _guard = SegmentRecordKeysGuard::new(); + clear_marks(); + clear_mark_seeds(); + + let arrays = crate::intl::segmenter::populate_shared_segment_keys_for_test(); + let valid_ptrs = build_valid_pointer_set(); + + crate::intl::segmenter::scan_segment_record_keys_roots_mut(&mut RuntimeRootVisitor::for_mark( + &valid_ptrs, + )); + + for (i, arr) in arrays.iter().enumerate() { + assert!(!arr.is_null(), "keys slot {i} should have been populated"); + assert_marked_user_ptr( + *arr as usize, + &format!("segment-record keys array {i} (nothing else references it)"), + ); + } + + clear_marks(); + clear_mark_seeds(); +} + +/// REWRITE, every slot. Marking keeps the array alive; only the rewrite makes +/// the slot name the surviving copy. +#[test] +fn every_segment_record_keys_slot_is_rewritten_by_the_collector() { + let _guard = SegmentRecordKeysGuard::new(); + + let before = crate::intl::segmenter::populate_shared_segment_keys_for_test(); + let valid_ptrs = build_valid_pointer_set(); + let expected: Vec<*mut ArrayHeader> = before.iter().map(|p| evacuate_array(*p)).collect(); + + crate::intl::segmenter::scan_segment_record_keys_roots_mut( + &mut RuntimeRootVisitor::for_rewrite(&valid_ptrs), + ); + + for (i, shape) in SEGMENT_RECORD_SHAPE_LIST.into_iter().enumerate() { + assert_eq!( + crate::intl::segmenter::shared_segment_keys_peek_for_test(shape), + expected[i], + "segment-record keys slot {i} ({shape:?}) must be rewritten to the \ + relocated array. A marked-but-stale slot goes bad at collection #0 \ + and then EVERY segment record on this thread installs a from-space \ + keys array as its shape." + ); + } +} + +/// An empty cache is the state between process start and the first +/// `Intl.Segmenter` use, and every cycle in that window scans it. A null slot +/// must be skipped, not treated as an address. +#[test] +fn scanning_an_empty_segment_record_keys_cache_is_a_no_op() { + let _guard = SegmentRecordKeysGuard::new(); + let valid_ptrs = build_valid_pointer_set(); + + crate::intl::segmenter::scan_segment_record_keys_roots_mut( + &mut RuntimeRootVisitor::for_rewrite(&valid_ptrs), + ); + + for shape in SEGMENT_RECORD_SHAPE_LIST { + assert!( + crate::intl::segmenter::shared_segment_keys_peek_for_test(shape).is_null(), + "scanning must not populate the {shape:?} keys slot" + ); + } +} + +/// …and it must actually be REGISTERED: an unregistered scanner is a no-op in +/// production, which is precisely the bug this cache would introduce. +#[test] +fn segment_record_keys_scanner_is_registered() { + crate::gc::gc_init(); + let registered = |scanner: MutableRootScanner| { + crate::gc::roots::MUTABLE_ROOT_SCANNERS.with(|scanners| { + scanners + .borrow() + .iter() + .any(|entry| entry.scanner as usize == scanner as usize) + }) + }; + + assert!( + registered( + crate::intl::segmenter::scan_segment_record_keys_roots_mut as MutableRootScanner + ), + "scan_segment_record_keys_roots_mut must be registered in gc_init — unregistered, \ + the shared segment-record keys arrays are swept by the first minor and every later \ + segment record installs a freed array as its shape" + ); +} + +/// The cache must be STABLE: the second segment record reuses the array the +/// first one built. If it did not, nothing would have been saved — and, +/// because `shape_id_for_keys_ensure` keys the shape table on the array's +/// ADDRESS, a fresh array per record is also a fresh shape id per record, +/// which is what made every read of `.segment` an inline-cache miss. +#[test] +fn segment_record_keys_are_built_once_per_shape() { + let _guard = SegmentRecordKeysGuard::new(); + + let first = crate::intl::segmenter::populate_shared_segment_keys_for_test(); + let second = crate::intl::segmenter::populate_shared_segment_keys_for_test(); + + assert_eq!( + first, second, + "the shared keys arrays must be built once per thread per shape; rebuilding them \ + per record restores both the per-record allocations and the one-shape-id-per-record \ + inline-cache miss" + ); + assert_eq!( + first.len(), + SEGMENT_RECORD_SHAPE_LIST.len(), + "every segment-record shape needs its own shared array" + ); + assert_ne!( + first[SegmentRecordShape::Plain as usize], + first[SegmentRecordShape::WordLike as usize], + "the two shapes must not share one array: `isWordLike` is present only for \ + word granularity" + ); +} diff --git a/crates/perry-runtime/src/hot_diag.rs b/crates/perry-runtime/src/hot_diag.rs index 059f6e153a..48216ced2c 100644 --- a/crates/perry-runtime/src/hot_diag.rs +++ b/crates/perry-runtime/src/hot_diag.rs @@ -128,7 +128,7 @@ pub struct RegexDiag { per_pattern: HashMap, } -thread_local! { +crate::perry_thread_local! { static REGEX_DIAG: RefCell = RefCell::new(RegexDiag::default()); } @@ -417,7 +417,7 @@ pub struct IcDiag { sites: HashMap, } -thread_local! { +crate::perry_thread_local! { static IC_DIAG: RefCell = RefCell::new(IcDiag::default()); } diff --git a/crates/perry-runtime/src/intl.rs b/crates/perry-runtime/src/intl.rs index ae6c46176d..88084c0db4 100644 --- a/crates/perry-runtime/src/intl.rs +++ b/crates/perry-runtime/src/intl.rs @@ -63,7 +63,7 @@ mod number_format_options; mod numbering_system; use numbering_system::{is_well_formed_numbering_system, resolve_numbering_system}; mod canon_aliases; -mod segmenter; +pub(crate) mod segmenter; use canon_aliases::canonicalize_unicode_extension_types; pub(crate) use date_collator::{ diff --git a/crates/perry-runtime/src/intl/segmenter.rs b/crates/perry-runtime/src/intl/segmenter.rs index 6ba71870ec..0986d6f0f1 100644 --- a/crates/perry-runtime/src/intl/segmenter.rs +++ b/crates/perry-runtime/src/intl/segmenter.rs @@ -65,21 +65,199 @@ unsafe fn segmenter_input_text(ptr: *const StringHeader) -> String { text } +/// The two shapes a segment record can have. ECMA-402 18.5.1 attaches +/// `isWordLike` only to word granularity, so there are exactly two. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum SegmentRecordShape { + /// `{ segment, index, input }` + Plain = 0, + /// `{ segment, index, input, isWordLike }` + WordLike = 1, +} + +/// Every shape, for the root-scanner tests. +#[cfg(test)] +pub(crate) const SEGMENT_RECORD_SHAPE_LIST: [SegmentRecordShape; SEGMENT_RECORD_SHAPES] = + [SegmentRecordShape::Plain, SegmentRecordShape::WordLike]; + +const SEGMENT_RECORD_SHAPES: usize = 2; + +crate::perry_thread_local! { + /// Per-thread shared keys arrays for segment records, indexed by + /// [`SegmentRecordShape`]. + /// + /// Same construction, and the same reason, as `iter_result`'s + /// `ITER_RESULT_KEYS` (#7564): `set_field`-by-name clones an object's key + /// list before writing, so building a record property-by-property gave + /// EVERY record its own keys array — a fresh array address per record, + /// therefore a fresh ShapeId per record (`shape_id_for_keys_ensure` keys + /// the shape table on the array's address), therefore a guaranteed inline + /// -cache miss on every `.segment` / `.index` / `.input` read and one more + /// descriptor in the shape table per segment. On the claude-code TUI, + /// whose text measurement segments every string it renders, that was + /// 175,797 misses on `.segment` alone in one 400-character reply + /// (`PERRY_IC_DIAG`). One shared array per shape means one ShapeId for + /// every segment record in the program. + /// + /// Per-thread and not process-global for the same reason the intern table + /// is: each `perry/thread` worker has its own arena. + /// + /// GC-visible through [`scan_segment_record_keys_roots_mut`], which both + /// MARKS (nothing else references these arrays; the records that use them + /// are short-lived and the cache outlives them) and REWRITES them. + static SEGMENT_RECORD_KEYS: std::cell::UnsafeCell<[*mut crate::array::ArrayHeader; SEGMENT_RECORD_SHAPES]> = + std::cell::UnsafeCell::new([std::ptr::null_mut(); SEGMENT_RECORD_SHAPES]); +} + +#[inline(always)] +fn cached_segment_keys(shape: SegmentRecordShape) -> *mut crate::array::ArrayHeader { + SEGMENT_RECORD_KEYS.with(|c| unsafe { (*c.get())[shape as usize] }) +} + +/// NaN-boxed bits of an interned constant property name. +#[inline] +fn interned_key_bits(bytes: &[u8]) -> u64 { + JSValue::string_ptr(crate::string::intern_ascii_literal(bytes) as *mut _).bits() +} + +/// Build this thread's shared keys array for `shape`, if it has none. +/// +/// Cold and at most twice per thread for the program's lifetime, so it is +/// written for obviousness rather than speed: every intermediate is rooted +/// across every allocation that follows it. Interning makes the names +/// pointer-identical to the `"segment"` / `"index"` / `"input"` the READ side +/// hashes, and gives them a second independent root in the intern table. +#[cold] +unsafe fn build_shared_segment_keys(shape: SegmentRecordShape) { + const NAMES: [&[u8]; 4] = [b"segment", b"index", b"input", b"isWordLike"]; + let n = match shape { + SegmentRecordShape::Plain => 3usize, + SegmentRecordShape::WordLike => 4usize, + }; + + let scope = crate::gc::RuntimeHandleScope::new(); + let keys_h = scope.root_raw_mut_ptr(js_array_alloc(n as u32)); + + // Each intern ALLOCATES on a first-call-per-thread miss, so the array's + // address is taken back out of its handle ACROSS them. + let mut handles = Vec::with_capacity(n); + for name in NAMES.iter().take(n) { + let (bits, _) = + keys_h.across_mut::(|| interned_key_bits(name)); + handles.push(scope.root_nanbox_u64(bits)); + } + // Every call below is non-allocating (`store_array_slot` writes one slot; + // `rebuild_array_layout_exact` only clears and recomputes layout metadata), + // so the pointer is scoped rather than carried across a collection point. + keys_h.with_mut_ptr::(|keys| { + (*keys).length = n as u32; + for (i, h) in handles.iter().enumerate() { + crate::array::store_array_slot(keys, i, h.get_nanbox_u64()); + } + crate::array::rebuild_array_layout_exact(keys); + + // Copy-on-write marker. Without it, `record.extra = 1` on ONE record would + // append to the array every other record shares. + crate::gc::mark_shape_shared(keys as *mut u8); + + // The table this publishes into is scanned by + // `scan_segment_record_keys_roots_mut`, registered in `gc/mod.rs`. + SEGMENT_RECORD_KEYS.with(|c| (*c.get())[shape as usize] = keys); + crate::gc::runtime_write_barrier_root_raw_ptr(keys); + }); +} + +/// GC root scanner for the shared segment-record keys arrays. See +/// `SEGMENT_RECORD_KEYS`. +pub fn scan_segment_record_keys_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { + SEGMENT_RECORD_KEYS.with(|c| unsafe { + for slot in (*c.get()).iter_mut() { + visitor.visit_raw_mut_ptr_slot(slot); + } + }); +} + +/// Drop the cached arrays. The unit-test harness resets arenas between tests +/// while thread-locals persist, which would leave these pointing into a +/// deallocated block. +#[cfg(test)] +pub(crate) fn populate_shared_segment_keys_for_test() -> Vec<*mut crate::array::ArrayHeader> { + for shape in SEGMENT_RECORD_SHAPE_LIST { + if cached_segment_keys(shape).is_null() { + unsafe { build_shared_segment_keys(shape) }; + } + } + SEGMENT_RECORD_SHAPE_LIST + .iter() + .map(|shape| cached_segment_keys(*shape)) + .collect() +} + +#[cfg(test)] +pub(crate) fn shared_segment_keys_peek_for_test( + shape: SegmentRecordShape, +) -> *mut crate::array::ArrayHeader { + cached_segment_keys(shape) +} + +#[cfg(test)] +pub(crate) fn reset_shared_segment_keys_for_test() { + SEGMENT_RECORD_KEYS.with(|c| unsafe { + (*c.get()) = [std::ptr::null_mut(); SEGMENT_RECORD_SHAPES]; + }); +} + pub(crate) fn make_segment_record( segment_value: f64, index: u32, input_value: f64, word_like: Option, ) -> f64 { - let obj = js_object_alloc(0, 4); - set_field(obj, "segment", segment_value); - // `index` is a plain Number (UTF-16 code-unit offset into the input). - set_field(obj, "index", index as f64); - set_field(obj, "input", input_value); - if let Some(word_like) = word_like { - set_field(obj, "isWordLike", bool_value(word_like)); + let shape = if word_like.is_some() { + SegmentRecordShape::WordLike + } else { + SegmentRecordShape::Plain + }; + let n = match shape { + SegmentRecordShape::Plain => 3usize, + SegmentRecordShape::WordLike => 4usize, + }; + unsafe { + let scope = crate::gc::RuntimeHandleScope::new(); + // Both caller-supplied values are heap pointers; the allocations below + // can collect and move them. + let segment_h = scope.root_nanbox_f64(segment_value); + let input_h = scope.root_nanbox_f64(input_value); + + // Fill the keys cache BEFORE the record exists, so its cold + // allocations cannot invalidate a pointer already being held. + if cached_segment_keys(shape).is_null() { + build_shared_segment_keys(shape); + } + + let obj_h = scope.root_nanbox_f64(js_nanbox_pointer(js_object_alloc(0, n as u32) as i64)); + // Everything below re-reads through storage the collector rewrites: + // the record from its handle, the keys array from the scanned + // thread-local. No address here predates the allocation above. + let obj = || crate::js_nanbox_get_pointer(obj_h.get_nanbox_f64()) as *mut ObjectHeader; + crate::object::js_object_set_keys(obj(), cached_segment_keys(shape)); + crate::object::js_object_set_field( + obj(), + 0, + JSValue::from_bits(segment_h.get_nanbox_f64().to_bits()), + ); + // `index` is a plain Number (UTF-16 code-unit offset into the input). + crate::object::js_object_set_field(obj(), 1, JSValue::number(index as f64)); + crate::object::js_object_set_field( + obj(), + 2, + JSValue::from_bits(input_h.get_nanbox_f64().to_bits()), + ); + if let Some(word_like) = word_like { + crate::object::js_object_set_field(obj(), 3, JSValue::bool(word_like)); + } + js_nanbox_pointer(obj() as i64) } - js_nanbox_pointer(obj as i64) } /// Build the segment list for `input` under `granularity`. The backing array diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index 6b17a7a6f1..719fade363 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -25,12 +25,37 @@ // watchOS) on 32-bit. Keep mimalloc's speed on 64-bit. // `alloc-mimalloc` (default-on) can be dropped by a size-optimized rebuild // (`PERRY_SIZE_OPT`), trading the faster allocator for ~140 KB of binary. -#[cfg(all(target_pointer_width = "64", feature = "alloc-mimalloc"))] +#[cfg(all( + target_pointer_width = "64", + feature = "alloc-mimalloc", + not(feature = "alloc-census") +))] #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; -#[cfg(not(all(target_pointer_width = "64", feature = "alloc-mimalloc")))] +#[cfg(all( + not(all(target_pointer_width = "64", feature = "alloc-mimalloc")), + not(feature = "alloc-census") +))] #[global_allocator] static GLOBAL: std::alloc::System = std::alloc::System; +// `alloc-census` builds wrap whichever allocator the target would have used. +// The wrapper is the only way to attribute native-heap bytes to a call site, +// and it is compiled out of every build that does not ask for it. +#[cfg(all( + target_pointer_width = "64", + feature = "alloc-mimalloc", + feature = "alloc-census" +))] +#[global_allocator] +static GLOBAL: alloc_census::CensusAlloc = + alloc_census::CensusAlloc(mimalloc::MiMalloc); +#[cfg(all( + not(all(target_pointer_width = "64", feature = "alloc-mimalloc")), + feature = "alloc-census" +))] +#[global_allocator] +static GLOBAL: alloc_census::CensusAlloc = + alloc_census::CensusAlloc(std::alloc::System); // Declared FIRST and with `#[macro_use]`: `per_test_global!` has to be in // scope for every module below it. See its module docs for #7672. @@ -41,6 +66,8 @@ pub mod abi_trampoline; pub mod agent; #[cfg(test)] mod agent_dispatch_tests; +#[cfg(feature = "alloc-census")] +pub mod alloc_census; pub mod app_group; pub mod arena; pub mod array; diff --git a/crates/perry-runtime/src/object/shapes.rs b/crates/perry-runtime/src/object/shapes.rs index 049f72295f..8d26b9c024 100644 --- a/crates/perry-runtime/src/object/shapes.rs +++ b/crates/perry-runtime/src/object/shapes.rs @@ -264,6 +264,15 @@ impl ShapeTableInner { self.families.entry(keys).or_default().push_back(id); } + /// Append a FRESHLY allocated id (see [`IdList::append_unchecked`]): the + /// id came from `alloc_shape_id`, which never reuses a value, so the + /// membership scan `family_push_back` would run is dead work that is + /// linear in the number of descriptors this keys array has ever had. + #[inline] + fn family_append_fresh(&mut self, keys: u64, id: u32) { + self.families.entry(keys).or_default().append_unchecked(id); + } + #[inline] fn family_push_front(&mut self, keys: u64, id: u32) { self.families.entry(keys).or_default().push_front(id); @@ -287,6 +296,12 @@ impl ShapeTableInner { self.by_facts.entry(facts).or_default().push_back(id); } + /// Fresh-id twin of [`ShapeTableInner::facts_push_back`]; same argument. + #[inline] + fn facts_append_fresh(&mut self, facts: u64, id: u32) { + self.by_facts.entry(facts).or_default().append_unchecked(id); + } + #[inline] fn facts_push_front(&mut self, facts: u64, id: u32) { self.by_facts.entry(facts).or_default().push_front(id); @@ -507,8 +522,12 @@ pub(crate) fn shape_descriptor_ensure_with_holes( // complete descriptor. // SAFETY: no slab reference is held; `slab()` above went out of scope. unsafe { table.slab_mut().insert(id, record) }; - inner.facts_push_back(facts, id); - inner.family_push_back(keys_id, id); + // `id` was just handed out by `alloc_shape_id`, which never reuses a + // value, so neither accelerator can already hold it: append without the + // membership scan, whose cost is linear in this keys array's descriptor + // history (see `IdList::append_unchecked`). + inner.facts_append_fresh(facts, id); + inner.family_append_fresh(keys_id, id); Ok(id) } diff --git a/crates/perry-runtime/src/object/shapes_slot_list.rs b/crates/perry-runtime/src/object/shapes_slot_list.rs index 2dd6d74518..b93ddd35e3 100644 --- a/crates/perry-runtime/src/object/shapes_slot_list.rs +++ b/crates/perry-runtime/src/object/shapes_slot_list.rs @@ -401,7 +401,9 @@ pub(crate) unsafe fn rekey_stable_tombstone_shape_after_squeeze( .get_mut(&record.keys) .is_some_and(|ids| ids.replace(old_id, new_id)); if !replaced { - inner.family_push_back(record.keys, new_id); + // `new_id` came from `alloc_shape_id` a few lines above and is in no + // list yet (see `IdList::append_unchecked`). + inner.family_append_fresh(record.keys, new_id); } inner.indices.remove(&(record.keys as usize)); drop(inner); diff --git a/crates/perry-runtime/src/object/shapes_store.rs b/crates/perry-runtime/src/object/shapes_store.rs index ec9d0b812a..d5cb498ce7 100644 --- a/crates/perry-runtime/src/object/shapes_store.rs +++ b/crates/perry-runtime/src/object/shapes_store.rs @@ -537,6 +537,27 @@ impl IdList { if self.contains(id) { return; } + self.append_unchecked(id); + } + + /// Append an id the caller knows is not in this list. + /// + /// `alloc_shape_id` hands out a strictly increasing counter that is never + /// reused (it parks at `SHAPE_ID_END` rather than wrapping), so an id that + /// was allocated after this list was built cannot be in it, in this family + /// or in any other. The membership scan in [`push_back`] is therefore dead + /// work at the two interning sites, and it is not O(1) dead work: a family + /// holds every descriptor ever created for one keys array, so the scan is + /// linear in the history of that keys array and interning the *n*-th + /// descriptor for it costs O(n) — quadratic over a render that keeps + /// bumping a shape's semantic generation. `IdList::contains` was 6.2 % of + /// main-thread leaf samples on a claude-code streamed reply, 95 % of it + /// under `ShapeTableInner::family_push_back`. + /// + /// Callers that re-file an EXISTING id (the metadata rekey when a keys + /// array moves) must keep using [`push_back`]: those ids can already be in + /// the destination list. + pub(super) fn append_unchecked(&mut self, id: u32) { match self { IdList::Inline { len, ids } if (*len as usize) < ids.len() => { ids[*len as usize] = id; diff --git a/crates/perry-runtime/src/object/shapes_tests.rs b/crates/perry-runtime/src/object/shapes_tests.rs index 43475708a2..cd3a56feee 100644 --- a/crates/perry-runtime/src/object/shapes_tests.rs +++ b/crates/perry-runtime/src/object/shapes_tests.rs @@ -551,6 +551,43 @@ mod descriptor_tests_8067 { test_drop_shape_descriptors(keys); } + #[test] + fn interning_appends_each_new_descriptor_to_the_family_exactly_once() { + // `shape_descriptor_ensure` appends a FRESHLY allocated id with + // `IdList::append_unchecked`, skipping the membership scan whose cost + // is linear in the family's history. The scan is skippable only + // because `alloc_shape_id` never reuses a value; this pins the + // observable consequence — every distinct descriptor for one keys + // array appears in its family exactly once, in birth order — so a + // later change that feeds a recycled id through the fresh path fails + // here instead of silently duplicating a family entry. + let _lock = crate::gc::global_side_table_test_lock(); + let keys = 0x8067_0000_0000_2900usize; + let mut born = Vec::new(); + for n in 1..=6u32 { + born.push( + shape_descriptor_ensure(keys as *const ArrayHeader, n, n) + .expect("shape range unexpectedly exhausted"), + ); + } + assert_eq!( + test_shape_ids_for_keys(keys), + born, + "each new descriptor is appended once, in birth order" + ); + // Re-interning the same facts must hit the accelerator and add nothing. + for (i, n) in (1..=6u32).enumerate() { + assert_eq!( + shape_descriptor_ensure(keys as *const ArrayHeader, n, n).unwrap(), + born[i], + "an existing descriptor must be reused, not re-appended" + ); + } + assert_eq!(test_shape_ids_for_keys(keys), born); + + test_drop_shape_descriptors(keys); + } + #[test] fn a_foreign_agent_id_misses_instead_of_aliasing_same_address() { let _lock = crate::gc::global_side_table_test_lock(); diff --git a/crates/perry-transform/src/closure_local_inline.rs b/crates/perry-transform/src/closure_local_inline.rs index 51611dab92..2b84d5cf1e 100644 --- a/crates/perry-transform/src/closure_local_inline.rs +++ b/crates/perry-transform/src/closure_local_inline.rs @@ -109,6 +109,18 @@ fn process_stmts(stmts: &mut Vec, next_local_id: &mut LocalId) { // (`let exists' = exists`); follow such copies so the calls through // them count as calls of the closure. let set = collect_aliases(&stmts[i + 1..], id); + // Forward captures and reads can precede the declaration. Removing + // its initializer leaves those live boxes uninitialized even though + // every later use is an inlineable call (#9721). Earlier calls must + // also retain their original TDZ behavior. + let mut earlier_uses = Uses::default(); + for s in &stmts[..i] { + collect_uses_in_stmt(s, &set, &mut earlier_uses); + } + if earlier_uses.other || earlier_uses.calls != 0 { + i += 1; + continue; + } let mut uses = Uses::default(); for s in &stmts[i + 1..] { collect_uses_in_stmt(s, &set, &mut uses); @@ -887,6 +899,54 @@ mod tests { assert_eq!(format!("{stmts:?}"), format!("{before:?}")); } + #[test] + fn a_forward_capture_keeps_the_later_initializer() { + let mut earlier = arrow(2, Vec::new(), call_local(F, vec![Expr::Integer(1)]), false); + if let Expr::Closure { + captures, + mutable_captures, + .. + } = &mut earlier + { + captures.push(F); + mutable_captures.push(F); + } + let mut stmts = vec![ + Stmt::PreallocateTdzBoxes(vec![F]), + Stmt::Expr(earlier), + Stmt::Let { + id: F, + name: "later".into(), + ty: Type::Any, + mutable: false, + init: Some(arrow(1, vec![param(P, "value")], Expr::LocalGet(P), false)), + }, + Stmt::Expr(call_local(F, vec![Expr::Integer(2)])), + ]; + let before = format!("{stmts:?}"); + process_stmts(&mut stmts, &mut 100); + assert_eq!(format!("{stmts:?}"), before); + } + + #[test] + fn a_call_before_initialization_keeps_its_tdz_and_later_initializer() { + let mut stmts = vec![ + Stmt::PreallocateTdzBoxes(vec![F]), + Stmt::Expr(call_local(F, vec![Expr::Integer(1)])), + Stmt::Let { + id: F, + name: "later".into(), + ty: Type::Any, + mutable: false, + init: Some(arrow(1, vec![param(P, "value")], Expr::LocalGet(P), false)), + }, + Stmt::Expr(call_local(F, vec![Expr::Integer(2)])), + ]; + let before = format!("{stmts:?}"); + process_stmts(&mut stmts, &mut 100); + assert_eq!(format!("{stmts:?}"), before); + } + #[test] fn a_non_trivial_argument_or_a_capture_by_a_nested_closure_is_declined() { // Non-trivial argument: the arrow would duplicate or reorder effects. diff --git a/crates/perry/src/commands/compile.rs b/crates/perry/src/commands/compile.rs index 453556b499..5b321eb6e0 100644 --- a/crates/perry/src/commands/compile.rs +++ b/crates/perry/src/commands/compile.rs @@ -48,6 +48,7 @@ mod post_link; mod precompile_capture; mod reachability; mod size_report; +mod typed_feedback_profile; mod update_config; mod windows_target; // pub(crate): commands/deps.rs (the `check --check-deps` dependency checker) diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index ce3c8fa94f..ae988212c7 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -94,6 +94,11 @@ const BUILD_CACHE_ENV_VARS: &[&str] = &[ // #9026: gates the once-per-closure-entry resolution of read-only boxed // capture cells — flipping it changes every closure body that qualifies. "PERRY_BOX_CAPTURE_ENTRY_CELLS", + // #9514: gates the per-site concat cache. `PERRY_CONCAT_SITE_CACHE=0` + // removes the lane at build time, so a qualifying `"literal" + value` + // site lowers to a different sequence with it on and off and a cached + // object from one setting must not serve the other. + "PERRY_CONCAT_SITE_CACHE", // The guarded-preinline IR-size ceiling: functions on either side of the // budget inline differently, so a run with a raised ceiling must not be // served objects a default run produced (same rule as the RS4GC budget @@ -138,6 +143,7 @@ const BUILD_CACHE_ENV_VARS: &[&str] = &[ "PERRY_GC_MOVING_LOOP_POLLS", "PERRY_CANONICAL_I32_LOCALS", "PERRY_CANONICAL_STR_LOCALS", + "PERRY_CONCAT_SITE_CACHE", "PERRY_CODEGEN_UNITS", "PERRY_CODEGEN_UNIT_BYTES", "PERRY_CODEGEN_UNIT_SIZE", @@ -821,6 +827,9 @@ fn eligibility(args: &CompileArgs, project_root: &Path) -> Result<(), String> { if args.print_hir || args.trace.is_some() || args.focus.is_some() { return Err("diagnostic-mode".to_string()); } + if args.typed_feedback_profile.is_some() || args.typed_feedback_sites.is_some() { + return Err("typed-feedback-profile".to_string()); + } if args.explain_lowering { return Err("explain-lowering".to_string()); } diff --git a/crates/perry/src/commands/compile/lowering_report.rs b/crates/perry/src/commands/compile/lowering_report.rs index c74e2aaff8..63f9e8334e 100644 --- a/crates/perry/src/commands/compile/lowering_report.rs +++ b/crates/perry/src/commands/compile/lowering_report.rs @@ -365,6 +365,22 @@ fn aggregate_record( let notes_text = notes.join(";"); let access_mode = string_field(record, "access_mode").unwrap_or_default(); + for (prefix, decision) in [ + ("typed_feedback_replay_selected=", "selected"), + ("typed_feedback_replay_rejected=", "rejected"), + ] { + if let Some(reason) = notes.iter().find_map(|note| note.strip_prefix(prefix)) { + push_typed_path_evidence( + summary, + evidence, + module, + record, + decision, + format!("typed_feedback_replay:{reason}"), + ); + } + } + let is_dynamic_fallback = access_mode == "dynamic_fallback" || string_field(record, "fallback_reason").is_some(); if is_dynamic_fallback { diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 1eb33e6edd..79fd77520c 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -273,6 +273,28 @@ pub fn compute_object_cache_key( }) } +/// Replay freshness uses the normal complete lowering key, excluding only +/// instrumentation/reporting switches that capture and replay intentionally vary. +pub(super) fn typed_feedback_lowering_key( + opts: &perry_codegen::CompileOptions, + hir_hash: u64, + version: &str, +) -> u64 { + let mut opts = opts.clone(); + opts.emit_ir_only = false; + opts.verify_native_regions = false; + compute_object_cache_key_with_env(&opts, hir_hash, version, |name| { + if matches!( + name, + "PERRY_TYPED_FEEDBACK" | "PERRY_TYPED_FEEDBACK_TRACE" | "PERRY_VERIFY_NATIVE_REGIONS" + ) { + None + } else { + std::env::var(name).ok() + } + }) +} + fn compute_object_cache_key_with_env( opts: &perry_codegen::CompileOptions, hir_hash: u64, @@ -1105,6 +1127,12 @@ fn compute_object_cache_key_with_env( .as_deref() .unwrap_or(""), ); + // Also consumed by the replay freshness fingerprint. A changed concat + // lane must not reuse a catalog produced with different lowering inputs. + h.field( + "env_concat_site_cache", + env_var("PERRY_CONCAT_SITE_CACHE").as_deref().unwrap_or(""), + ); h.field( "env_full_outline_ic", env_var("PERRY_FULL_OUTLINE_IC").as_deref().unwrap_or(""), diff --git a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs index 9bea93ecbf..aa2d196db1 100644 --- a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs +++ b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs @@ -741,6 +741,7 @@ fn key_changes_with_codegen_env_vars() { // Codegen tuning/emission toggles (#6394). "PERRY_TYPED_FEEDBACK", "PERRY_TYPED_FEEDBACK_TRACE", + "PERRY_CONCAT_SITE_CACHE", "PERRY_FULL_OUTLINE_IC", "PERRY_FULL_OUTLINE_IC_MIN_FUNCS", "PERRY_OUTLINE_METHOD_DISPATCH", diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index cd5c539975..8dee26ecab 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -546,6 +546,8 @@ pub fn run_with_parse_cache( use_color: bool, verbose: u8, ) -> Result { + let typed_feedback = super::typed_feedback_profile::prepare(&args)?; + // #4826: fold `--libc musl` into the effective target up-front (before any // downstream code reads `args.target`) so the rest of the pipeline only // ever sees the concrete `linux-musl` triple family. @@ -2521,8 +2523,11 @@ pub fn run_with_parse_cache( .ok() .as_deref() == Some("1"); - let cache_enabled = - !args.no_cache && !cache_env_disabled && !bitcode_link && !verify_native_regions; + let cache_enabled = !args.no_cache + && !cache_env_disabled + && !bitcode_link + && !verify_native_regions + && typed_feedback.is_none(); // Target dir name for the cache layout. Using the resolved LLVM triple // keeps cross-compile caches from colliding with native-host caches. let cache_target_dir = target.as_deref().unwrap_or("host"); @@ -5593,7 +5598,12 @@ pub fn run_with_parse_cache( // everything recorded on this worker thread between these two // calls belongs to this module and nothing else. perry_codegen::ext_registry::begin_module_capture(); - let object_code = perry_codegen::compile_module(hir_module, opts).map_err(|e| { + let compiled = if let Some(session) = &typed_feedback { + super::typed_feedback_profile::compile(session, hir_module, opts, path, perry_version) + } else { + perry_codegen::compile_module(hir_module, opts) + }; + let object_code = compiled.map_err(|e| { perry_codegen::ext_registry::take_module_capture(); format!( "Error compiling module '{}' ({}) with --backend llvm: {:#}", @@ -5940,6 +5950,25 @@ pub fn run_with_parse_cache( } } + if let Some(session) = &typed_feedback { + for decision in session.finish(args.typed_feedback_sites.as_deref())? { + eprintln!( + "[typed-feedback-replay] {} {} site {}: {}", + if decision.accepted { + "accepted" + } else { + "rejected" + }, + decision.module, + decision + .site_id + .map(|id| id.to_string()) + .unwrap_or_else(|| "profile".into()), + decision.reason + ); + } + } + if let Some(explain_lowering) = explain_lowering.as_ref() { explain_lowering.emit(format)?; } diff --git a/crates/perry/src/commands/compile/typed_feedback_profile.rs b/crates/perry/src/commands/compile/typed_feedback_profile.rs new file mode 100644 index 0000000000..7436fccf67 --- /dev/null +++ b/crates/perry/src/commands/compile/typed_feedback_profile.rs @@ -0,0 +1,66 @@ +//! CLI freshness inputs for advisory typed-feedback replay. +use super::CompileArgs; +use anyhow::{Context, Result}; +use perry_codegen::typed_feedback_profile::{ModuleIdentity, Session}; +use sha2::{Digest, Sha256}; +use std::path::Path; + +pub(super) fn prepare(args: &CompileArgs) -> Result> { + if args.typed_feedback_profile.is_none() && args.typed_feedback_sites.is_none() { + return Ok(None); + } + if matches!( + args.target.as_deref(), + Some( + "web" + | "wasm" + | "ios-widget" + | "ios-widget-simulator" + | "watchos-widget" + | "watchos-widget-simulator" + | "android-widget" + | "wearos-tile" + ) + ) { + anyhow::bail!("typed-feedback capture/replay requires a native LLVM target"); + } + let profile = args + .typed_feedback_profile + .as_deref() + .map(Session::read_profile) + .transpose()?; + // No version-only fallback: unreadable compiler identity is an actionable + // error for explicit replay/capture, never permission to trust stale facts. + let executable = + std::env::current_exe().context("cannot identify compiler for typed-feedback replay")?; + let compiler = format!( + "sha256:{}", + hex::encode(Sha256::digest( + std::fs::read(&executable).context("cannot hash compiler for typed-feedback replay")? + )) + ); + Ok(Some(Session::new(compiler, profile))) +} + +pub(super) fn compile( + session: &Session, + hir: &perry_hir::Module, + opts: perry_codegen::CompileOptions, + path: &Path, + version: &str, +) -> Result> { + let source = std::fs::read(path) + .with_context(|| format!("cannot hash typed-feedback source {}", path.display()))?; + let hir_hash = perry_hir::stable_hash::hash_module(hir); + let identity = ModuleIdentity { + module: hir.name.clone(), + source_hash: format!("sha256:{}", hex::encode(Sha256::digest(&source))), + hir_hash: format!("{hir_hash:016x}"), + lowering_hash: format!( + "{:016x}", + super::object_cache::typed_feedback_lowering_key(&opts, hir_hash, version) + ), + target: perry_codegen::typed_feedback_profile::effective_target(&opts), + }; + session.compile_module(hir, opts, identity) +} diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index c19f502c1e..daaffb002a 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -336,6 +336,15 @@ pub struct CompileArgs { #[arg(long)] pub explain_lowering: bool, + /// Replay advisory typed-feedback observations with exact freshness checks. + #[arg(long)] + pub typed_feedback_profile: Option, + + /// Write a versioned site catalog to join with a runtime typed-feedback + /// trace. Compile with PERRY_TYPED_FEEDBACK=1 to record runtime sites. + #[arg(long)] + pub typed_feedback_sites: Option, + /// #504 — emit `.attest.json` next to the compiled /// executable. The sidecar carries SHA-256 of the binary + /// provenance (perry version, git commit, build timestamp) so diff --git a/crates/perry/src/commands/dev.rs b/crates/perry/src/commands/dev.rs index 22a9ebe73e..5b28e33f3a 100644 --- a/crates/perry/src/commands/dev.rs +++ b/crates/perry/src/commands/dev.rs @@ -317,6 +317,8 @@ fn build_once( verify_native_regions: false, disable_buffer_fast_path: false, explain_lowering: false, + typed_feedback_profile: None, + typed_feedback_sites: None, opt_report: None, statepoint_report: None, emit_attest: false, diff --git a/crates/perry/src/commands/run/mod.rs b/crates/perry/src/commands/run/mod.rs index b6f2512c95..c7fc514104 100644 --- a/crates/perry/src/commands/run/mod.rs +++ b/crates/perry/src/commands/run/mod.rs @@ -229,6 +229,8 @@ pub fn run(args: RunArgs, format: OutputFormat, use_color: bool, verbose: u8) -> verify_native_regions: false, disable_buffer_fast_path: false, explain_lowering: false, + typed_feedback_profile: None, + typed_feedback_sites: None, opt_report: None, statepoint_report: None, emit_attest: false, diff --git a/crates/perry/tests/issue_8907_macos_http_link.rs b/crates/perry/tests/issue_8907_macos_http_link.rs new file mode 100644 index 0000000000..a81f70820b --- /dev/null +++ b/crates/perry/tests/issue_8907_macos_http_link.rs @@ -0,0 +1,176 @@ +//! #8907: the v0.5.1220 macOS arm64 release could not link node:http. +//! +//! #5983 removed the external HTTP pump from the full stdlib. The #8587 +//! feature-graph guard protects that fix, but does not exercise the linker. +//! Stage a source-free installation with the full archives and compile the +//! reported server in both default and PERRY_NO_AUTO_OPTIMIZE modes. This +//! must work without a source checkout repairing the libraries via auto-opt. + +// The macOS/Linux CLI is self-contained. Windows additionally needs LLVM-C.dll +// staged beside the executable, which this Unix installation regression omits. +#![cfg(unix)] + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +const HTTP_FIXTURE: &str = include_str!("../../../test-files/test_issue_8907_http_link.ts"); +const ARCHIVES: [&str; 3] = [ + "libperry_runtime.a", + "libperry_stdlib.a", + "libperry_ext_http.a", +]; + +fn prebuilt_archives() -> Vec { + // A caller may supply a coherent, freshly built release bundle. Otherwise + // build all three archives in one Cargo graph so stdlib and ext-http share + // the same Tokio instance. Building only the missing wrapper can split it. + if let Some(dir) = std::env::var_os("PERRY_RUNTIME_DIR") { + let paths: Vec<_> = ARCHIVES + .iter() + .map(|name| PathBuf::from(&dir).join(name)) + .collect(); + if paths.iter().all(|path| path.is_file()) { + return paths; + } + } + + let build = Command::new(env!("CARGO")) + .current_dir(Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")) + .args([ + "build", + "--release", + "--message-format=json", + "-p", + "perry-runtime-static", + "-p", + "perry-stdlib-static", + "-p", + "perry-ext-http", + ]) + .output() + .expect("build coherent prebuilt HTTP archives"); + assert!( + build.status.success(), + "archive build failed:\n{}\n{}", + String::from_utf8_lossy(&build.stdout), + String::from_utf8_lossy(&build.stderr) + ); + let artifacts: Vec = String::from_utf8_lossy(&build.stdout) + .lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .filter(|item: &serde_json::Value| item["reason"] == "compiler-artifact") + .collect(); + ARCHIVES + .iter() + .map(|filename| { + artifacts + .iter() + .filter_map(|item| item["filenames"].as_array()) + .flatten() + .filter_map(|value| value.as_str()) + .map(PathBuf::from) + .find(|path| path.file_name().is_some_and(|file| file == *filename)) + .unwrap_or_else(|| panic!("Cargo did not produce {filename}")) + }) + .collect() +} + +fn run_with_timeout(binary: &Path, cwd: &Path) -> String { + // File-backed output keeps a failing fixture from blocking on a full pipe. + let stdout = tempfile::tempfile().expect("stdout file"); + let stderr = tempfile::tempfile().expect("stderr file"); + let mut child = Command::new(binary) + .current_dir(cwd) + .stdout(Stdio::from(stdout.try_clone().expect("clone stdout"))) + .stderr(Stdio::from(stderr.try_clone().expect("clone stderr"))) + .spawn() + .expect("run compiled fixture"); + let deadline = Instant::now() + Duration::from_secs(30); + let (status, timed_out) = loop { + if let Some(status) = child.try_wait().expect("poll fixture") { + break (status, false); + } + if Instant::now() >= deadline { + child.kill().expect("kill hung fixture"); + break (child.wait().expect("reap hung fixture"), true); + } + std::thread::sleep(Duration::from_millis(20)); + }; + use std::io::{Read, Seek}; + let read = |mut file: std::fs::File| { + file.rewind().expect("rewind output"); + let mut text = String::new(); + file.read_to_string(&mut text).expect("read output"); + text + }; + let stdout = read(stdout); + let stderr = read(stderr); + assert!( + !timed_out, + "{} did not exit within 30 seconds:\n{stdout}\n{stderr}", + binary.display() + ); + assert!( + status.success(), + "fixture failed: {status}\n{stdout}\n{stderr}" + ); + stdout +} + +#[test] +fn source_free_http_server_links_and_closes_in_both_modes() { + let archives = prebuilt_archives(); + let dir = tempfile::tempdir().expect("installation tempdir"); + let install = dir.path().join("install"); + let app = dir.path().join("app"); + std::fs::create_dir(&install).expect("create installation"); + std::fs::create_dir(&app).expect("create app directory"); + let compiler = install.join(format!("perry{}", std::env::consts::EXE_SUFFIX)); + std::fs::copy(env!("CARGO_BIN_EXE_perry"), &compiler).expect("stage compiler"); + for archive in archives { + std::fs::copy(&archive, install.join(archive.file_name().unwrap())).expect("stage archive"); + } + + for no_auto in [false, true] { + let mode = if no_auto { "no-auto" } else { "default" }; + for (name, source, expected) in [ + ("nohttp", "console.log('ok');", "ok\n"), + ("httpmin", HTTP_FIXTURE, "listening\nclosed\n"), + ] { + let entry = app.join(format!("{name}-{mode}.ts")); + let output = app.join(format!("{name}-{mode}{}", std::env::consts::EXE_SUFFIX)); + std::fs::write(&entry, source).expect("write fixture"); + let mut command = Command::new(&compiler); + command + .current_dir(&app) + .env_remove("PERRY_WORKSPACE_ROOT") + .env_remove("PERRY_DISABLE_WELL_KNOWN") + .env_remove("PERRY_FORCE_WELL_KNOWN") + .env_remove("PERRY_NO_AUTO_OPTIMIZE") + .env("PERRY_RUNTIME_DIR", &install) + .env("PERRY_LIB_DIR", &install) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output); + if no_auto { + command.env("PERRY_NO_AUTO_OPTIMIZE", "1"); + } + let compile = command.output().expect("compile fixture"); + let stdout = String::from_utf8_lossy(&compile.stdout); + let stderr = String::from_utf8_lossy(&compile.stderr); + assert!( + compile.status.success(), + "{name} ({mode}) failed to link (#8907):\n{stdout}\n{stderr}" + ); + if !no_auto { + assert!( + stderr.contains("Perry workspace source not found"), + "{name}: default mode must exercise the source-free fallback:\n{stdout}\n{stderr}" + ); + } + assert_eq!(run_with_timeout(&output, &app), expected, "{name} ({mode})"); + } + } +} diff --git a/crates/perry/tests/issue_9249_array_prototype_define_property.rs b/crates/perry/tests/issue_9249_array_prototype_define_property.rs index 9aa254290d..5a977e95e6 100644 --- a/crates/perry/tests/issue_9249_array_prototype_define_property.rs +++ b/crates/perry/tests/issue_9249_array_prototype_define_property.rs @@ -68,6 +68,30 @@ console.log(hits, nums.length, nums[7]); ); } +#[test] +fn default_array_prototype_setter_intercepts_in_bounds_holes() { + compile_and_run( + include_str!("../../../test-files/test_gap_9787_array_hole_inherited_setter.ts"), + "true:31 false array-proto-three 1\n\ + true:31,true:37 false array-proto-three 5\n\ + own 2 true 41 4\n\ + deleted true:31,true:37,true:43 false array-proto-three 4\n\ + removed true 47 5\n", + "array_prototype_holes", + ); +} + +#[test] +fn default_object_prototype_descriptors_intercept_in_bounds_holes() { + compile_and_run( + include_str!("../../../test-files/test_gap_9787_object_prototype_hole_setter.ts"), + "1 true 53 false object-proto-eight 10\n\ + TypeError false getter-only 10\n\ + TypeError false locked 10\n", + "object_prototype_holes", + ); +} + #[test] fn define_properties_array_prototype_index_setter_intercepts_boolean_store() { compile_and_run( diff --git a/crates/perry/tests/typed_feedback_profile.rs b/crates/perry/tests/typed_feedback_profile.rs new file mode 100644 index 0000000000..a579fc9369 --- /dev/null +++ b/crates/perry/tests/typed_feedback_profile.rs @@ -0,0 +1,249 @@ +//! #8504: exercise real capture/replay, stale-input isolation and JS parity. +#![cfg(unix)] +use serde_json::Value; +use std::path::Path; +use std::process::{Command, Output}; + +const SOURCE: &str = include_str!("../../../test-files/test_typed_feedback_profile_replay.ts"); + +fn success(output: Output) -> Output { + assert!( + output.status.success(), + "status={}\nstdout={}\nstderr={}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + output +} +fn compile(dir: &Path, name: &str, args: &[&str], instrument: bool) -> Output { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_perry")); + cmd.current_dir(dir) + .args(["compile", "main.ts", "-o", name, "--no-cache"]) + .args(args) + .env_remove("PERRY_TYPED_FEEDBACK") + .env_remove("PERRY_TYPED_FEEDBACK_TRACE"); + if instrument { + cmd.env("PERRY_TYPED_FEEDBACK", "1"); + } + cmd.output().unwrap() +} +fn run(dir: &Path, name: &str, disagree: bool, trace: Option<&Path>) -> Output { + let mut cmd = Command::new(dir.join(name)); + cmd.current_dir(dir) + .env_remove("PERRY_TYPED_FEEDBACK") + .env_remove("PERRY_TYPED_FEEDBACK_TRACE"); + if disagree { + cmd.arg("disagree"); + } + if let Some(trace) = trace { + cmd.env("PERRY_TYPED_FEEDBACK_TRACE", trace); + } + success(cmd.output().unwrap()) +} +fn read_json(path: &Path) -> Value { + serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap() +} + +#[test] +fn capture_replay_guard_failure_and_semantic_parity() { + let temp = tempfile::tempdir().unwrap(); + let dir = temp.path(); + std::fs::write(dir.join("main.ts"), SOURCE).unwrap(); + success(compile( + dir, + "capture", + &["--typed-feedback-sites", "sites.json"], + true, + )); + let trace_path = dir.join("capture-trace.json"); + let captured = run(dir, "capture", false, Some(&trace_path)); + let script = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../scripts/typed-feedback-profile.py"); + success( + Command::new("python3") + .arg(&script) + .current_dir(dir) + .args([ + "--sites", + "sites.json", + "--trace", + "capture-trace.json", + "-o", + "profile.json", + ]) + .output() + .unwrap(), + ); + // Conversion is deterministic too, independent of output path. + success( + Command::new("python3") + .arg(script) + .current_dir(dir) + .args([ + "--sites", + "sites.json", + "--trace", + "capture-trace.json", + "-o", + "profile2.json", + ]) + .output() + .unwrap(), + ); + assert_eq!( + std::fs::read(dir.join("profile.json")).unwrap(), + std::fs::read(dir.join("profile2.json")).unwrap() + ); + let replay_compile = success(compile( + dir, + "replay", + &[ + "--typed-feedback-profile", + "profile.json", + "--explain-lowering", + ], + true, + )); + let stderr = String::from_utf8_lossy(&replay_compile.stderr); + assert!( + stderr.contains("[typed-feedback-replay] accepted"), + "{stderr}" + ); + assert!(stderr.contains("fresh_numeric_array_observation")); + assert_eq!(captured.stdout, run(dir, "replay", false, None).stdout); + let disagree_trace = dir.join("disagree-trace.json"); + let replay = run(dir, "replay", true, Some(&disagree_trace)); + let trace = read_json(&disagree_trace); + let guarded: Vec<_> = trace["sites"] + .as_array() + .unwrap() + .iter() + .filter(|s| s["guard_name"] == "numeric_array_index_get_guard") + .collect(); + assert!( + guarded + .iter() + .any(|s| s["guard_failures"].as_u64().unwrap_or(0) > 0 + && s["fallback_calls"].as_u64().unwrap_or(0) > 0), + "{trace}" + ); + success(compile(dir, "baseline", &[], false)); + assert_eq!(run(dir, "baseline", true, None).stdout, replay.stdout); + success(compile( + dir, + "replay-normal", + &[ + "--typed-feedback-profile", + "profile.json", + "--verify-native-regions", + ], + false, + )); + assert_eq!(run(dir, "replay-normal", true, None).stdout, replay.stdout); + // Node sees the exact same JS after stripping these three TS annotations. + let js = SOURCE + .replace(": any[]", "") + .replace(": any", "") + .replace(": number", ""); + std::fs::write(dir.join("main.js"), js).unwrap(); + let node = success( + Command::new("node") + .current_dir(dir) + .args(["main.js", "disagree"]) + .output() + .unwrap(), + ); + assert_eq!(node.stdout, replay.stdout); + let mut stale_profile = read_json(&dir.join("profile.json")); + for module in stale_profile["modules"].as_array_mut().unwrap() { + module["identity"]["source_hash"] = Value::String("stale".into()); + } + std::fs::write( + dir.join("stale.json"), + serde_json::to_vec(&stale_profile).unwrap(), + ) + .unwrap(); + let stale_compile = success(compile( + dir, + "stale.o", + &[ + "--typed-feedback-profile", + "stale.json", + "--explain-lowering", + "--no-link", + ], + false, + )); + let stale_stderr = String::from_utf8_lossy(&stale_compile.stderr); + assert!( + stale_stderr.contains("source_hash_mismatch"), + "{stale_stderr}" + ); + assert!(!stale_stderr.contains("[typed-feedback-replay] accepted")); + let reports: Vec<_> = std::fs::read_dir(dir.join(".perry-trace/lowering")) + .unwrap() + .map(|e| read_json(&e.unwrap().path().join("explain-lowering.json"))) + .collect(); + assert!(reports + .iter() + .any(|r| r["summary"]["typed_path_selection_reason_counts"] + ["typed_feedback_replay:fresh_numeric_array_observation"] + .as_u64() + .unwrap_or(0) + > 0)); + assert!(reports + .iter() + .any(|r| r["summary"]["typed_path_rejection_reason_counts"] + ["typed_feedback_replay:source_hash_mismatch"] + .as_u64() + .unwrap_or(0) + > 0)); +} + +#[test] +fn explicit_malformed_profile_has_actionable_diagnostic() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join("main.ts"), "console.log(1)").unwrap(); + for invalid in ["{", "{}", "{\"schema_version\":\"one\"}"] { + std::fs::write(temp.path().join("bad.json"), invalid).unwrap(); + let result = compile( + temp.path(), + "unused", + &["--typed-feedback-profile", "bad.json"], + false, + ); + assert!(!result.status.success()); + let stderr = String::from_utf8_lossy(&result.stderr); + assert!( + stderr.contains("invalid --typed-feedback-profile"), + "{stderr}" + ); + assert!( + stderr.contains("scripts/typed-feedback-profile.py"), + "{stderr}" + ); + } + std::fs::write( + temp.path().join("future.json"), + r#"{"schema_version": 2, "future_schema_body": []}"#, + ) + .unwrap(); + let future = success(compile( + temp.path(), + "future.o", + &["--typed-feedback-profile", "future.json", "--no-link"], + false, + )); + assert!(String::from_utf8_lossy(&future.stderr).contains("schema_mismatch")); + let result = compile( + temp.path(), + "unused", + &["--typed-feedback-profile", "missing.json"], + false, + ); + assert!(!result.status.success()); + assert!( + String::from_utf8_lossy(&result.stderr).contains("cannot read --typed-feedback-profile") + ); +} diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 6a929a8681..b85fd44cf8 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -155,6 +155,7 @@ - [Geisterhand (UI Fuzzer)](testing/geisterhand.md) - [Node Compatibility Matrix](testing/node-compat-matrix.md) - [CI Tiers (PR gate / sweep / full)](testing/ci-tiers.md) +- [Claude Code Bundle Parity](testing/cc-parity.md) - [CI Gate Scheduling](testing/ci-gate-scheduling.md) # CLI Reference diff --git a/docs/src/cli/flags.md b/docs/src/cli/flags.md index f407739255..92630ae062 100644 --- a/docs/src/cli/flags.md +++ b/docs/src/cli/flags.md @@ -465,3 +465,87 @@ perry app.ts -o app --target web --minify - [Commands](commands.md) — All CLI commands - [Platform Overview](../platforms/overview.md) — Platform targets + +## Typed-feedback profile replay + +`--typed-feedback-profile ` supplies an **advisory** profile to native LLVM +lowering. Default builds do not read a profile. The first supported observation, +`numeric_array_element`, can select the existing guarded numeric-array read at +an otherwise generic checked `array[index]` site. Already specialized reads and +other site/observation kinds are ignored and explained. + +Capture a workload, then replay it with the **same compiler executable, source, +target and lowering options**: + +```bash +PERRY_TYPED_FEEDBACK=1 perry compile app.ts -o app-capture \ + --typed-feedback-sites typed-feedback-sites.json +PERRY_TYPED_FEEDBACK_TRACE=typed-feedback-trace.json ./app-capture +python3 scripts/typed-feedback-profile.py \ + --sites typed-feedback-sites.json --trace typed-feedback-trace.json \ + -o typed-feedback-profile.json +perry compile app.ts -o app \ + --typed-feedback-profile typed-feedback-profile.json --explain-lowering +``` + +The conversion utility is in the Perry source checkout. Pair the catalog with +the trace from that exact capture build. It retains only observed numeric array +reads and excludes runtime addresses, shape IDs and method identities. A trace +without supported observations produces a diagnostic instead of an empty profile. +The capture build must enable `PERRY_TYPED_FEEDBACK` at compile time; enabling it +only when running a normal binary cannot restore omitted instrumentation. + +The JSON replay schema is version 1: + +```json +{ + "schema_version": 1, + "compiler": "sha256:", + "modules": [{ + "identity": { + "module": "app.ts", + "source_hash": "sha256:", + "hir_hash": "", + "lowering_hash": "", + "target": "x86_64-unknown-linux-gnu" + }, + "sites": [{ + "site_id": 123, + "function": "perry_fn_app_ts__read", + "kind": "array_element", + "operation": "array[index]", + "observation_kind": "numeric_array_element" + }] + }] +} +``` + +Use generated identities, rather than copying this illustrative site ID. Site +IDs identify deterministic lowering sites within an exact module/compiler/input +combination; function, kind and operation must also match. The lowering hash +includes target CPU/features, codegen settings and imported capabilities using +the object cache's complete input fingerprint. Capture instrumentation and +native-region reporting/verification are excluded so they can vary during replay. +Even a comment-only source change invalidates the source hash, and rebuilding +Perry invalidates the compiler hash without needing a version bump. No +cross-version or best-effort stale replay is attempted. + +Malformed or unreadable explicit input is a compilation error naming the profile +and how to create it. Well-formed schema/compiler/target/source/HIR/options +mismatches, unknown modules/sites, duplicate identities and unsupported +observations are ignored for specialization. Each rejected fact is reported on +stderr with its reason. Accepted and rejected facts also appear in native-rep +artifacts (`PERRY_NATIVE_REPS=1`) and `--explain-lowering`'s typed-path evidence and +reason counts. Replay and catalog builds bypass build/object cache reuse to +produce evidence from this compilation. Artifact filenames and report paths have +run-specific nonces; decisions and lowering are deterministic for identical inputs. + +Profiles and TypeScript annotations never authorize an unchecked operation. +Every replay-selected read rechecks the live receiver, array representation, +descriptors/prototype state and bounds with the existing numeric-array runtime +guard. Strings, holes, changed layouts, non-array receivers and other guard +failures use the original boxed JavaScript fallback, with no added number +coercion. Replay does not relax ownership, alias, lifetime or method-identity +checks. Native-region verification requires a consumed fresh replay fact, +a matching runtime guard and an explicit fallback/materialization record for +every claimed profile selection. diff --git a/docs/src/testing/cc-parity.md b/docs/src/testing/cc-parity.md new file mode 100644 index 0000000000..891dbf2c6f --- /dev/null +++ b/docs/src/testing/cc-parity.md @@ -0,0 +1,101 @@ +# Claude Code bundle parity + +The `cc-parity` workflow compiles the standalone Claude Code **2.1.112** npm +bundle with Perry and compares native `--help` and `--version` stdout with +checked-in Node output. It covers the bundle-scale regressions described in +[#9346](https://github.com/PerryTS/perry/issues/9346). + +## Opt in + +Apply **`run-cc-parity`** to a PR changing compiler/runtime crates, build inputs, +or the gate itself. The workflow also supports manual dispatch. Unlabelled PRs +skip every job; labelled documentation-only PRs skip the expensive job. A new +commit supersedes the previous run on the same PR. + +This starts as a **non-required** check. Adding it to branch protection is a +separate maintainer decision after successful hosted runs. It has no push, +schedule, or release-tag trigger and is independent of `run-extended-tests`. + +The expensive job uses one `macos-15-intel` runner. Its +[14 GB RAM allocation](https://docs.github.com/en/actions/reference/runners/github-hosted-runners) +provides more headroom for bundle IR construction than the 7 GB ARM runner. +The job removes unused simulator images and disables Cargo incremental artifacts +to leave disk space for LLVM and the native archives. The issue estimated 25–40 +minutes for bundle compilation; local validation took **57 minutes 25 seconds** +on macOS arm64 with five LLVM workers. The hosted Intel run with four workers +remains to be measured. Allow additional time for toolchain setup, especially on +a cold cache. The job has a 90-minute cap, compilation a 75-minute cap, and each +CLI invocation a 60-second cap. Timings are recorded for diagnosis, not compared +with a performance threshold. + +## What the check proves + +`tests/cc-parity/manifest.json` pins the npm tarball and extracted `package/cli.js` +by both size and SHA-256. Only that regular file is extracted; no package install +hooks run. The compiler is built first, then the runtime, stdlib, Wasm host, and +all native extension archives are built together with `perry-runtime/wasm-host`. +This avoids stale runtime copies in extension archives (#6303). Compilation uses +`--no-auto-optimize --no-cache --enable-wasm-runtime`, with four LLVM workers +(`PERRY_CODEGEN_UNIT_JOBS=4`) to use the Intel runner's four cores within its +memory budget. + +The runtime arm requires a native Mach-O executable. Each invocation gets its own +temporary HOME, XDG directories, working directory, and TMPDIR, with a small +environment allowlist and no inherited credentials or compiler tuning knobs. +macOS `sandbox-exec` denies network access; the gate fails if that sandbox is +unavailable. The harness tests include an attempted connection to prove the +network restriction is active. + +Both commands must exit zero before their deadlines and produce exactly the +golden bytes: **9,175 bytes** for help and **22 bytes** for version. A crash, +timeout, empty output, or one-byte difference fails. The manifest also pins the +goldens themselves, so changing a golden without updating its identity fails. + +Downloading the bundle, LLVM, and Rust dependencies requires network access +during setup. The two CLI executions are offline and use no Node installation +or API key. The artifact contains source identity, build/compile logs, actual +stdout/stderr, and JSON results; it excludes the downloaded bundle and executable. + +## Run locally on macOS + +From the repository root, with LLVM 22 and the pinned Rust toolchain available: + +```bash +export LLVM_SYS_221_PREFIX="$(brew --prefix llvm@22)" +export CARGO_BUILD_JOBS=4 +cc_work="$(mktemp -d)" +python3 -m unittest discover -s tests -p test_cc_parity_gate.py -v +python3 scripts/cc_parity_gate.py prepare --work-dir "$cc_work" +python3 scripts/cc_parity_gate.py build --work-dir "$cc_work" +python3 scripts/cc_parity_gate.py compile --timeout 4500 --work-dir "$cc_work" --perry "$PWD/target/perry-dev/perry" +python3 scripts/cc_parity_gate.py check --work-dir "$cc_work" +``` + +If using `CARGO_TARGET_DIR`, pass the compiler in that directory instead. A local +tarball can be supplied to `prepare --archive `; the same hashes are still +required. Inspect `$cc_work/logs/` for output differences and failure details. +Never run the bundle using your regular HOME: Claude can write its configuration +even on startup paths. + +## Refresh the pin and oracle deliberately + +2.1.112 is a standalone `cli.js` release. A newer package may have a different +distribution shape; confirm it still supplies the full bundle before changing +the pin. Update the manifest's version, URL, archive identity, and bundle identity +from the exact public npm tarball, then run `prepare` again. + +The recorded reference used Node **v26.5.1** on macOS arm64. To verify that oracle +with the same scratch environment and network sandbox: + +```bash +python3 scripts/cc_parity_gate.py check --work-dir "$cc_work" --node "$(command -v node)" +``` + +This writes `logs/node-help.stdout`, `logs/node-version.stdout`, and +`logs/node-parity.json`. A deliberate version refresh may fail the old golden +comparison; inspect both command results, require zero exit codes and no timeout, +and review the output changes before copying those two stdout files into +`tests/cc-parity/`. Update their byte counts and SHA-256 values and the oracle +provenance in the manifest. Rerun the Node check, harness tests, native compilation, +and native check. Commit the manifest and goldens together; never accept output +from a failing native executable as the new oracle. diff --git a/docs/src/testing/ci-tiers.md b/docs/src/testing/ci-tiers.md index 0e291d9e08..318f20d420 100644 --- a/docs/src/testing/ci-tiers.md +++ b/docs/src/testing/ci-tiers.md @@ -141,6 +141,10 @@ window (`previous sweep SHA .. this sweep SHA`), exactly as for the six-hourly g ## Opting a PR into more +- **`run-cc-parity` label** — runs the [Claude Code bundle parity gate](cc-parity.md) + on changes to crates, build inputs, or the gate. This independent, initially + non-required check compiles pinned Claude Code 2.1.112 and compares offline + native help/version output with checked-in Node goldens on one macOS runner. - **`run-extended-tests` label** — promotes the PR's `test.yml` run to the `full` tier AND enables the PR arm of every satellite gate. Use it for GC / codegen changes that should be measured before merge, and for anything touching a diff --git a/scripts/addr_class_allowlist.txt b/scripts/addr_class_allowlist.txt index e3511c2ac8..5f0a030f52 100644 --- a/scripts/addr_class_allowlist.txt +++ b/scripts/addr_class_allowlist.txt @@ -170,3 +170,5 @@ crates/perry-runtime/src/arena/page_meta.rs | let header = addr as *const GcHead crates/perry-runtime/src/arena/tests_promoted_runs.rs | * | arena promoted-run tests: header addresses are offsets into a buffer the test itself allocated and initialised, never NaN-box payloads -- same discipline as the arena/tests.rs entry above crates/perry-runtime/src/box/release_tests.rs | * | async-box release tests: the closure whose GcHeader is read is allocated by the test itself, and the test needs a MUTABLE header to toggle GC_FLAG_MARKED and restore it -- try_read_gc_header yields a shared ref, so it cannot express this. Same discipline as the arena/tests_promoted_runs.rs entry above. crates/perry-ext-typescript/src/bun.rs | const HANDLE_BAND_MAX: usize = 0x100000; | #9219: raw_heap_address must reject the fetch/zlib/proxy handle bands (real addresses on Linux; macOS hides it), but this crate links only perry-ffi and cannot import value::addr_class::HANDLE_BAND_MAX. The literal is a documented mirror of that constant, used solely as the >= floor — no other band arithmetic here. Delete it if perry-ffi ever re-exports the predicate. +crates/perry-runtime/src/arena/page_meta.rs | (0x1000_0000, 0x1010_0000, 7, 0x10_0000), | #9779 test fixture, not classification: two synthetic 1 MiB block ranges with a 1 MiB hole between them, inside `#[test] fn block_range_lookup_respects_gaps_and_ends`. They are inputs to `old_arena_block_range_index`, asserting a gap is not attributed to the block below it — no runtime address is classified against them. +crates/perry-runtime/src/arena/page_meta.rs | (0x1020_0000, 0x1030_0000, 9, 0x10_0000), | #9779 test fixture — the second of the two synthetic block ranges above. diff --git a/scripts/cc_parity_gate.py b/scripts/cc_parity_gate.py new file mode 100644 index 0000000000..4828d93d17 --- /dev/null +++ b/scripts/cc_parity_gate.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Pinned Claude Code native parity gate. Runtime checks require macOS Seatbelt.""" + +import argparse +import hashlib +import io +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import tarfile +import tempfile +import time +import urllib.request + +ROOT = Path(__file__).resolve().parents[1] +CORPUS = ROOT / "tests/cc-parity" +SANDBOX = ["/usr/bin/sandbox-exec", "-p", "(version 1) (allow default) (deny network*)"] +CASES = ("help", "version") + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def verify(data, expected, description): + if len(data) != expected["bytes"] or digest(data) != expected["sha256"]: + raise ValueError(f"{description}: size/SHA-256 mismatch") + + +def write_json(path, value): + path.write_text(json.dumps(value, indent=2) + "\n") + + +def prepare(work, manifest, archive_path=None): + if archive_path: + archive = archive_path.read_bytes() + else: + with urllib.request.urlopen(manifest["archive"]["url"], timeout=120) as response: + archive = response.read() + verify(archive, manifest["archive"], "npm archive") + # Never extract paths, symlinks, or executable package hooks from the archive. + with tarfile.open(fileobj=io.BytesIO(archive), mode="r:gz") as package: + member = package.getmember("package/cli.js") + if not member.isfile(): + raise ValueError("package/cli.js is not a regular file") + with package.extractfile(member) as source: + bundle = source.read() + verify(bundle, manifest["bundle"], "cli.js") + (work / "cli.js").write_bytes(bundle) + write_json(work / "logs/source.json", manifest) + + +def build_toolchain(work): + metadata = json.loads(subprocess.check_output( + ["cargo", "metadata", "--no-deps", "--format-version", "1"], cwd=ROOT + )) + base = ["cargo", "build", "--locked", "--profile", "perry-dev"] + # The compiler uses the default runtime without external Wasm symbols. + commands = [base + ["-p", "perry"]] + packages = ["perry-runtime-static", "perry-stdlib-static", "perry-wasm-host"] + packages += sorted(p["name"] for p in metadata["packages"] if p["name"].startswith("perry-ext-")) + # Unify wasm-host in EVERY archive embedding runtime code (#6303). + runtime = base + ["--features", "perry-runtime/wasm-host"] + for package in packages: + runtime += ["-p", package] + commands.append(runtime) + with (work / "logs/build.log").open("w") as log: + for command in commands: + print(" ".join(command), flush=True) + subprocess.run(command, cwd=ROOT, stdout=log, stderr=subprocess.STDOUT, check=True) + + +def run_logged(command, cwd, env, stdout, stderr, timeout): + started = time.monotonic() + with stdout.open("wb") as out, stderr.open("wb") as err: + process = subprocess.Popen( + command, cwd=cwd, env=env, stdout=out, stderr=err, start_new_session=True + ) + timed_out = False + try: + process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + timed_out = True + os.killpg(process.pid, signal.SIGKILL) + process.wait() + return { + "exit_code": process.returncode, + "timed_out": timed_out, + "seconds": round(time.monotonic() - started, 3), + "stdout_bytes": stdout.stat().st_size, + "stdout_sha256": digest(stdout.read_bytes()), + } + + +def compile_bundle(work, manifest, perry, timeout): + verify((work / "cli.js").read_bytes(), manifest["bundle"], "cli.js") + binary = work / "claude-native" + binary.unlink(missing_ok=True) # A failed rebuild must never reuse an old executable. + env = {key: value for key, value in os.environ.items() if not key.startswith("PERRY_")} + env.update(PERRY_RUNTIME_DIR=str(perry.parent), PERRY_NO_AUTO_OPTIMIZE="1", PERRY_NO_CACHE="1", + PERRY_CODEGEN_UNIT_JOBS="4") + command = [str(perry), "compile", "--no-auto-optimize", "--no-cache", + "--enable-wasm-runtime", str(work / "cli.js"), "-o", str(binary)] + result = run_logged(command, work, env, work / "logs/compile.stdout", + work / "logs/compile.stderr", timeout) + write_json(work / "logs/compile.json", {"command": command, **result}) + if result["exit_code"] != 0 or result["timed_out"]: + raise ValueError("native compilation failed; see logs/compile.stderr and compile.json") + require_native(binary) + + +def require_native(binary): + # This gate runs on macOS; a Node wrapper must not satisfy the native arm. + with binary.open("rb") as executable: + magic = executable.read(4) + if magic not in (b"\xcf\xfa\xed\xfe", b"\xfe\xed\xfa\xcf"): + raise ValueError(f"{binary}: expected a 64-bit Mach-O executable") + if not os.access(binary, os.X_OK): + raise ValueError(f"{binary}: not executable") + + +def scratch_env(directory): + # Do not inherit credentials, user configuration, or caller's PERRY_* knobs. + return { + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "HOME": str(directory), + "XDG_CONFIG_HOME": str(directory / "config"), + "XDG_CACHE_HOME": str(directory / "cache"), + "XDG_STATE_HOME": str(directory / "state"), + "TMPDIR": str(directory), + "LANG": "C.UTF-8", "LC_ALL": "C.UTF-8", "TERM": "dumb", + "CI": "1", "NO_COLOR": "1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", + } + + +def check(work, manifest, corpus=CORPUS, timeout=60, node=None): + if sys.platform != "darwin" or not Path(SANDBOX[0]).is_file(): + raise ValueError("offline execution requires macOS sandbox-exec; no unsandboxed fallback") + if node: + verify((work / "cli.js").read_bytes(), manifest["bundle"], "cli.js") + command = [str(node), str(work / "cli.js")] + prefix = "node-" + else: + binary = work / "claude-native" + require_native(binary) + command = [str(binary)] + prefix = "" + report = {"bundle_sha256": manifest["bundle"]["sha256"], "cases": {}} + for case in CASES: + expected = (corpus / f"{case}.stdout").read_bytes() + verify(expected, manifest["goldens"][case], f"{case} golden") + stdout = work / f"logs/{prefix}{case}.stdout" + stderr = work / f"logs/{prefix}{case}.stderr" + with tempfile.TemporaryDirectory(prefix=f"cc-parity-{case}-") as scratch: + directory = Path(scratch) + result = run_logged(SANDBOX + command + [f"--{case}"], directory, + scratch_env(directory), stdout, stderr, timeout) + result["matches_golden"] = stdout.read_bytes() == expected + result["passed"] = (result["exit_code"] == 0 and not result["timed_out"] + and result["matches_golden"]) + report["cases"][case] = result + print(f"{case}: {'PASS' if result['passed'] else 'FAIL'} {json.dumps(result)}", flush=True) + write_json(work / f"logs/{prefix}parity.json", report) + if not all(result["passed"] for result in report["cases"].values()): + raise ValueError("Claude Code parity failed; compare logs/*.stdout with tests/cc-parity/*.stdout") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=("prepare", "build", "compile", "check")) + parser.add_argument("--work-dir", type=Path, required=True) + parser.add_argument("--archive", type=Path, help="use a local pinned npm archive during prepare") + parser.add_argument("--perry", type=Path, help="fresh compiler next to coherently built runtime archives") + parser.add_argument("--node", type=Path, help="check the Node oracle locally instead of the native executable") + parser.add_argument("--timeout", type=float, help="seconds; default compile 3600, check 60") + args = parser.parse_args() + work = args.work_dir.resolve() + (work / "logs").mkdir(parents=True, exist_ok=True) + manifest = json.loads((CORPUS / "manifest.json").read_text()) + try: + if args.command == "prepare": + prepare(work, manifest, args.archive) + elif args.command == "build": + build_toolchain(work) + elif args.command == "compile": + if args.perry is None: + parser.error("compile requires --perry") + compile_bundle(work, manifest, args.perry.resolve(), args.timeout or 3600) + else: + check(work, manifest, timeout=args.timeout or 60, + node=args.node.resolve() if args.node else None) + except (OSError, ValueError, tarfile.TarError, subprocess.CalledProcessError) as error: + print(f"cc-parity: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 169f1cfc50..c942984b5e 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -130,6 +130,12 @@ "verdict": "not_a_gc_pointer", "why": "HashSet of perry-ffi registry handle ids already found stale, kept only to log each once. Registry ids are indices into the ffi DashMap, not heap addresses." }, + { + "file": "crates/perry-runtime/src/alloc_census.rs", + "name": "CREDIT", + "verdict": "not_a_gc_pointer", + "why": "#9771: bytes remaining to allocate before the next native-heap sample. A `Cell` counter, const-initialised so the TLS access itself never allocates." + }, { "file": "crates/perry-runtime/src/async_hooks.rs", "name": "ASYNC_HOOK_HANDLES", @@ -264,7 +270,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.", + "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.", "window": { "start": { "file": "crates/perry-runtime/src/gc/census.rs", @@ -279,9 +285,9 @@ "function": "run_to_completion" }, "sources": { - "crates/perry-runtime/src/gc/census.rs": "0d5c6fcec6500692f702c8e7422e255b224de968febdb441b235b14358684b00", + "crates/perry-runtime/src/gc/census.rs": "8050a9d1ca15f783195ccfa5963089b60bc4a6c31d9ced5755537623e70e7e3c", "crates/perry-runtime/src/gc/cycle.rs": "4acea623de941aac70d38a4c993a3cd23135152e51f24b11bacd3d68e2571208", - "crates/perry-runtime/src/gc/mod.rs": "6d138d48e496160e711fa4389f5fd9eb12787e0a869b87da3e5a8c27719ef3ee", + "crates/perry-runtime/src/gc/mod.rs": "ae00e84027b5b442c4a4723309b3e8f6911bc8f592c2ecd5678024e7c244ee26", "crates/perry-runtime/src/gc/policy.rs": "319ed42f1a985c88f6362657a08518077283fe5216d6055fc82343b34dec50f9", "crates/perry-runtime/src/gc/progress.rs": "a5ad3971bbe4047229ca57325234780daa85921dbc778e1c08dff4ad07ccfb96" } @@ -299,6 +305,12 @@ "verdict": "test_only", "why": "Declared under cfg(test); holds an explicitly leaked Rust path string used by the isolated census unit tests." }, + { + "file": "crates/perry-runtime/src/gc/oldgen_defrag.rs", + "name": "LAST_IDLE_PREDICTED_RELEASE", + "verdict": "not_a_gc_pointer", + "why": "#9772: releasable block BYTES the last idle selection promised \u2014 a size, not an address. A `Cell` compared against what the collection actually released." + }, { "file": "crates/perry-runtime/src/gc/trace.rs", "name": "FORWARDED_STUB_MEMBERSHIP_RECOVERIES", diff --git a/scripts/shape_descriptor_census.py b/scripts/shape_descriptor_census.py index 405ecfddb2..927bc0d098 100644 --- a/scripts/shape_descriptor_census.py +++ b/scripts/shape_descriptor_census.py @@ -403,10 +403,25 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: # the tombstone-delete work; `_with_generation` is a thin forwarding # wrapper. The authority ordering is checked where the writes are. ensure = function_body(shapes, "shape_descriptor_ensure_with_holes") + # The property is that the by-id descriptor is installed BEFORE the reverse + # accelerator points at it — never which append spells it. #9768 added + # `family_append_fresh`, which is `family_push_back` minus a membership scan + # that is dead work for an id `alloc_shape_id` just minted and never reuses. + # Both append to the same family list, so pinning only the older name made a + # strictly cheaper append look like a lost ordering guarantee. + ensure_append = next( + (m for m in ("family_append_fresh", "family_push_back") if m in ensure), + None, + ) + if ensure_append is None: + raise CensusError( + "shape descriptor authority surface missing: family append in " + "shape_descriptor_ensure_with_holes" + ) assert_before( ensure, "slab_mut().insert", - "family_push_back", + ensure_append, "by-id descriptor before reverse accelerator", ) sync = function_body(shapes, "publish_object_shape_from") diff --git a/scripts/typed-feedback-profile.py b/scripts/typed-feedback-profile.py new file mode 100644 index 0000000000..17c37fd8e7 --- /dev/null +++ b/scripts/typed-feedback-profile.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Join a same-build site catalog and runtime trace into an advisory replay profile.""" +import argparse +import copy +import json +from pathlib import Path + + +def make_profile(catalog, trace): + if catalog.get("schema_version") != 1: + raise ValueError("unsupported site catalog schema_version (expected 1)") + rows = {} + for row in trace["sites"]: + key = (row["site_id"], row["function"], row["kind"], row["operation"]) + if key in rows: + raise ValueError(f"duplicate runtime trace site: {key}") + rows[key] = row + profile = copy.deepcopy(catalog) + selected = 0 + for module in profile["modules"]: + sites = [] + for site in module["sites"]: + key = (site["site_id"], site["function"], site["kind"], site["operation"]) + row = rows.get(key) + if row is None or not row.get("observed_count", 0): + continue + observations = row.get("observed_kinds", []) + # Consume only stable, pointer-free numeric observations. Runtime + # addresses, shape IDs, and method/closure identities never replay. + if (site["kind"] == "array_element" and site["operation"] == "array[index]" + and observations and all( + obs.get("source") == "array" + and obs.get("heap_type") == "array" + and obs.get("array_access") == "indexed_in_bounds" + and obs.get("array_element_kind") in ("number", "int32") + for obs in observations)): + site["observation_kind"] = "numeric_array_element" + sites.append(site) + selected += 1 + module["sites"] = sorted(sites, key=lambda site: site["site_id"]) + profile["modules"].sort(key=lambda module: module["identity"]["module"]) + if not selected: + raise ValueError("trace contains no supported numeric array-read observations; compile with PERRY_TYPED_FEEDBACK=1 and exercise an array[index] read") + return profile + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--sites", required=True, type=Path, help="--typed-feedback-sites catalog from the instrumented build") + parser.add_argument("--trace", required=True, type=Path, help="typed-feedback-trace.json from that same build") + parser.add_argument("-o", "--output", required=True, type=Path) + args = parser.parse_args() + try: + profile = make_profile(json.loads(args.sites.read_text()), json.loads(args.trace.read_text())) + args.output.write_text(json.dumps(profile, indent=2, sort_keys=True) + "\n") + except (OSError, ValueError, KeyError, TypeError) as error: + parser.exit(2, f"typed-feedback-profile: {error}\n") + + +if __name__ == "__main__": + main() diff --git a/test-files/test_gap_9721_forward_const_initialization.ts b/test-files/test_gap_9721_forward_const_initialization.ts new file mode 100644 index 0000000000..6c293944ae --- /dev/null +++ b/test-files/test_gap_9721_forward_const_initialization.ts @@ -0,0 +1,28 @@ +type Off = () => string; +const listeners: (() => string)[] = []; +const ev = { on(cb: () => string): Off { listeners.push(cb); return () => "off-called"; } }; + +function main(): void { + const fact = (n: number): number => (n <= 1 ? 1 : n * fact(n - 1)); + console.log("fact=" + fact(5)); + + const off = ev.on(() => off()); + console.log("off=" + listeners[0]!()); + + const sub = { unsub: (): string => "unsubbed" }; + const sub2 = ((o: { next: () => string }) => { listeners.push(o.next); return sub; })({ next: () => sub2.unsub() }); + console.log("sub2=" + listeners[1]!()); + + const a = (): string => b() + "/a", + b = (): string => "b"; + console.log("multi=" + a()); + + const fib = function rec(n: number): number { return n < 2 ? n : rec(n - 1) + rec(n - 2); }; + console.log("fib=" + fib(10)); + + // mutual recursion across statements + const even = (n: number): boolean => (n === 0 ? true : odd(n - 1)); + const odd = (n: number): boolean => (n === 0 ? false : even(n - 1)); + console.log("even10=" + even(10) + " odd7=" + odd(7)); +} +main(); diff --git a/test-files/test_gap_9721_tdz_binding_names.ts b/test-files/test_gap_9721_tdz_binding_names.ts new file mode 100644 index 0000000000..111684f713 --- /dev/null +++ b/test-files/test_gap_9721_tdz_binding_names.ts @@ -0,0 +1,45 @@ +// Genuine dead-zone reads must name the source binding, including captures. +function captures(): void { + const read = () => later(); + try { read(); } catch (error) { console.log(error.name, error.message); } + const later = () => "initialized"; + console.log(read(), later()); +} + +function localAndCaptured(): void { + const read = () => value; + try { console.log(value); } catch (error) { console.log(error.name, error.message); } + try { read(); } catch (error) { console.log(error.name, error.message); } + let value = 41; + value++; + console.log(read()); +} + +function typeOfAndUpdate(): void { + const type = () => typeof count; + const update = () => count++; + try { type(); } catch (error) { console.log(error.name, error.message); } + try { update(); } catch (error) { console.log(error.name, error.message); } + let count = 10; + console.log(type(), update(), count); +} + +function nestedNames(): void { + const value = "outer"; + { + const read = () => value; + try { read(); } catch (error) { console.log(error.name, error.message); } + const value = "inner"; + console.log(read()); + } + console.log(value); + const readUnicode = () => café; + try { readUnicode(); } catch (error) { console.log(error.name, error.message); } + const café = "ready"; + console.log(readUnicode()); +} + +captures(); +localAndCaptured(); +typeOfAndUpdate(); +nestedNames(); diff --git a/test-files/test_gap_9787_array_hole_inherited_setter.ts b/test-files/test_gap_9787_array_hole_inherited_setter.ts new file mode 100644 index 0000000000..33f25dd423 --- /dev/null +++ b/test-files/test_gap_9787_array_hole_inherited_setter.ts @@ -0,0 +1,36 @@ +// An in-bounds hole has no own property: it must consult inherited setters. +"use strict"; + +const calls: string[] = []; +Object.defineProperty(Array.prototype, "3", { + configurable: true, + get() { return "array-proto-three"; }, + set(this: any, value: any) { calls.push(`${Array.isArray(this)}:${value}`); }, +}); + +try { + const outOfBounds: any[] = [0]; + outOfBounds[3] = 31; + console.log(calls.join(","), Object.hasOwn(outOfBounds, 3), outOfBounds[3], outOfBounds.length); + + const inBoundsHole: any[] = new Array(5); + inBoundsHole[3] = 37; + console.log(calls.join(","), Object.hasOwn(inBoundsHole, 3), inBoundsHole[3], inBoundsHole.length); + + // An own undefined value is not a hole and must bypass the inherited setter. + const own: any[] = [0, 1, 2, undefined]; + own[3] = 41; + console.log("own", calls.length, Object.hasOwn(own, 3), own[3], own.length); + + // Deletion creates the same obligation as new Array(n)'s initial holes. + delete own[3]; + own[3] = 43; + console.log("deleted", calls.join(","), Object.hasOwn(own, 3), own[3], own.length); +} finally { + delete (Array.prototype as any)[3]; +} + +// The invalidation latch stays set after deletion, but no setter remains. +const plain: any[] = new Array(5); +plain[3] = 47; +console.log("removed", Object.hasOwn(plain, 3), plain[3], plain.length); diff --git a/test-files/test_gap_9787_object_prototype_hole_setter.ts b/test-files/test_gap_9787_object_prototype_hole_setter.ts new file mode 100644 index 0000000000..84922be313 --- /dev/null +++ b/test-files/test_gap_9787_object_prototype_hole_setter.ts @@ -0,0 +1,54 @@ +// Object.prototype alone must invalidate the numeric hole-store fast path. +"use strict"; + +let calls = 0; +let receiverIsArray = false; +let assigned = 0; +Object.defineProperty(Object.prototype, "8", { + configurable: true, + get() { return "object-proto-eight"; }, + set(this: any, value: number) { + calls++; + receiverIsArray = Array.isArray(this); + assigned = value; + }, +}); + +let setterResult = ""; +try { + const holes: any[] = new Array(10); + holes[8] = 53; + setterResult = [calls, receiverIsArray, assigned, Object.hasOwn(holes, 8), holes[8], holes.length].join(" "); +} finally { + delete (Object.prototype as any)[8]; +} +console.log(setterResult); + +Object.defineProperty(Object.prototype, "8", { + configurable: true, + get() { return "getter-only"; }, +}); +let getterResult = ""; +try { + const holes: any[] = new Array(10); + let error = "none"; + try { holes[8] = 59; } catch (e) { error = (e as Error).constructor.name; } + getterResult = [error, Object.hasOwn(holes, 8), holes[8], holes.length].join(" "); +} finally { + delete (Object.prototype as any)[8]; +} +console.log(getterResult); + +Object.defineProperty(Object.prototype, "8", { + configurable: true, value: "locked", writable: false, +}); +let lockedResult = ""; +try { + const holes: any[] = new Array(10); + let error = "none"; + try { holes[8] = 61; } catch (e) { error = (e as Error).constructor.name; } + lockedResult = [error, Object.hasOwn(holes, 8), holes[8], holes.length].join(" "); +} finally { + delete (Object.prototype as any)[8]; +} +console.log(lockedResult); diff --git a/test-files/test_issue_8907_http_link.ts b/test-files/test_issue_8907_http_link.ts new file mode 100644 index 0000000000..313ce53fca --- /dev/null +++ b/test-files/test_issue_8907_http_link.ts @@ -0,0 +1,13 @@ +// #8907: v0.5.1220 on macOS arm64 failed to link this node:http lifecycle +// with 17 undefined HTTP symbols. Keep the listener/close callbacks live. +import { createServer } from "node:http"; + +function main(): void { + const server = createServer((_req, res) => { res.end("ok"); }); + server.listen(0, () => { + console.log("listening"); + server.close(() => { console.log("closed"); }); + }); +} + +main(); diff --git a/test-files/test_typed_feedback_profile_replay.ts b/test-files/test_typed_feedback_profile_replay.ts new file mode 100644 index 0000000000..d704a88680 --- /dev/null +++ b/test-files/test_typed_feedback_profile_replay.ts @@ -0,0 +1,12 @@ +function read(xs: any[], i: number): any { return xs[i | 0]; } +const getter: any[] = [0]; +Object.defineProperty(getter, "0", { get() { return "getter"; } }); +const grown: any[] = [9]; +const alias = grown; +for (let i = 0; i < 80; i++) grown.push(i); +const samples: any[] = [[11, 22], ["changed"], [true], [{x: 1}], [], new Array(1), {0: "object"}, new Uint8Array([7]), getter, alias]; +const disagree = process.argv.indexOf("disagree") >= 0; +for (let i = 0; i < samples.length; i++) { + const xs: any = disagree ? samples[i] : samples[0]; + console.log(JSON.stringify(read(xs, 0))); +} diff --git a/tests/cc-parity/help.stdout b/tests/cc-parity/help.stdout new file mode 100644 index 0000000000..123e61db2d --- /dev/null +++ b/tests/cc-parity/help.stdout @@ -0,0 +1,72 @@ +Usage: claude [options] [command] [prompt] + +Claude Code - starts an interactive session by default, use -p/--print for +non-interactive output + +Arguments: + prompt Your prompt + +Options: + --add-dir Additional directories to allow tool access to + --agent Agent for the current session. Overrides the 'agent' setting. + --agents JSON object defining custom agents (e.g. '{"reviewer": {"description": "Reviews code", "prompt": "You are a code reviewer"}}') + --allow-dangerously-skip-permissions Enable bypassing all permission checks as an option, without it being enabled by default. Recommended only for sandboxes with no internet access. + --allowedTools, --allowed-tools Comma or space-separated list of tool names to allow (e.g. "Bash(git *) Edit") + --append-system-prompt Append a system prompt to the default system prompt + --bare Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. Sets CLAUDE_CODE_SIMPLE=1. Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via --settings (OAuth and keychain are never read). 3P providers (Bedrock/Vertex/Foundry) use their own credentials. Skills still resolve via /skill-name. Explicitly provide context via: --system-prompt[-file], --append-system-prompt[-file], --add-dir (CLAUDE.md dirs), --mcp-config, --settings, --agents, --plugin-dir. + --betas Beta headers to include in API requests (API key users only) + --brief Enable SendUserMessage tool for agent-to-user communication + --chrome Enable Claude in Chrome integration + -c, --continue Continue the most recent conversation in the current directory + --dangerously-skip-permissions Bypass all permission checks. Recommended only for sandboxes with no internet access. + -d, --debug [filter] Enable debug mode with optional category filtering (e.g., "api,hooks" or "!1p,!file") + --debug-file Write debug logs to a specific file path (implicitly enables debug mode) + --disable-slash-commands Disable all skills + --disallowedTools, --disallowed-tools Comma or space-separated list of tool names to deny (e.g. "Bash(git *) Edit") + --effort Effort level for the current session (low, medium, high, xhigh, max) + --exclude-dynamic-system-prompt-sections Move per-machine sections (cwd, env info, memory paths, git status) from the system prompt into the first user message. Improves cross-user prompt-cache reuse. Only applies with the default system prompt (ignored with --system-prompt). (default: false) + --fallback-model Enable automatic fallback to specified model when default model is overloaded (only works with --print) + --file File resources to download at startup. Format: file_id:relative_path (e.g., --file file_abc:doc.txt file_def:img.png) + --fork-session When resuming, create a new session ID instead of reusing the original (use with --resume or --continue) + --from-pr [value] Resume a session linked to a PR by PR number/URL, or open interactive picker with optional search term + -h, --help Display help for command + --ide Automatically connect to IDE on startup if exactly one valid IDE is available + --include-hook-events Include all hook lifecycle events in the output stream (only works with --output-format=stream-json) + --include-partial-messages Include partial message chunks as they arrive (only works with --print and --output-format=stream-json) + --input-format Input format (only works with --print): "text" (default), or "stream-json" (realtime streaming input) (choices: "text", "stream-json") + --json-schema JSON Schema for structured output validation. Example: {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]} + --max-budget-usd Maximum dollar amount to spend on API calls (only works with --print) + --mcp-config Load MCP servers from JSON files or strings (space-separated) + --mcp-debug [DEPRECATED. Use --debug instead] Enable MCP debug mode (shows MCP server errors) + --model Model for the current session. Provide an alias for the latest model (e.g. 'sonnet' or 'opus') or a model's full name (e.g. 'claude-sonnet-4-6'). + -n, --name Set a display name for this session (shown in /resume and terminal title) + --no-chrome Disable Claude in Chrome integration + --no-session-persistence Disable session persistence - sessions will not be saved to disk and cannot be resumed (only works with --print) + --output-format Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming) (choices: "text", "json", "stream-json") + --permission-mode Permission mode to use for the session (choices: "acceptEdits", "auto", "bypassPermissions", "default", "dontAsk", "plan") + --plugin-dir Load plugins from a directory for this session only (repeatable: --plugin-dir A --plugin-dir B) (default: []) + -p, --print Print response and exit (useful for pipes). Note: The workspace trust dialog is skipped when Claude is run with the -p mode. Only use this flag in directories you trust. + --remote-control-session-name-prefix Prefix for auto-generated Remote Control session names (default: hostname) + --replay-user-messages Re-emit user messages from stdin back on stdout for acknowledgment (only works with --input-format=stream-json and --output-format=stream-json) + -r, --resume [value] Resume a conversation by session ID, or open interactive picker with optional search term + --session-id Use a specific session ID for the conversation (must be a valid UUID) + --setting-sources Comma-separated list of setting sources to load (user, project, local). + --settings Path to a settings JSON file or a JSON string to load additional settings from + --strict-mcp-config Only use MCP servers from --mcp-config, ignoring all other MCP configurations + --system-prompt System prompt to use for the session + --tmux Create a tmux session for the worktree (requires --worktree). Uses iTerm2 native panes when available; use --tmux=classic for traditional tmux. + --tools Specify the list of available tools from the built-in set. Use "" to disable all tools, "default" to use all tools, or specify tool names (e.g. "Bash,Edit,Read"). + --verbose Override verbose mode setting from config + -v, --version Output the version number + -w, --worktree [name] Create a new git worktree for this session (optionally specify a name) + +Commands: + agents [options] List configured agents + auth Manage authentication + auto-mode Inspect auto mode classifier configuration + doctor Check the health of your Claude Code auto-updater. Note: The workspace trust dialog is skipped and stdio servers from .mcp.json are spawned for health checks. Only use this command in directories you trust. + install [options] [target] Install Claude Code native build. Use [target] to specify version (stable, latest, or specific version) + mcp Configure and manage MCP servers + plugin|plugins Manage Claude Code plugins + setup-token Set up a long-lived authentication token (requires Claude subscription) + update|upgrade Check for updates and install if available diff --git a/tests/cc-parity/manifest.json b/tests/cc-parity/manifest.json new file mode 100644 index 0000000000..90bdc3ab0b --- /dev/null +++ b/tests/cc-parity/manifest.json @@ -0,0 +1,29 @@ +{ + "package": "@anthropic-ai/claude-code", + "version": "2.1.112", + "archive": { + "url": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-2.1.112.tgz", + "bytes": 18679326, + "sha256": "84379969ea53a0e5fd231a8f77debe4c7cb17dd971f4809d10d33f9aeca5de09" + }, + "bundle": { + "bytes": 13711684, + "sha256": "bc3358282800e3e99daa8e71ac5b7b1566bd0d7ca7eb94f714a7859365d3163f" + }, + "oracle": { + "node": "v26.5.1", + "platform": "darwin-arm64", + "captured": "2026-09-05", + "network": "denied by sandbox-exec" + }, + "goldens": { + "help": { + "bytes": 9175, + "sha256": "6cdb361880002e66c20e00de48ef13170c0b90185a69f651748f21958ab47094" + }, + "version": { + "bytes": 22, + "sha256": "4d9d156e4f0af416a02d325d3aab4dc084c09c2b78cf189ef63e1f13b8a1833e" + } + } +} diff --git a/tests/cc-parity/version.stdout b/tests/cc-parity/version.stdout new file mode 100644 index 0000000000..d1ad175c12 --- /dev/null +++ b/tests/cc-parity/version.stdout @@ -0,0 +1 @@ +2.1.112 (Claude Code) diff --git a/tests/test_cc_parity_gate.py b/tests/test_cc_parity_gate.py new file mode 100644 index 0000000000..de4b736a2a --- /dev/null +++ b/tests/test_cc_parity_gate.py @@ -0,0 +1,175 @@ +"""Exercise the gate with deliberately broken archives and native executables.""" + +import importlib.util +import io +import json +from pathlib import Path +import subprocess +import sys +import tarfile +import tempfile +import unittest + +SPEC = importlib.util.spec_from_file_location( + "cc_parity_gate", Path(__file__).resolve().parents[1] / "scripts/cc_parity_gate.py" +) +gate = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(gate) + + +def identity(data): + return {"bytes": len(data), "sha256": gate.digest(data)} + + +class GateTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.work = Path(self.temp.name) + (self.work / "logs").mkdir() + self.manifest = {"bundle": identity(b"bundle"), "goldens": {}} + for case, data in (("help", b"help\n"), ("version", b"version\n")): + (self.work / f"{case}.stdout").write_bytes(data) + self.manifest["goldens"][case] = identity(data) + + def archive(self, source=b"bundle", symlink=False): + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w:gz") as archive: + member = tarfile.TarInfo("package/cli.js") + member.size = len(source) + if symlink: + member.type = tarfile.SYMTYPE + member.linkname = "../../outside" + archive.addfile(member, io.BytesIO(source)) + data = buffer.getvalue() + path = self.work / "package.tgz" + path.write_bytes(data) + self.manifest["archive"] = identity(data) + return path + + def test_prepare_checks_both_hashes(self): + archive = self.archive() + gate.prepare(self.work, self.manifest, archive) + self.assertEqual((self.work / "cli.js").read_bytes(), b"bundle") + archive.write_bytes(archive.read_bytes() + b"changed") + with self.assertRaisesRegex(ValueError, "npm archive"): + gate.prepare(self.work, self.manifest, archive) + archive = self.archive(b"different bundle") + with self.assertRaisesRegex(ValueError, "cli.js"): + gate.prepare(self.work, self.manifest, archive) + + def test_prepare_rejects_symlink(self): + with self.assertRaisesRegex(ValueError, "regular file"): + gate.prepare(self.work, self.manifest, self.archive(symlink=True)) + + def test_rejects_script_in_native_arm(self): + binary = self.work / "claude-native" + binary.write_text("#!/bin/sh\necho help\n") + binary.chmod(0o755) + with self.assertRaisesRegex(ValueError, "Mach-O"): + gate.require_native(binary) + + def test_failed_compile_cannot_reuse_a_stale_binary(self): + (self.work / "cli.js").write_bytes(b"bundle") + binary = self.work / "claude-native" + binary.write_bytes(b"old executable") + compiler = self.work / "failing-perry" + compiler.write_text("#!/bin/sh\necho deliberate compiler failure >&2\nexit 2\n") + compiler.chmod(0o755) + with self.assertRaisesRegex(ValueError, "native compilation failed"): + gate.compile_bundle(self.work, self.manifest, compiler, timeout=5) + self.assertFalse(binary.exists()) + report = json.loads((self.work / "logs/compile.json").read_text()) + self.assertEqual(report["exit_code"], 2) + + def test_scratch_environment_is_an_allowlist(self): + env = gate.scratch_env(self.work) + self.assertEqual(env["HOME"], str(self.work)) + self.assertEqual(env["TMPDIR"], str(self.work)) + self.assertNotIn("ANTHROPIC_API_KEY", env) + self.assertFalse(any(key.startswith("PERRY_") for key in env)) + self.assertNotIn("/opt/homebrew/bin", env["PATH"]) + + def test_checked_in_golden_integrity(self): + manifest = json.loads((gate.CORPUS / "manifest.json").read_text()) + self.assertEqual(manifest["goldens"]["help"]["bytes"], 9175) + for case in gate.CASES: + gate.verify((gate.CORPUS / f"{case}.stdout").read_bytes(), + manifest["goldens"][case], case) + + +@unittest.skipUnless(sys.platform == "darwin", "native offline gate uses macOS Seatbelt") +class NativeGateTests(unittest.TestCase): + setUp = GateTests.setUp + + def native(self, behavior=""): + source = self.work / "fixture.c" + source.write_text('''#include +#include +#include +#include +#include +#include +#include +int main(int argc, char **argv) { + if (argc != 2 || getenv("ANTHROPIC_API_KEY") || getenv("PERRY_TEST_KNOB")) return 8; + if (!getenv("HOME") || !strstr(getenv("HOME"), "cc-parity-")) return 9; + // Prove Seatbelt is live, even though these fixtures need no connection. + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd >= 0) { + struct sockaddr_in address = {0}; + address.sin_family = AF_INET; + address.sin_port = htons(9); + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + int result = connect(fd, (struct sockaddr *)&address, sizeof(address)); + int error = errno; + close(fd); + if (result != -1 || error != EPERM) return 10; + } else if (errno != EPERM) return 11; + ''' + behavior + ''' + puts(strcmp(argv[1], "--help") == 0 ? "help" : "version"); + return 0; +} +''') + subprocess.run(["/usr/bin/cc", str(source), "-o", str(self.work / "claude-native")], + check=True, capture_output=True) + + def check(self, timeout=5): + gate.check(self.work, self.manifest, corpus=self.work, timeout=timeout) + + def test_native_exact_bytes_pass_with_network_denied(self): + self.native() + self.check() + report = json.loads((self.work / "logs/parity.json").read_text()) + self.assertEqual(set(report["cases"]), {"help", "version"}) + self.assertTrue(all(case["passed"] for case in report["cases"].values())) + + def test_one_byte_difference_fails(self): + self.native('putchar(\'!\');') + with self.assertRaisesRegex(ValueError, "parity failed"): + self.check() + + def test_nonzero_exit_fails_even_with_matching_stdout(self): + self.native('puts(strcmp(argv[1], "--help") == 0 ? "help" : "version"); return 3;') + with self.assertRaisesRegex(ValueError, "parity failed"): + self.check() + report = json.loads((self.work / "logs/parity.json").read_text()) + self.assertTrue(report["cases"]["help"]["matches_golden"]) + + def test_timeout_fails(self): + self.native("sleep(10);") + with self.assertRaisesRegex(ValueError, "parity failed"): + self.check(timeout=0.2) + report = json.loads((self.work / "logs/parity.json").read_text()) + self.assertTrue(report["cases"]["help"]["timed_out"]) + + def test_changed_golden_fails_before_execution(self): + self.native() + (self.work / "help.stdout").write_bytes(b"incorrect golden\n") + with self.assertRaisesRegex(ValueError, "help golden"): + self.check() + self.assertFalse((self.work / "logs/help.stdout").exists()) + + +if __name__ == "__main__": + unittest.main()