From 932fde795babd83d8e53c4cdca71b37ba143a278 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 2 Oct 2026 10:38:08 +0200 Subject: [PATCH 01/17] Unify shape facts, ConstFn calls and numeric receiver regions --- .github/workflows/gc-native-roots.yml | 59 +- changelog.d/11680-one-shape-campaign.md | 31 + crates/perry-abi/src/lib.rs | 16 +- crates/perry-codegen/src/codegen/artifacts.rs | 6 +- crates/perry-codegen/src/codegen/closure.rs | 13 +- .../src/codegen/emission_order_tests.rs | 1 + crates/perry-codegen/src/codegen/entry.rs | 24 +- .../perry-codegen/src/codegen/entry/tests.rs | 20 + crates/perry-codegen/src/codegen/function.rs | 1 - .../src/codegen/literal_method_this_tests.rs | 64 + crates/perry-codegen/src/codegen/method.rs | 1 - .../src/codegen/method_static.rs | 1 - .../src/codegen/method_trampolines.rs | 9 +- crates/perry-codegen/src/codegen/mod.rs | 188 ++- .../src/codegen/number_exactness_tests.rs | 1 + crates/perry-codegen/src/codegen/opts.rs | 9 + .../src/codegen/static_constfn.rs | 362 ++++++ .../src/codegen/static_constfn_class.rs | 213 ++++ .../src/codegen/static_constfn_tests.rs | 622 ++++++++++ .../src/codegen/static_shape_ids.rs | 275 ++++- .../src/codegen/static_shape_ids_tests.rs | 192 +++ .../perry-codegen/src/codegen/string_pool.rs | 254 +++- crates/perry-codegen/src/collectors/mod.rs | 4 + .../collectors/proven_this_routing_tests.rs | 1 + .../perry-codegen/src/collectors/ptr_shape.rs | 4 + .../src/collectors/ptr_shape_numeric.rs | 284 ++++- .../src/collectors/receiver_regions.rs | 5 +- .../src/collectors/receiver_regions_tests.rs | 17 +- crates/perry-codegen/src/concat_site_cache.rs | 15 +- .../src/expr/array_push_guard_tests.rs | 1 + .../src/expr/barrier_stem_census_tests.rs | 148 ++- crates/perry-codegen/src/expr/binary.rs | 115 +- .../src/expr/call_spread_short.rs | 9 +- .../src/expr/class_field_barrier_tests.rs | 1 + .../src/expr/class_field_inline_guard.rs | 184 +-- .../class_method_arguments_object_tests.rs | 1 + crates/perry-codegen/src/expr/closure.rs | 3 +- .../src/expr/collecting_root_tests.rs | 1082 +++++++++++++++++ crates/perry-codegen/src/expr/compare.rs | 3 + .../src/expr/conforming_layout_note_tests.rs | 1 + .../src/expr/element_shape_guard.rs | 6 +- crates/perry-codegen/src/expr/index_get.rs | 4 + .../src/expr/index_get/guarded_array.rs | 21 +- .../src/expr/index_get_claim_tests.rs | 64 +- crates/perry-codegen/src/expr/method_site.rs | 60 +- crates/perry-codegen/src/expr/mod.rs | 54 - .../perry-codegen/src/expr/object_literal.rs | 4 +- crates/perry-codegen/src/expr/property_get.rs | 66 - .../expr/property_get/front_contract_tests.rs | 365 ++++++ .../src/expr/property_get/helpers.rs | 75 +- .../src/expr/property_get/tests.rs | 33 +- crates/perry-codegen/src/expr/property_set.rs | 675 +++++----- .../expr/property_set/sloppy_class_field.rs | 58 - .../perry-codegen/src/expr/receiver_range.rs | 3 + .../src/expr/region_loop_tests.rs | 441 ++++++- crates/perry-codegen/src/expr/store_census.rs | 7 +- crates/perry-codegen/src/fn_info.rs | 100 +- crates/perry-codegen/src/gc_call_effects.rs | 18 + .../src/gc_effects/linux-x86_64.tsv | 8 +- .../src/gc_effects/macos-aarch64.tsv | 3 + .../src/gc_effects/windows-x86_64.tsv | 3 + crates/perry-codegen/src/lib.rs | 10 +- .../src/lower_call/alloc_hot_tests.rs | 1 + .../src/lower_call/method_override.rs | 28 +- .../src/lower_call/native_module_dispatch.rs | 23 +- crates/perry-codegen/src/lower_call/new.rs | 59 +- .../src/lower_call/new_ctor_args.rs | 63 +- .../property_get/dynamic_dispatch.rs | 7 +- .../src/lower_call/typed_shape_bake_tests.rs | 1 + crates/perry-codegen/src/module.rs | 7 +- .../src/native_root_coverage/mod.rs | 1 + .../runtime_decls/stdlib_ffi/third_party.rs | 11 + .../src/runtime_decls/strings.rs | 12 +- .../src/stmt/boxed_slot_no_root_tests.rs | 1 + .../src/stmt/class_field_loop_tests.rs | 213 ++-- .../src/stmt/element_shape_loop.rs | 9 +- .../src/stmt/element_shape_loop_tests.rs | 1 + crates/perry-codegen/src/stmt/loops.rs | 570 +-------- .../src/stmt/prealloc_module_global_tests.rs | 1 + .../src/stmt/region_loop/arrays.rs | 5 +- .../src/stmt/region_loop/guard.rs | 38 +- .../perry-codegen/src/stmt/region_loop/mod.rs | 236 +++- .../stmt/region_loop/numeric_expression.rs | 242 ++++ .../region_loop/numeric_expression/tests.rs | 343 ++++++ .../src/stmt/region_loop/plan.rs | 322 ++++- .../src/stmt/region_loop/verify.rs | 93 ++ .../src/stmt/versioned_indexed_loop.rs | 11 +- crates/perry-codegen/src/strings.rs | 13 + crates/perry-codegen/src/stubs.rs | 130 +- .../src/temp_root_coverage/mod.rs | 1 + .../perry-codegen/src/testing/root_slots.rs | 19 +- .../perry-codegen/src/testing/temp_slots.rs | 17 +- .../src/type_analysis/numeric.rs | 6 + .../src/type_analysis/numeric/tests.rs | 1 + crates/perry-codegen/src/type_analysis/pod.rs | 17 +- crates/perry-codegen/src/typed_shape.rs | 42 + .../perry-codegen/src/wasm32/runtime_abi.tsv | 8 +- .../tests/app_window_config_options.rs | 1 + .../tests/argless_builtin_extra_args.rs | 1 + .../tests/class_field_store_pointer_test.rs | 1 + .../perry-codegen/tests/class_keys_gc_root.rs | 1 + .../tests/concat_site_agent_ownership.rs | 51 + .../tests/constructor_recursion.rs | 1 + .../tests/crypto_hash_chain_lowering.rs | 1 + .../tests/destructure_call_location.rs | 1 + .../tests/i64_spec_ternary_recursion.rs | 1 + .../tests/ios_platform_api_lowering.rs | 1 + .../tests/large_object_barriers.rs | 1 + .../tests/loop_safepoint_purity.rs | 1 + .../tests/macos_bundle_chdir_gate.rs | 1 + .../tests/native_proof_buffer_views.rs | 1 + .../tests/native_proof_regressions.rs | 1 + .../tests/node_test_mock_property_presence.rs | 1 + .../tests/perry_builtin_name_collision.rs | 1 + .../tests/release_boxes_lowering.rs | 1 + .../tests/scalar_replaced_slot_roots.rs | 1 + .../tests/shadow_slot_hygiene.rs | 1 + .../tests/static_symbol_hygiene.rs | 1 + .../tests/temp_root_operand_temporaries.rs | 1 + .../tests/thread_agent_strings.rs | 198 +++ .../tests/thread_immutable_globals.rs | 20 +- crates/perry-codegen/tests/typed_feedback.rs | 1 + .../tests/typed_shape_descriptor.rs | 1 + .../tests/typed_shape_descriptors.rs | 1 + crates/perry-hir/src/lower/context_new.rs | 1 + .../perry-hir/src/lower/lowering_context.rs | 2 + crates/perry-hir/src/lower/mod.rs | 2 +- crates/perry-hir/src/lower/stmt.rs | 3 + crates/perry-hir/src/lower/stmt_loops.rs | 380 ++++-- crates/perry-hir/src/lower_decl/body_stmt.rs | 17 +- crates/perry-runtime/src/array/iterator.rs | 40 +- crates/perry-runtime/src/gc/barrier_store.rs | 6 +- .../perry-runtime/src/gc/layout/by_shape.rs | 34 +- crates/perry-runtime/src/hot_diag.rs | 11 +- .../perry-runtime/src/object/alloc_plain.rs | 30 +- .../src/object/class_birth_rep_tests.rs | 130 ++ .../src/object/constfn_unload_tests.rs | 110 ++ crates/perry-runtime/src/object/field_rep.rs | 133 +- .../src/object/field_rep_store.rs | 244 +++- .../src/object/field_rep_store_tests.rs | 126 +- .../src/object/field_set_by_name/tail.rs | 8 +- .../perry-runtime/src/object/method_site.rs | 183 ++- .../src/object/region_numeric_read_tests.rs | 328 +++++ crates/perry-runtime/src/object/shapes.rs | 337 ++++- .../src/object/shapes_slot_list.rs | 93 +- .../perry-runtime/src/object/shapes_store.rs | 1043 +++++++--------- .../src/object/shapes_store_tests.rs | 651 ++++++++++ .../perry-runtime/src/object/shapes_tests.rs | 4 +- .../src/object/shapes_worker_seed.rs | 113 +- .../perry-runtime/src/object/static_shapes.rs | 389 +++++- .../src/object/static_shapes_tests.rs | 441 +++++++ crates/perry-runtime/src/proxy/put_value.rs | 22 + .../proxy/put_value/cached_constfn_tests.rs | 251 ++++ .../put_value/numeric_write_constfn_tests.rs | 144 +++ .../src/proxy/put_value/packed_add.rs | 11 +- .../src/proxy/put_value/packed_set.rs | 12 +- .../perry-runtime/src/string/concat_site.rs | 10 +- crates/perry-runtime/src/thread.rs | 94 +- .../src/thread_constfn_transfer.rs | 73 ++ .../src/thread_constfn_transfer_tests.rs | 744 ++++++++++++ .../src/thread_literal_launch_tests.rs | 232 ++++ .../src/thread_static_shape_tests.rs | 41 + .../src/thread_transfer_guard_tests.rs | 2 + .../src/typed_feedback/guards.rs | 32 +- crates/perry-runtime/src/weakref.rs | 19 + .../src/weakref/trace_slot_tests.rs | 98 ++ crates/perry-transform/src/finally_inline.rs | 154 ++- .../src/inline/call_inliner.rs | 88 ++ .../src/inline/discarded_result.rs | 1 + crates/perry-transform/src/inline/mod.rs | 4 + .../src/inline/numeric_loop.rs | 243 ++++ crates/perry/src/commands/compile.rs | 13 +- .../perry/src/commands/compile/build_cache.rs | 2 + .../src/commands/compile/object_cache.rs | 21 + .../object_cache/object_cache_tests.rs | 65 + .../src/commands/compile/run_pipeline.rs | 96 +- .../tests/shape_record_lookup_in_bound.rs | 175 +++ .../tests/thread_immutable_global_leaves.rs | 153 ++- scripts/addr_class_ratchet_baseline.txt | 2 +- scripts/constfn_executable_gates.py | 690 +++++++++++ scripts/constfn_sabotage_patch.py | 66 + scripts/gc_root_dominance_check.py | 34 + scripts/shape_descriptor_census.py | 3 +- scripts/string_payload_access_baseline.txt | 2 +- scripts/test_constfn_numeric_refusal.py | 119 ++ scripts/thread_exit_address_globals.json | 7 +- test-files/constfn-executable-gates/cache.ts | 6 + .../constfn-executable-gates/classes.ts | 24 + .../collecting-field-assignment.ts | 22 + .../collecting-nested-add.ts | 20 + .../constfn-executable-gates/consumer.ts | 4 + .../constfn-executable-gates/factory.ts | 12 + .../numeric-class-loop-replacement.ts | 11 + .../numeric-control.ts | 25 + .../constfn-executable-gates/numeric-loops.ts | 58 + .../numeric-recheck-control.ts | 7 + .../numeric-recheck.ts | 20 + .../numeric-refusal-all.ts | 10 + .../numeric-refusal-cell.ts | 15 + .../numeric-refusal-control.ts | 35 + .../numeric-refusal-pair.ts | 10 + .../numeric-refusal-reader.ts | 25 + .../constfn-executable-gates/producer.ts | 4 + .../worker-producer.ts | 5 + .../constfn-executable-gates/workers-after.ts | 21 + .../workers-before.ts | 20 + test-files/test_constfn_static_user_class.ts | 44 + ..._gap_iterator_close_canceled_completion.ts | 274 +++++ ...est_gap_iterator_close_completion_order.ts | 135 ++ .../test_gap_iterator_close_nested_catch.ts | 180 +++ ...est_gap_region_numeric_expression_order.ts | 158 +++ .../test_gap_region_unmarked_numeric_read.ts | 52 + test-files/test_thread_concat_site_cache.ts | 18 + 213 files changed, 16186 insertions(+), 2774 deletions(-) create mode 100644 changelog.d/11680-one-shape-campaign.md create mode 100644 crates/perry-codegen/src/codegen/static_constfn.rs create mode 100644 crates/perry-codegen/src/codegen/static_constfn_class.rs create mode 100644 crates/perry-codegen/src/codegen/static_constfn_tests.rs create mode 100644 crates/perry-codegen/src/expr/collecting_root_tests.rs create mode 100644 crates/perry-codegen/src/expr/property_get/front_contract_tests.rs create mode 100644 crates/perry-codegen/src/stmt/region_loop/numeric_expression.rs create mode 100644 crates/perry-codegen/src/stmt/region_loop/numeric_expression/tests.rs create mode 100644 crates/perry-codegen/tests/concat_site_agent_ownership.rs create mode 100644 crates/perry-codegen/tests/thread_agent_strings.rs create mode 100644 crates/perry-runtime/src/object/constfn_unload_tests.rs create mode 100644 crates/perry-runtime/src/object/region_numeric_read_tests.rs create mode 100644 crates/perry-runtime/src/object/shapes_store_tests.rs create mode 100644 crates/perry-runtime/src/proxy/put_value/cached_constfn_tests.rs create mode 100644 crates/perry-runtime/src/proxy/put_value/numeric_write_constfn_tests.rs create mode 100644 crates/perry-runtime/src/thread_constfn_transfer.rs create mode 100644 crates/perry-runtime/src/thread_constfn_transfer_tests.rs create mode 100644 crates/perry-runtime/src/thread_literal_launch_tests.rs create mode 100644 crates/perry-runtime/src/weakref/trace_slot_tests.rs create mode 100644 crates/perry-transform/src/inline/numeric_loop.rs create mode 100644 crates/perry/tests/shape_record_lookup_in_bound.rs create mode 100755 scripts/constfn_executable_gates.py create mode 100755 scripts/constfn_sabotage_patch.py create mode 100644 scripts/test_constfn_numeric_refusal.py create mode 100644 test-files/constfn-executable-gates/cache.ts create mode 100644 test-files/constfn-executable-gates/classes.ts create mode 100644 test-files/constfn-executable-gates/collecting-field-assignment.ts create mode 100644 test-files/constfn-executable-gates/collecting-nested-add.ts create mode 100644 test-files/constfn-executable-gates/consumer.ts create mode 100644 test-files/constfn-executable-gates/factory.ts create mode 100644 test-files/constfn-executable-gates/numeric-class-loop-replacement.ts create mode 100644 test-files/constfn-executable-gates/numeric-control.ts create mode 100644 test-files/constfn-executable-gates/numeric-loops.ts create mode 100644 test-files/constfn-executable-gates/numeric-recheck-control.ts create mode 100644 test-files/constfn-executable-gates/numeric-recheck.ts create mode 100644 test-files/constfn-executable-gates/numeric-refusal-all.ts create mode 100644 test-files/constfn-executable-gates/numeric-refusal-cell.ts create mode 100644 test-files/constfn-executable-gates/numeric-refusal-control.ts create mode 100644 test-files/constfn-executable-gates/numeric-refusal-pair.ts create mode 100644 test-files/constfn-executable-gates/numeric-refusal-reader.ts create mode 100644 test-files/constfn-executable-gates/producer.ts create mode 100644 test-files/constfn-executable-gates/worker-producer.ts create mode 100644 test-files/constfn-executable-gates/workers-after.ts create mode 100644 test-files/constfn-executable-gates/workers-before.ts create mode 100644 test-files/test_constfn_static_user_class.ts create mode 100644 test-files/test_gap_iterator_close_canceled_completion.ts create mode 100644 test-files/test_gap_iterator_close_completion_order.ts create mode 100644 test-files/test_gap_iterator_close_nested_catch.ts create mode 100644 test-files/test_gap_region_numeric_expression_order.ts create mode 100644 test-files/test_gap_region_unmarked_numeric_read.ts create mode 100644 test-files/test_thread_concat_site_cache.ts diff --git a/.github/workflows/gc-native-roots.yml b/.github/workflows/gc-native-roots.yml index 5532fc6705..e070db053d 100644 --- a/.github/workflows/gc-native-roots.yml +++ b/.github/workflows/gc-native-roots.yml @@ -419,25 +419,10 @@ jobs: for probe in benchmarks/gc_ratchet/probes/*.ts; do total=$((total+1)) name=$(basename "$probe" .ts) - if [ "$RUNNER_OS" = "Windows" ] && [ "$name" = "09_try_catch_roots" ]; then - # #7354 measured negative, pinned as a REFUSAL: windows-msvc - # `try` lowers to WinEH funclet pads, which crash LLVM's - # rewrite-statepoints-for-gc outright (access violation on opt - # 22.1.3, reproducible from an eight-line module). Perry refuses - # the module before the pass runs; this arm pins that it STAYS a - # refusal — never a crash, never a silently rootless binary. It - # goes red the day the pass learns funclet EH, which is the - # prompt to fold 09 into this matrix. - if PERRY_RS4GC=1 ./target/perry-dev/perry "$probe" \ - -o "/tmp/rs4gc-$name" > "/tmp/rs4gc-$name.compile.log" 2>&1; then - echo "::error::$name compiled under RS4GC on Windows — the funclet refusal is gone: either rewrite-statepoints-for-gc learned funclet EH (fold 09 into the matrix) or the refusal was lost" - exit 1 - fi - grep -q "funclet" "/tmp/rs4gc-$name.compile.log" \ - || { echo "::error::$name failed for a reason other than the funclet refusal:"; cat "/tmp/rs4gc-$name.compile.log"; exit 1; } - pass=$((pass+1)) - continue - fi + # #10385 replaced Windows funclets with Perry's landing-pad + # personality. Probe 09 must now execute with precise roots on + # Windows too. Actual funclet IR remains refused by linker.rs's + # rs4gc_funclet_refusal and its unit test (#7354). node --expose-gc --experimental-strip-types "$probe" > "/tmp/rs4gc-$name.oracle" PERRY_RS4GC=1 ./target/perry-dev/perry "$probe" -o "/tmp/rs4gc-$name" # perry appends the platform default extension to an -o with none. @@ -461,11 +446,17 @@ jobs: readelf -S "$out" | grep -q "\.llvm_stackmaps" \ && { echo "::error::$name still carries .llvm_stackmaps — the compact rewrite did not run"; exit 1; } fi + PERRY_GC_DIAG=1 \ PERRY_RS4GC=1 PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 \ PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \ "$out" > "/tmp/rs4gc-$name.out" 2> "/tmp/rs4gc-$name.err" diff "/tmp/rs4gc-$name.oracle" "/tmp/rs4gc-$name.out" \ || { echo "::error::$name diverged from the pinned oracle under RS4GC"; exit 1; } + if [ "$name" = "09_try_catch_roots" ]; then + py=python3; command -v python3 >/dev/null 2>&1 || py=python + "$py" scripts/gc_evacuation_liveness_assert.py "/tmp/rs4gc-$name.err" \ + --probe "$name ($RUNNER_OS RS4GC)" + fi errs="$errs /tmp/rs4gc-$name.err" pass=$((pass+1)) done @@ -485,14 +476,9 @@ jobs: # windows-latest exposes the toolcache python as `python`, not python3. py=python3; command -v python3 >/dev/null 2>&1 || py=python - # The PORTABLE assertion, on every arm. `11_collect_at_depth` is - # deliberate: it contains no `try`, so it compiles under RS4GC - # everywhere. `09_try_catch_roots` does NOT — RS4GC cannot rewrite - # WinEH funclet pads, so `linker.rs`'s `rs4gc_funclet_refusal` rejects - # it on windows-msvc, and the probe loop above only tolerates that - # because it greps the compile log for "funclet". A report assertion - # pinned to a probe that cannot compile on one arm is a gate that - # fails for a reason unrelated to its subject. + # The recursive-depth assertion, on every arm. This probe carries + # live roots across a deep stack; the separate try probe below + # covers roots across normal and unwinding exception edges. # # --only-backend proves the lowering ran on every function; the two # --require-positive checks prove it PRODUCED something. Those counts @@ -510,19 +496,18 @@ jobs: --require-positive records \ --require-positive roots - # The try-specific arm, everywhere RS4GC can compile a `try`. This is + # The try-specific arm on every target, including Windows since + # #10385 replaced funclets with Perry's landing-pad personality. This is # the coverage the probe above cannot give: 128 of 479 gap tests # contain `try {}`, and RS4GC being the only backend that handles them # is the reason the bridge could be deleted (#7339, #7348). - if [ "$RUNNER_OS" != "Windows" ]; then - PERRY_RS4GC=1 ./target/perry-dev/perry \ - benchmarks/gc_ratchet/probes/09_try_catch_roots.ts \ - -o /tmp/rs4gc-try-probe --statepoint-report=json 2> /tmp/rs4gc-try.json - "$py" scripts/statepoint_report_assert.py /tmp/rs4gc-try.json \ - --only-backend rs4gc \ - --require-positive records \ - --require-positive roots - fi + PERRY_RS4GC=1 ./target/perry-dev/perry \ + benchmarks/gc_ratchet/probes/09_try_catch_roots.ts \ + -o /tmp/rs4gc-try-probe --statepoint-report=json 2> /tmp/rs4gc-try.json + "$py" scripts/statepoint_report_assert.py /tmp/rs4gc-try.json \ + --only-backend rs4gc \ + --require-positive records \ + --require-positive roots # Walker liveness, on EVERY arm. A walker that visits zero frames # still lets most probes print the right answer, because other root diff --git a/changelog.d/11680-one-shape-campaign.md b/changelog.d/11680-one-shape-campaign.md new file mode 100644 index 0000000000..36c05f504c --- /dev/null +++ b/changelog.d/11680-one-shape-campaign.md @@ -0,0 +1,31 @@ +Complete the remaining one-shape compiler paths: shape-record lookup reads the +published per-agent directory directly; immutable method slots use shape-owned +ConstFn metadata with worker transfer and unload handling. Cyclic module startup +prepares literal pools and closed literal layouts before eager bodies execute. + +Numeric receiver regions use shape and F64 field proofs for loop arithmetic and +individual comparisons, preserving evaluation order, exception handlers and +generic fallback. Read-only regions also accept classless runtime records without +granting store permission; virtual namespace and per-object reads remain excluded. +Delete the separate numeric class-field loop emitter and its cached raw-pointer +facts, retaining ordinary property fallbacks and specialized array/element paths. + +Keep receivers, assignment results and dynamic-add operands rooted across +collecting fallbacks. Correct synchronous IteratorClose ordering and nested catch +completion handling: return operands evaluate before close, cleanup runs once, +and exceptions bypass catches already exited by the pending completion. + +Add compiler, runtime and executable regression coverage for numeric route +admission and refusal, closure identity, worker metadata, moving collections, +cyclic initialization, operand ordering and nested iterator cleanup. + +Worker executable fixtures allocate and recheck captures across scheduled +collections; the fixed seeds retain positive-copying and moved-object assertions. + +Worker programs keep the 32 cached results of each literal-prefix string concatenation in thread-local cells. Each worker now owns and roots its own strings, so repeated worker launches and simultaneous workers cannot reuse another agent's cached heap handles. The single-agent cache and its checked miss/root-registration path are preserved. Added graph-level IR ownership coverage and an executable two-launch regression that checks exact concatenation results. + +Fix synchronous for-of IteratorClose when an inner break or continue from finally cancels a pending return. Captured exits restore the completion inherited at their target, so cleanup loops retain an outer pending return and normal iterator exhaustion does not call return(). Preserve generated preludes around labeled control targets. Recognize every label in a label chain as targeting its terminal loop when deciding which finally blocks a captured exit crosses. + +Preserve normal call boundaries for closure-bearing inline candidates inside loops that perform receiver field arithmetic. This keeps closure creation out of the body that guarded numeric regions must version, while retaining codegen's refusal to duplicate closures and its recheck after calls. Tiny callees, calls outside these loops, and loops without receiver field arithmetic continue to inline. Focused tests cover all loop forms, nested helper inlining, and both controls. + +Run the try/catch native-root probe on Windows after its exception lowering moved to landing pads. Require Node-equivalent execution, a native root map, nonzero evacuation and RS4GC root records; keep the linker refusal for actual WinEH funclet IR. Use the runtime TLS declaration macro for worker launch test observations. diff --git a/crates/perry-abi/src/lib.rs b/crates/perry-abi/src/lib.rs index d61afce88b..9c1706a0b0 100644 --- a/crates/perry-abi/src/lib.rs +++ b/crates/perry-abi/src/lib.rs @@ -20,7 +20,7 @@ pub const ARRAY_HEADER_SIZE: usize = 8; /// `agent_ptrs::PERRY_AGENT_PTRS`: the number of per-agent pointer slots. pub const AGENT_PTR_SLOTS: usize = 4; /// Slot 0: the address of this agent's ordinary shape-directory mirror -/// (`shapes_store::ORDINARY_DIR`), which a generic read site passes to its +/// (`shapes_store::AGENT_SHAPE_DIR[0]`), which a generic read site passes to its /// GC-leaf miss front (`js_object_get_field_ic_front`) so the front reads no /// thread-local. Slot 1 held the implicit-`this` cell's address until /// this-as-a-parameter deleted the cell, and is free; slot 2 is the stack limit. @@ -344,6 +344,10 @@ pub const FN_NON_CONSTRUCTOR: u32 = 1 << 9; pub const FN_BUILTIN: u32 = 1 << 10; /// `declared` is valid. pub const FN_HAS_DECLARED: u32 = 1 << 11; +/// Body metadata and code are linked into a permanent executable image. +/// Dylib bodies omit this bit: a shape must not retain their info address +/// beyond `dlclose` or mistake a reused address for the same body. +pub const FN_PERMANENT_IMAGE: u32 = 1 << 12; /// Byte offsets of the fields codegen emits and emitted code reads. pub const JS_FUNCTION_INFO_CODE_OFFSET: usize = 0; @@ -564,8 +568,12 @@ pub const METHOD_SITE_SPILL: u64 = 1 << 62; /// The entry `slot` bit for an own key of a function-object receiver: an /// inline slot of the object at `ClosureHeader::props`. pub const METHOD_SITE_FUNCTION_BAG: u64 = 1 << 61; -/// The index bits of an entry's `slot` word (bit 60 is reserved for the -/// accessor entry kind). -pub const METHOD_SITE_INDEX_MASK: u64 = (1 << 60) - 1; +/// An own inline method whose ShapeId fixes one static body. The hit loads +/// the receiver's current closure slot for captures, but needs no closure +/// kind or info load after the shape compare. +pub const METHOD_SITE_CONSTFN: u64 = 1 << 59; +/// The index bits of an entry's `slot` word (bit 60 remains reserved for the +/// accessor entry kind; bit 59 is ConstFn). +pub const METHOD_SITE_INDEX_MASK: u64 = (1 << 59) - 1; /// `object::ObjectMeta::spill` (the object-owned overflow buffer). pub const OBJECT_META_SPILL_OFFSET: usize = 32; diff --git a/crates/perry-codegen/src/codegen/artifacts.rs b/crates/perry-codegen/src/codegen/artifacts.rs index a92cb82755..0601abfd59 100644 --- a/crates/perry-codegen/src/codegen/artifacts.rs +++ b/crates/perry-codegen/src/codegen/artifacts.rs @@ -30,7 +30,10 @@ use super::string_pool::emit_string_pool; /// function, string pool. Mirrors the in-prelude execution order of /// the original `compile_module`. #[allow(clippy::too_many_arguments)] -pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { +pub(super) fn emit_module_artifacts( + c: ModuleArtifactsCtx<'_>, + agent_strings_tls: bool, +) -> Result<()> { // Destructure so the verbatim block below reads against the // original local names. `llmod` / `strings` are `&mut` bindings // (auto-reborrowed on each per-function call site below); the @@ -1006,6 +1009,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { llmod, strings, module_prefix, + agent_strings_tls, output_type, class_keys_init_data, class_header_image_inits, diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 09e7b9ae73..19194e584e 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -123,7 +123,7 @@ fn emit_public_typed_closure_trampoline( )) } }; - let public_name = format!("perry_closure_{}__{}", module_prefix, func_id); + let public_name = crate::fn_info::closure_body_symbol(module_prefix, func_id); let typed_name = match kind { TypedFunctionTrampolineKind::F64 => typed_f64_closure_name(&public_name), TypedFunctionTrampolineKind::I32 => typed_i32_closure_name(&public_name), @@ -235,7 +235,7 @@ pub(super) fn compile_typed_string_closure( } }; - let generic_name = format!("perry_closure_{}__{}", module_prefix, func_id); + let generic_name = crate::fn_info::closure_body_symbol(module_prefix, func_id); let llvm_name = typed_string_closure_name(&generic_name); let mut llvm_params: Vec<(LlvmType, String)> = Vec::with_capacity(params.len() + 1); llvm_params.push((I64, "%this_closure".to_string())); @@ -283,7 +283,7 @@ pub(super) fn compile_typed_f64_closure( _ => return Err(anyhow!("compile_typed_f64_closure: expected Expr::Closure")), }; - let generic_name = format!("perry_closure_{}__{}", module_prefix, func_id); + let generic_name = crate::fn_info::closure_body_symbol(module_prefix, func_id); let llvm_name = typed_f64_closure_name(&generic_name); let mut llvm_params: Vec<(LlvmType, String)> = Vec::with_capacity(params.len() + 1); llvm_params.push((I64, "%this_closure".to_string())); @@ -328,7 +328,7 @@ pub(super) fn compile_typed_i1_closure( _ => return Err(anyhow!("compile_typed_i1_closure: expected Expr::Closure")), }; - let generic_name = format!("perry_closure_{}__{}", module_prefix, func_id); + let generic_name = crate::fn_info::closure_body_symbol(module_prefix, func_id); let llvm_name = typed_i1_closure_name(&generic_name); let param_reps = typed_param_reps_for_params(params) .ok_or_else(|| anyhow!("typed-i1 closure '{}' has unsupported parameter", func_id))?; @@ -373,7 +373,7 @@ pub(super) fn compile_typed_i32_closure( _ => return Err(anyhow!("compile_typed_i32_closure: expected Expr::Closure")), }; - let generic_name = format!("perry_closure_{}__{}", module_prefix, func_id); + let generic_name = crate::fn_info::closure_body_symbol(module_prefix, func_id); let llvm_name = typed_i32_closure_name(&generic_name); let mut llvm_params: Vec<(LlvmType, String)> = Vec::with_capacity(params.len() + 1); llvm_params.push((I64, "%this_closure".to_string())); @@ -510,7 +510,7 @@ pub(super) fn compile_closure( closure_relevant_ids.extend(params.iter().map(|p| p.id)); closure_relevant_ids.extend(captures.iter().copied()); - let public_llvm_name = format!("perry_closure_{}__{}", module_prefix, func_id); + let public_llvm_name = crate::fn_info::closure_body_symbol(module_prefix, func_id); let regex_factory_identity = (!is_async && !is_generator && params.is_empty() @@ -1182,7 +1182,6 @@ pub(super) fn compile_closure( array_length_snapshots: HashMap::new(), string_window_array_facts: Vec::new(), suppressed_cleared_shadow_slots: std::collections::HashSet::new(), - class_field_loop_facts: Vec::new(), region_loops: Vec::new(), region_loop_facts: Vec::new(), element_shape_loop_facts: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/emission_order_tests.rs b/crates/perry-codegen/src/codegen/emission_order_tests.rs index 3f3a286ec5..1f9480fe8b 100644 --- a/crates/perry-codegen/src/codegen/emission_order_tests.rs +++ b/crates/perry-codegen/src/codegen/emission_order_tests.rs @@ -73,6 +73,7 @@ fn ir_opts() -> CompileOptions { target: None, is_entry_module: false, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 4910e86420..94f4711445 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -147,6 +147,7 @@ pub(super) fn compile_module_entry( // resolves the symbols at link time. for prefix in non_entry_module_prefixes { llmod.declare_function(&format!("{}__init", prefix), VOID, &[]); + llmod.declare_function(&format!("__perry_prepare_literals_{}", prefix), VOID, &[]); } // Issue #753: emit a no-op `__init` stub so the // dispatch site in some other module that does `await @@ -547,6 +548,19 @@ pub(super) fn compile_module_entry( if crate::collectors::is_cjs_wrapped_module(hir) { blk.call_void("js_bootstrap_cjs_main_module_placeholder", &[]); } + // The topo sort intentionally drops cyclic evaluation back-edges. + // A first eager module can therefore call a later module's hoisted + // factory without ever reaching that module's __init wrapper. All + // eager literal pools must be ready before ANY eager body runs. + // Deferred pools remain lazy and prepare in their own wrapper. + for prefix in non_entry_module_prefixes { + if cross_module.deferred_module_prefixes.contains(prefix) { + continue; + } + let prepare_addr = + format!("ptrtoint (ptr @__perry_prepare_literals_{} to i64)", prefix); + blk.call_void("js_run_module_init_catching", &[(I64, &prepare_addr)]); + } for (index, prefix) in non_entry_module_prefixes.iter().enumerate() { if cross_module.deferred_module_prefixes.contains(prefix) { continue; @@ -780,7 +794,6 @@ pub(super) fn compile_module_entry( array_length_snapshots: HashMap::new(), string_window_array_facts: Vec::new(), suppressed_cleared_shadow_slots: std::collections::HashSet::new(), - class_field_loop_facts: Vec::new(), region_loops: Vec::new(), region_loop_facts: Vec::new(), element_shape_loop_facts: Vec::new(), @@ -1354,6 +1367,14 @@ pub(super) fn compile_module_entry( { let blk = wrap_fn.block_mut(2).unwrap(); blk.store(I8, "1", &format!("@{}", done_global)); + // Cyclic dependencies may call our hoisted functions before + // our body. Prepare literal infrastructure without evaluating + // declared classes or any user statement ahead of dependencies. + let prepare_addr = format!( + "ptrtoint (ptr @__perry_prepare_literals_{} to i64)", + module_prefix + ); + blk.call_void("js_run_module_init_catching", &[(I64, &prepare_addr)]); // Trigger init of static-dep + re-export source modules // before the body runs. Each `__init` is itself // wrapped by the same guard pattern, so this short- @@ -1646,7 +1667,6 @@ pub(super) fn compile_module_entry( array_length_snapshots: HashMap::new(), string_window_array_facts: Vec::new(), suppressed_cleared_shadow_slots: std::collections::HashSet::new(), - class_field_loop_facts: Vec::new(), region_loops: Vec::new(), region_loop_facts: Vec::new(), element_shape_loop_facts: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/entry/tests.rs b/crates/perry-codegen/src/codegen/entry/tests.rs index 0d78b4a9c1..b7fb7052cf 100644 --- a/crates/perry-codegen/src/codegen/entry/tests.rs +++ b/crates/perry-codegen/src/codegen/entry/tests.rs @@ -8,6 +8,7 @@ fn entry_opts(output_type: &str) -> CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), @@ -642,3 +643,22 @@ fn set_bun_platform_marker_lowers_to_the_runtime_flag_setter() { // Control: the default (node) platform never emits the call. assert!(!emitted_ir("executable").contains("call void @js_set_bun_platform")); } + +#[test] +fn eager_literal_pools_precede_all_bodies_but_deferred_pools_stay_lazy() { + let mut opts = entry_opts("executable"); + opts.non_entry_module_prefixes = vec!["first_ts".into(), "later_ts".into(), "lazy_ts".into()]; + opts.deferred_module_prefixes.insert("lazy_ts".into()); + let ir = String::from_utf8(compile_module(&empty_module(), opts).unwrap()).unwrap(); + let first_body = ir.find("call void @first_ts__init()").unwrap(); + let later_body = ir.find("call void @later_ts__init()").unwrap(); + let first_pool = ir + .find("ptr @__perry_prepare_literals_first_ts to i64") + .unwrap(); + let later_pool = ir + .find("ptr @__perry_prepare_literals_later_ts to i64") + .unwrap(); + assert!(first_pool < first_body && later_pool < first_body && first_body < later_body); + assert!(!ir.contains("ptr @__perry_prepare_literals_lazy_ts to i64")); + assert!(!ir.contains("call void @lazy_ts__init()")); +} diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 9bf2c13c69..71e76369cf 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -1318,7 +1318,6 @@ pub(super) fn compile_function( array_length_snapshots: HashMap::new(), string_window_array_facts: Vec::new(), suppressed_cleared_shadow_slots: std::collections::HashSet::new(), - class_field_loop_facts: Vec::new(), region_loops: Vec::new(), region_loop_facts: Vec::new(), element_shape_loop_facts: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/literal_method_this_tests.rs b/crates/perry-codegen/src/codegen/literal_method_this_tests.rs index 93083d75fd..423e8feffb 100644 --- a/crates/perry-codegen/src/codegen/literal_method_this_tests.rs +++ b/crates/perry-codegen/src/codegen/literal_method_this_tests.rs @@ -366,3 +366,67 @@ fn anon_shape_is_registered_before_its_shape_id_is_minted() { "js_register_anon_shape_class_id must run before the class ShapeId is minted" ); } + +/// Cyclic imports can invoke hoisted functions before dependency bodies finish. +/// Literal infrastructure must be ready then, without publishing declared-class +/// keys/prototypes/constructors ahead of their existing evaluation boundary. +#[test] +fn cyclic_literal_bootstrap_precedes_dependencies_without_prewarming_user_classes() { + let mut hir = literal_module(method(METHOD, false, false)); + hir.classes.push(anon_shape(2, "Declared", 91, 70)); + let opts = CompileOptions { + emit_ir_only: true, + is_entry_module: false, + module_init_deps: vec!["consumer_ts".into()], + ..Default::default() + }; + let ir = String::from_utf8(compile_module(&hir, opts).unwrap()).unwrap(); + let body = |symbol: &str| { + let start = ir + .lines() + .find(|line| line.starts_with("define ") && line.contains(&format!("@{symbol}("))) + .unwrap(); + let at = ir.find(start).unwrap(); + &ir[at..at + ir[at..].find("\n}\n").unwrap()] + }; + let wrapper = body("literal_method_this_test__init"); + let prepare = wrapper + .find("@__perry_prepare_literals_literal_method_this_test") + .unwrap(); + let dependency = wrapper.find("@consumer_ts__init()").unwrap(); + let module_body = wrapper + .find("@literal_method_this_test__init_body") + .unwrap(); + assert!( + prepare < dependency && dependency < module_body, + "{wrapper}" + ); + assert_eq!(wrapper.matches("@js_run_module_init_catching(").count(), 2); + let prepare = body("__perry_prepare_literals_literal_method_this_test"); + assert!( + prepare.contains("load i8") && prepare.contains("br i1"), + "{prepare}" + ); + assert!(!prepare.contains("_class_chunk"), "{prepare}"); + let literal_chunks = ir + .split("\n}\n") + .filter(|chunk| { + chunk.lines().any(|line| { + line.starts_with("define ") + && line.contains("__perry_init_strings_literal_method_this_test_literal_chunk") + }) + }) + .collect::>() + .join("\n"); + assert!(literal_chunks.contains("call void @js_register_anon_shape_class_id")); + assert!(literal_chunks.contains("@perry_class_keys_literal_method_this_test____AnonShape_")); + assert!(!literal_chunks.contains("@perry_class_keys_literal_method_this_test__Declared")); + assert!(!literal_chunks.contains("call void @js_register_class_constructor")); + assert!(!literal_chunks.contains("call void @js_register_class_getter")); + let strings = body("__perry_init_strings_literal_method_this_test"); + assert!( + strings.find("@__perry_prepare_literals_").unwrap() < strings.find("_class_chunk").unwrap() + ); + assert!(body("literal_method_this_test__init_body") + .contains("call void @__perry_init_strings_literal_method_this_test()")); +} diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index 87fd794db5..88fecf49a7 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -672,7 +672,6 @@ pub(super) fn compile_method( array_length_snapshots: HashMap::new(), string_window_array_facts: Vec::new(), suppressed_cleared_shadow_slots: std::collections::HashSet::new(), - class_field_loop_facts: Vec::new(), region_loops: Vec::new(), region_loop_facts: Vec::new(), element_shape_loop_facts: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/method_static.rs b/crates/perry-codegen/src/codegen/method_static.rs index f7c7f7a203..d75e79b6b4 100644 --- a/crates/perry-codegen/src/codegen/method_static.rs +++ b/crates/perry-codegen/src/codegen/method_static.rs @@ -351,7 +351,6 @@ pub(in crate::codegen) fn compile_static_method( array_length_snapshots: HashMap::new(), string_window_array_facts: Vec::new(), suppressed_cleared_shadow_slots: std::collections::HashSet::new(), - class_field_loop_facts: Vec::new(), region_loops: Vec::new(), region_loop_facts: Vec::new(), element_shape_loop_facts: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/method_trampolines.rs b/crates/perry-codegen/src/codegen/method_trampolines.rs index bb36463600..a9d436d8f8 100644 --- a/crates/perry-codegen/src/codegen/method_trampolines.rs +++ b/crates/perry-codegen/src/codegen/method_trampolines.rs @@ -330,7 +330,14 @@ pub(super) fn emit_guarded_nonnegative_index( let expected_shape_i64 = blk.zext(I32, &expected_shape, I64); let expected_shape_high = blk.shl(I64, &expected_shape_i64, "32"); let expected = blk.or(I64, &expected_shape_high, &expected_class_id.to_string()); - let shape_matches = blk.icmp_eq(I64, &class_shape, &expected); + let shape_matches = crate::typed_shape::emit_compatible_class_shape_eq( + blk, + &class_shape, + &expected_class_id.to_string(), + &expected_shape, + &expected, + &[], + ); let shape_rel = blk.add(I32, &expected_shape, "-2147483648"); let shape_valid = blk.icmp_ult(I32, &shape_rel, "1073741824"); let exact_layout = blk.and(I1, &gc_ok, &shape_matches); diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index e4aa216e01..66c0110105 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -251,15 +251,18 @@ mod spec_preserve_none_tests; mod spec_return_proof; #[cfg(test)] mod spec_self_recursion_tests; +pub(crate) mod static_constfn; +pub(crate) mod static_constfn_class; pub(crate) mod static_fields; mod static_shape_ids; pub use static_shape_ids::{ assign_static_shape_ids, decode_static_seed, encode_static_seed, take_module_static_seeds, - BirthProto, BirthShape, DefinedClassShape, ModuleBirth, ProgramClassShapeIds, TypedMasks, - STATIC_SEED_FORMAT, + BirthProto, BirthShape, ConstFnBirth, DefinedClassShape, ModuleBirth, ProgramClassShapeIds, + TypedMasks, STATIC_SEED_FORMAT, }; pub(crate) use static_shape_ids::{ - static_region_slots, static_shape_id_for_foreign_global, static_shape_id_for_keys_global, + compatible_final_shape_ids, slot_may_be_constfn, static_region_slots, + static_shape_id_for_foreign_global, static_shape_id_for_keys_global, }; mod string_pool; #[cfg(test)] @@ -469,10 +472,11 @@ fn compile_module_impl( let (live_cjs_hir, cjs_property_exports) = cjs_exports::prepare(hir); let hir = live_cjs_hir.as_ref(); // The driver sets the whole-program perry/thread flag before any module - // codegen. A direct compile_module caller has no graph, so also detect a - // launch in this module without changing shared compiler state. + // codegen. A direct compile_module caller without callback prefixes also + // needs local launch detection for its string-preparation callback, even + // when the process flag is already set. Do not change shared compiler state. let mut local_thread_use = false; - if !program_has_thread_agents() { + if opts.thread_literal_module_prefixes.is_empty() { perry_hir::for_each_module_expr(hir, &mut |expr| { if matches!(expr, perry_hir::Expr::NativeMethodCall { module, method, .. } if module == "perry/thread" @@ -482,7 +486,10 @@ fn compile_module_impl( } }); } - let thread_agents = program_has_thread_agents() || local_thread_use; + let thread_agents = program_has_thread_agents() + || !opts.thread_literal_module_prefixes.is_empty() + || local_thread_use; + let agent_strings_tls = program_has_worker() || thread_agents; let progress = CompileProgress::new(&hir.name, module_callable_count(hir)); let triple = opts.target.clone().unwrap_or_else(default_target_triple); if let Some(refusal) = crate::target_layout::ilp32_codegen_refusal(&triple) { @@ -577,7 +584,12 @@ fn compile_module_impl( // checker — the pool lives outside LlModule. The module prefix // becomes part of every emitted global so multi-module programs // don't collide on `.str.0.handle`. + let thread_literal_callback_prefix = opts + .thread_literal_module_prefixes + .first() + .unwrap_or(&module_prefix); let mut strings = StringPool::with_prefix(module_prefix.clone()); + strings.set_thread_literal_callback_prefix(thread_literal_callback_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 @@ -2475,6 +2487,29 @@ fn compile_module_impl( &class_birth_reps_map, &class_ids, ); + if static_constfn::enabled(&opts) { + let reps = class_keys_globals_map + .iter() + .filter_map(|(name, keys)| { + class_birth_reps_map + .get(keys) + .map(|rep| (name.clone(), *rep)) + }) + .collect(); + let class_finals = static_constfn_class::module_class_finals( + hir, + &module_prefix, + births, + &class_keys_globals_map, + &class_ids, + ); + births.extend(class_finals); + births.extend(static_constfn::module_literal_finals( + hir, + &module_prefix, + &reps, + )); + } return Ok(Vec::new()); } static_shape_ids::set_module_static_ids( @@ -2486,6 +2521,9 @@ fn compile_module_impl( &opts.static_shape_ids, &opts.program_class_shape_ids, ); + if !static_constfn::enabled(&opts) { + static_shape_ids::disable_static_final_shapes(); + } let class_header_images_map: std::collections::HashMap = class_keys_globals_map .iter() @@ -3797,50 +3835,104 @@ fn compile_module_impl( // entry-fn emission, string-pool init) lives in // `artifacts::emit_module_artifacts`. Behavior is unchanged — // see the doc on that fn for the split rationale. - emit_module_artifacts(ModuleArtifactsCtx { - progress: &progress, - llmod: &mut llmod, - target_triple: &triple, - strings: &mut strings, - hir, - import_function_prefixes: &opts.import_function_prefixes, - imported_classes: &opts.imported_classes, - constructor_param_counts: &opts.constructor_param_counts, - is_entry_module: opts.is_entry_module, - non_entry_module_prefixes: &opts.non_entry_module_prefixes, - output_type: &opts.output_type, - module_prefix: &module_prefix, - class_table: &class_table, - class_ids: &class_ids, - enum_table: &enum_table, - module_globals: &module_globals, - module_global_types: &module_global_types, - static_field_globals: &static_field_globals, - method_names: &method_names, - func_names: &func_names, - func_signatures: &func_signatures, - func_synthetic_arguments: &func_synthetic_arguments, - module_boxed_vars: &module_boxed_vars, - module_local_types: &module_local_types, - module_receiver_types: &module_receiver_types, - closure_rest_params: &closure_rest_params, - closure_synthetic_arguments: &closure_synthetic_arguments, - closure_rest_and_arguments: &closure_rest_and_arguments, - closure_arities: &closure_arities, - closure_lengths: &closure_lengths, - closure_arrow_functions: &closure_arrow_functions, - trusted_box_closures: &trusted_box_closures, - versioned_loop_callbacks: &versioned_loop_callbacks, - closures: &closures, - class_keys_init_data: &class_keys_init_data, - class_header_image_inits: &class_header_image_inits, - imported_class_stubs: &imported_class_stubs, - cross_module: &cross_module, - })?; + emit_module_artifacts( + ModuleArtifactsCtx { + progress: &progress, + llmod: &mut llmod, + target_triple: &triple, + strings: &mut strings, + hir, + import_function_prefixes: &opts.import_function_prefixes, + imported_classes: &opts.imported_classes, + constructor_param_counts: &opts.constructor_param_counts, + is_entry_module: opts.is_entry_module, + non_entry_module_prefixes: &opts.non_entry_module_prefixes, + output_type: &opts.output_type, + module_prefix: &module_prefix, + class_table: &class_table, + class_ids: &class_ids, + enum_table: &enum_table, + module_globals: &module_globals, + module_global_types: &module_global_types, + static_field_globals: &static_field_globals, + method_names: &method_names, + func_names: &func_names, + func_signatures: &func_signatures, + func_synthetic_arguments: &func_synthetic_arguments, + module_boxed_vars: &module_boxed_vars, + module_local_types: &module_local_types, + module_receiver_types: &module_receiver_types, + closure_rest_params: &closure_rest_params, + closure_synthetic_arguments: &closure_synthetic_arguments, + closure_rest_and_arguments: &closure_rest_and_arguments, + closure_arities: &closure_arities, + closure_lengths: &closure_lengths, + closure_arrow_functions: &closure_arrow_functions, + trusted_box_closures: &trusted_box_closures, + versioned_loop_callbacks: &versioned_loop_callbacks, + closures: &closures, + class_keys_init_data: &class_keys_init_data, + class_header_image_inits: &class_header_image_inits, + imported_class_stubs: &imported_class_stubs, + cross_module: &cross_module, + }, + agent_strings_tls, + )?; + + // The graph's first prefix owns its only preparation callback. All launch + // sites reference that symbol, including launches in other modules. With + // no graph, only a local launcher emits a local-only callback. Neither path + // evaluates module bodies; preparation precedes worker deserialization. + if thread_literal_callback_prefix == &module_prefix + && (!opts.thread_literal_module_prefixes.is_empty() || local_thread_use) + { + // Normalize only in the owner, never once per module. Preserve the + // owner as the first entry; the remaining string-only calls are pure + // preparation and have no module-evaluation ordering dependencies. + let mut prefixes: Vec<_> = opts + .thread_literal_module_prefixes + .iter() + .skip(1) + .filter(|prefix| *prefix != &module_prefix) + .cloned() + .collect(); + prefixes.sort(); + prefixes.dedup(); + prefixes.insert(0, module_prefix.clone()); + for prefix in &prefixes { + llmod.declare_function( + &format!("__perry_prepare_agent_strings_{}", prefix), + crate::types::VOID, + &[], + ); + } + let callback = llmod.define_function( + format!("__perry_prepare_thread_strings_{}", module_prefix), + crate::types::VOID, + vec![], + ); + let blk = callback.create_block("entry"); + for prefix in &prefixes { + blk.call_void(&format!("__perry_prepare_agent_strings_{}", prefix), &[]); + } + blk.ret_void(); + } // One `JsFunctionInfo` per body a function object runs (`crate::fn_info`), // after every function — and so every allocation site — exists. - llmod.emit_fn_infos(); + // Step 5C is opt-in until its GC/image and performance gates pass. + // A dylib never advertises a permanent body, even with the knob set. + let constfn_body_metadata = opts.output_type == "executable" + && std::env::var("PERRY_CONSTFN_SHAPE").as_deref() == Ok("1"); + if constfn_body_metadata { + // Omitted/dead literals must not leave body-info relocations behind. + static_constfn::emit_final_entries( + &mut llmod, + &module_prefix, + &static_shape_ids::module_final_seeds(), + ); + } + llmod.emit_fn_infos(constfn_body_metadata); // Emit the buffer alias-scope metadata once per module, covering every // scope id allocated across compile_function / compile_closure / diff --git a/crates/perry-codegen/src/codegen/number_exactness_tests.rs b/crates/perry-codegen/src/codegen/number_exactness_tests.rs index 93e4cc38ac..723fb8b85c 100644 --- a/crates/perry-codegen/src/codegen/number_exactness_tests.rs +++ b/crates/perry-codegen/src/codegen/number_exactness_tests.rs @@ -32,6 +32,7 @@ fn ir_opts() -> CompileOptions { target: None, is_entry_module: false, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs index a3a402253b..480d0b316f 100644 --- a/crates/perry-codegen/src/codegen/opts.rs +++ b/crates/perry-codegen/src/codegen/opts.rs @@ -136,6 +136,15 @@ pub struct CompileOptions { /// order matches Perry's existing topological sort (set up by the /// CLI driver in `crates/perry/src/commands/compile.rs`). pub non_entry_module_prefixes: Vec, + /// Complete native module graph, supplied identically to every module when + /// perry/thread can launch agents. The first prefix owns the graph's single + /// preparation callback (the CLI chooses the actual entry module); remaining + /// prefixes are unique and sorted, excluding the owner. Owner order affects + /// symbols and object cache identity. Every compiled module must be included. + /// Empty permits a direct local launcher to prepare only its own pool. + /// String preparation has no module-evaluation effects; deferred bodies and + /// declared-class registration remain lazy. + pub thread_literal_module_prefixes: Vec, /// For each imported function name in this module, the prefix of the /// source module that exports it. Used by `ExternFuncRef` lowering /// in `lower_call` to generate the correct cross-module call to diff --git a/crates/perry-codegen/src/codegen/static_constfn.rs b/crates/perry-codegen/src/codegen/static_constfn.rs new file mode 100644 index 0000000000..3a207aae25 --- /dev/null +++ b/crates/perry-codegen/src/codegen/static_constfn.rs @@ -0,0 +1,362 @@ +//! Static ConstFn describes a completed object. Allocation ids remain Any/F64. +use super::{BirthProto, BirthShape, CompileOptions, ConstFnBirth, ModuleBirth}; +use perry_hir::{Class, Expr, Module, Stmt}; +use std::collections::{BTreeSet, HashMap}; + +pub(crate) fn enabled(opts: &CompileOptions) -> bool { + opts.output_type == "executable" && std::env::var("PERRY_CONSTFN_SHAPE").as_deref() == Ok("1") +} + +pub(crate) fn literal_final( + prefix: &str, + props: &[(String, Expr)], + base_rep: u64, +) -> Option { + if props.is_empty() || props.len() > 32 { + return None; + } + let mut seen = BTreeSet::new(); + let mut keys = Vec::new(); + let mut rep = base_rep; + let mut constfn = Vec::new(); + for (slot, (key, value)) in props.iter().enumerate() { + if key.is_empty() || key.contains('\0') || key == "__proto__" || !seen.insert(key) { + return None; + } + keys.extend_from_slice(key.as_bytes()); + keys.push(0); + if let Expr::Closure { + func_id, + params, + is_async: false, + is_generator: false, + captures_this, + is_arrow, + .. + } = value + { + // Rebindable method clones cannot satisfy the direct body ABI. + if (*captures_this && !*is_arrow) + || params + .iter() + .any(|p| p.is_rest || p.arguments_object.is_some()) + { + continue; + } + if (base_rep >> (slot * 2)) & 3 != 0 { + return None; + } + rep |= 3 << (slot * 2); + constfn.push(ConstFnBirth { + slot: slot as u8, + symbol: crate::fn_info::info_symbol(&crate::fn_info::closure_body_symbol( + prefix, *func_id, + )), + }); + } + } + if constfn.is_empty() { + return None; + } + Some(BirthShape { + keys, + key_count: props.len() as u32, + live: props.len() as u32, + proto: BirthProto::Literal, + typed: None, + rep, + constfn, + }) +} + +/// Only the exact synthetic record constructor is admitted. Arbitrary classes, +/// heritage, descriptors, computed keys and early returns fail closed. +pub(crate) fn anon_props(class: &Class, args: &[Expr]) -> Option> { + if !class.is_literal_shape() || class.fields.len() != args.len() { + return None; + } + Some( + class + .fields + .iter() + .zip(args) + .map(|(f, value)| (f.name.clone(), value.clone())) + .collect(), + ) +} + +pub(crate) fn module_literal_finals( + module: &Module, + prefix: &str, + class_reps: &HashMap, +) -> Vec { + fn expr( + e: &Expr, + module: &Module, + prefix: &str, + reps: &HashMap, + out: &mut BTreeSet, + ) { + let shape = match e { + Expr::Object(props) => literal_final(prefix, props, 0), + Expr::New { + class_name, + args, + cap_args_appended: 0, + .. + } => module + .classes + .iter() + .find(|c| &c.name == class_name) + .and_then(|c| anon_props(c, args)) + .and_then(|props| literal_final(prefix, &props, *reps.get(class_name)?)), + _ => None, + }; + if let Some(shape) = shape { + out.insert(shape); + } + if let Expr::Closure { body, .. } = e { + stmts(body, module, prefix, reps, out); + } + perry_hir::walker::walk_expr_children(e, &mut |child| { + expr(child, module, prefix, reps, out) + }); + } + fn stmts( + body: &[Stmt], + m: &Module, + p: &str, + r: &HashMap, + out: &mut BTreeSet, + ) { + for stmt in body { + match stmt { + Stmt::Let { init, .. } | Stmt::Return(init) => { + if let Some(e) = init { + expr(e, m, p, r, out); + } + } + Stmt::Expr(e) | Stmt::Throw(e) => expr(e, m, p, r, out), + Stmt::If { + condition, + then_branch, + else_branch, + } => { + expr(condition, m, p, r, out); + stmts(then_branch, m, p, r, out); + if let Some(b) = else_branch { + stmts(b, m, p, r, out); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { condition, body } => { + expr(condition, m, p, r, out); + stmts(body, m, p, r, out); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(s) = init { + stmts(std::slice::from_ref(s), m, p, r, out); + } + for e in [condition, update].into_iter().flatten() { + expr(e, m, p, r, out); + } + stmts(body, m, p, r, out); + } + Stmt::Labeled { body, .. } => stmts(std::slice::from_ref(body), m, p, r, out), + Stmt::Try { + body, + catch, + finally, + } => { + stmts(body, m, p, r, out); + if let Some(c) = catch { + stmts(&c.body, m, p, r, out); + } + if let Some(b) = finally { + stmts(b, m, p, r, out); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + expr(discriminant, m, p, r, out); + for c in cases { + if let Some(e) = &c.test { + expr(e, m, p, r, out); + } + stmts(&c.body, m, p, r, out); + } + } + Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::PreallocateBoxes(_) + | Stmt::PreallocateTdzBoxes(_) + | Stmt::ReleaseBoxes(_) => {} + } + } + } + let mut out = BTreeSet::new(); + stmts(&module.init, module, prefix, class_reps, &mut out); + for global in &module.globals { + if let Some(e) = &global.init { + expr(e, module, prefix, class_reps, &mut out); + } + } + for f in &module.functions { + stmts(&f.body, module, prefix, class_reps, &mut out); + } + for c in &module.classes { + for f in c + .constructor + .iter() + .chain(&c.methods) + .chain(&c.static_methods) + .chain(c.getters.iter().map(|(_, f)| f)) + .chain(c.setters.iter().map(|(_, f)| f)) + { + stmts(&f.body, module, prefix, class_reps, &mut out); + } + for f in c.fields.iter().chain(&c.static_fields) { + if let Some(e) = &f.init { + expr(e, module, prefix, class_reps, &mut out); + } + } + } + out.into_iter() + .map(|shape| ModuleBirth { + keys_global: format!("perry_constfn_final_{}", shape.constfn[0].symbol), + class_id: 0, + defined: false, + shape, + }) + .collect() +} + +pub(crate) fn entries_symbol(prefix: &str, id: u32) -> String { + format!("perry_constfn_final_{prefix}__{id}") +} + +pub(crate) fn emit_final_entries( + module: &mut crate::module::LlModule, + prefix: &str, + shapes: &[(BirthShape, u32)], +) { + for (shape, id) in shapes.iter().filter(|(shape, _)| !shape.constfn.is_empty()) { + let entries = shape + .constfn + .iter() + .map(|e| { + module.request_static_seed_body( + e.symbol.strip_suffix("$info").expect("body info suffix"), + ); + format!("{{ i32, ptr }} {{ i32 {}, ptr @{} }}", e.slot, e.symbol) + }) + .collect::>() + .join(", "); + module.add_raw_global(format!( + "@{} = private constant [{} x {{ i32, ptr }}] [{entries}]", + entries_symbol(prefix, *id), + shape.constfn.len() + )); + } +} + +pub(crate) fn has_final_shapes() -> bool { + super::static_shape_ids::has_static_final_shapes() +} + +/// Called only below the last store/this patch. The runtime owns a root during +/// minting and returns its refreshed handle for the expression's result. +pub(crate) fn finalize_literal( + ctx: &mut crate::expr::FnCtx<'_>, + props: &[(String, Expr)], + base_rep: u64, + object: &str, +) -> String { + if !super::static_shape_ids::has_static_final_shapes() { + return object.to_string(); + } + let Some(shape) = literal_final(ctx.strings.module_prefix(), props, base_rep) else { + return object.to_string(); + }; + finalize_shape(ctx, &shape, object) +} + +pub(crate) fn finalize_class(ctx: &mut crate::expr::FnCtx<'_>, name: &str, boxed: &str) -> String { + let shape = ctx.classes.get(name).and_then(|class| { + let rep = *ctx + .class_birth_reps + .get(ctx.class_keys_globals.get(name)?)?; + let cid = *ctx.class_ids.get(name)?; + super::static_constfn_class::class_final( + ctx.strings.module_prefix(), + class, + ctx.classes, + rep, + cid, + ) + .ok() + }); + let Some(shape) = shape else { + return boxed.to_string(); + }; + if super::static_shape_ids::static_final_shape_id(&shape).is_none() { + return boxed.to_string(); + } + // `boxed` is the completed receiver, including constructor return override. + // The proof declines replacement-return constructors. Never recover the + // original allocation root here: super()/constructors own this selection. + let bits = ctx.block().bitcast_double_to_i64(boxed); + let handle = ctx + .block() + .and(crate::types::I64, &bits, crate::nanbox::POINTER_MASK_I64); + let result = finalize_shape(ctx, &shape, &handle); + crate::expr::nanbox_pointer_inline(ctx.block(), &result) +} + +fn finalize_shape(ctx: &mut crate::expr::FnCtx<'_>, shape: &BirthShape, object: &str) -> String { + let Some(id) = super::static_shape_ids::static_final_shape_id(&shape) else { + return object.to_string(); + }; + let entries = format!("@{}", entries_symbol(ctx.strings.module_prefix(), id)); + let packed = String::from_utf8(shape.keys.clone()).expect("UTF-8 property names"); + let key_idx = ctx.strings.intern(&packed); + let key = ctx.strings.entry(key_idx); + let global = format!("@{}", key.bytes_global); + let len = key.byte_len.to_string(); + use crate::types::{I32, I64, PTR}; + ctx.block().call( + I64, + "js_object_finalize_constfn_static", + &[ + (I64, object), + (I32, &id.to_string()), + (PTR, &global), + (I32, &len), + (I32, &shape.key_count.to_string()), + (I32, &shape.live.to_string()), + ( + I32, + &match shape.proto { + BirthProto::Literal => 0, + BirthProto::Class(cid) => cid, + } + .to_string(), + ), + (I64, &shape.rep.to_string()), + (PTR, &entries), + (I32, &shape.constfn.len().to_string()), + ], + ) +} + +#[cfg(test)] +#[path = "static_constfn_tests.rs"] +mod tests; diff --git a/crates/perry-codegen/src/codegen/static_constfn_class.rs b/crates/perry-codegen/src/codegen/static_constfn_class.rs new file mode 100644 index 0000000000..a82ca29126 --- /dev/null +++ b/crates/perry-codegen/src/codegen/static_constfn_class.rs @@ -0,0 +1,213 @@ +//! Conservative completed layouts for ordinary, locally defined user classes. +use super::{BirthProto, BirthShape, ModuleBirth}; +use perry_hir::{Class, Expr, Module, Stmt}; +use std::collections::{BTreeSet, HashMap}; + +// Admit expressions with no explicit property mutation or receiver dispatch. +// Operators can still coerce/collect; the runtime finalizer validates the +// resulting receiver after every construction effect has completed. +fn value_proof(e: &Expr) -> bool { + match e { + Expr::Closure { .. } => true, // body runs later, captures are current slots + // A direct, argument-free function effect cannot carry this receiver + // as an argument. It may allocate/collect; final facts are validated + // only after it returns. Receiver/property dispatch stays unsupported. + Expr::Call { callee, args, .. } => { + matches!(callee.as_ref(), Expr::FuncRef(_)) && args.is_empty() + } + Expr::Undefined + | Expr::Null + | Expr::Bool(_) + | Expr::Number(_) + | Expr::Integer(_) + | Expr::String(_) + | Expr::BigInt(_) + | Expr::LocalGet(_) + | Expr::GlobalGet(_) + | Expr::This => true, + Expr::Binary { .. } + | Expr::Unary { .. } + | Expr::Compare { .. } + | Expr::Logical { .. } + | Expr::Object(_) => { + let mut valid = true; + perry_hir::walker::walk_expr_children(e, &mut |child| valid &= value_proof(child)); + valid + } + _ => false, + } +} + +/// Concrete refusal reasons keep extensions of this proof reviewable. A local +/// root-to-leaf chain is required: imported stubs do not retain initializer +/// bodies, and dynamic/native heritage cannot prove a completed layout. +pub(crate) fn class_final( + prefix: &str, + class: &Class, + classes: &HashMap, + base_rep: u64, + class_id: u32, +) -> Result { + if class_id == 0 || class.name.starts_with("__AnonShape_") { + return Err("not an ordinary user class"); + } + let mut chain = Vec::new(); + let mut seen = BTreeSet::new(); + let mut current = class; + loop { + if !seen.insert(¤t.name) { + return Err("cyclic heritage"); + } + if current.is_imported_stub() + || current.extends_expr.is_some() + || current.native_extends.is_some() + || current.heritage_lexically_shadowed + { + return Err("unresolved or external heritage"); + } + if current.is_nested || current.alloc_width_hint != 0 { + return Err("captured or widened construction layout"); + } + if !current.decorators.is_empty() + || !current.computed_members.is_empty() + || current.has_private_instance_elements() + || !current.getters.is_empty() + || !current.setters.is_empty() + || current.methods.iter().any(|m| !m.decorators.is_empty()) + || current + .static_fields + .iter() + .any(|f| f.is_private || f.key_expr.is_some() || !f.decorators.is_empty()) + || current + .static_methods + .iter() + .any(|m| m.name.starts_with('#') || !m.decorators.is_empty()) + { + return Err("computed, private, decorated or accessor members"); + } + chain.push(current); + match current.extends_name.as_deref() { + Some(name) => { + let parent = classes + .get(name) + .copied() + .ok_or("unresolved named heritage")?; + if current.extends.is_some_and(|id| id != parent.id) { + return Err("ambiguous heritage identity"); + } + current = parent; + } + None if current.extends.is_some() => return Err("unnamed heritage"), + None => break, + } + } + let mut props = Vec::new(); + for c in chain.iter().rev() { + for field in &c.fields { + if field.is_private + || field.key_expr.is_some() + || !field.decorators.is_empty() + || field.name.starts_with("__perry_cap_") + { + return Err("nonpublic or captured field layout"); + } + let init = field.init.clone().unwrap_or(Expr::Undefined); + if !value_proof(&init) { + return Err("initializer has unsupported dispatch or property mutation"); + } + props.push((field.name.clone(), init)); + } + } + let names: BTreeSet<_> = props.iter().map(|(name, _)| name.as_str()).collect(); + for c in &chain { + if let Some(ctor) = &c.constructor { + if ctor.is_async + || ctor.is_generator + || !ctor.decorators.is_empty() + || ctor.params.iter().any(|p| { + !p.decorators.is_empty() || p.default.as_ref().is_some_and(|e| !value_proof(e)) + }) + { + return Err("unsupported constructor signature"); + } + let mut supers = 0; + for stmt in &ctor.body { + let valid = match stmt { + Stmt::Let { init, .. } => init.as_ref().is_none_or(value_proof), + Stmt::Expr(Expr::SuperCall(args)) if c.extends_name.is_some() => { + supers += 1; + supers == 1 && args.iter().all(value_proof) + } + Stmt::Expr(Expr::PropertySet { + object, + property, + value, + }) => { + matches!(object.as_ref(), Expr::This) + && names.contains(property.as_str()) + && value_proof(value) + } + Stmt::Expr(Expr::PutValueSet { + target, + key, + value, + receiver, + .. + }) => { + matches!(target.as_ref(), Expr::This) + && matches!(receiver.as_ref(), Expr::This) + && matches!(key.as_ref(), Expr::String(k) if names.contains(k.as_str())) + && value_proof(value) + } + Stmt::Expr(e) => value_proof(e), + _ => false, // early/value returns, branches, descriptors, delete + }; + if !valid { + return Err("constructor control flow or property mutation"); + } + } + if c.extends_name.is_some() && supers != 1 { + return Err("derived constructor requires one explicit super call"); + } + } + } + let mut shape = super::static_constfn::literal_final(prefix, &props, base_rep) + .ok_or("no safe closure initializer or uncertain slot order")?; + shape.proto = BirthProto::Class(class_id); + Ok(shape) +} + +pub(crate) fn module_class_finals( + module: &Module, + prefix: &str, + ordinary: &[ModuleBirth], + keys_globals: &HashMap, + class_ids: &HashMap, +) -> Vec { + let classes = module.classes.iter().map(|c| (c.name.clone(), c)).collect(); + let ordinary: HashMap<_, _> = ordinary + .iter() + .map(|b| (b.keys_global.as_str(), &b.shape)) + .collect(); + module + .classes + .iter() + .filter_map(|class| { + let keys = keys_globals.get(&class.name)?; + let birth = *ordinary.get(keys.as_str())?; + let cid = *class_ids.get(&class.name)?; + let shape = class_final(prefix, class, &classes, birth.rep, cid).ok()?; + // Allocation is the authority for keys and live capacity too. A + // widened/inferred layout is declined, never guessed by this producer. + if shape.keys != birth.keys || shape.live != birth.live || shape.proto != birth.proto { + return None; + } + Some(ModuleBirth { + keys_global: format!("perry_constfn_class_final_{prefix}__{cid}"), + class_id: cid, + defined: false, + shape, + }) + }) + .collect() +} diff --git a/crates/perry-codegen/src/codegen/static_constfn_tests.rs b/crates/perry-codegen/src/codegen/static_constfn_tests.rs new file mode 100644 index 0000000000..722647fdb6 --- /dev/null +++ b/crates/perry-codegen/src/codegen/static_constfn_tests.rs @@ -0,0 +1,622 @@ +use super::*; +use perry_hir::types::Type; +use perry_hir::{Function, Param}; + +static ENV: std::sync::Mutex<()> = std::sync::Mutex::new(()); +struct Pin(Option); +impl Drop for Pin { + fn drop(&mut self) { + match self.0.take() { + Some(v) => std::env::set_var("PERRY_CONSTFN_SHAPE", v), + None => std::env::remove_var("PERRY_CONSTFN_SHAPE"), + } + } +} +fn closure(id: u32, this: bool) -> Expr { + Expr::Closure { + func_id: id, + params: Vec::new(), + return_type: Type::Any, + body: vec![Stmt::Return(Some(Expr::Number(1.0)))], + captures: Vec::new(), + mutable_captures: Vec::new(), + captures_this: this, + captures_new_target: false, + enclosing_class: None, + is_arrow: !this, + is_async: false, + is_generator: false, + is_strict: true, + } +} +fn fixture() -> Module { + let mut m = Module::new("cf_final_order"); + m.init.push(Stmt::Expr(Expr::Object(vec![ + ("m".into(), closure(1, false)), + ("unsafe".into(), closure(2, true)), + ("collecting".into(), Expr::Object(Vec::new())), + ]))); + m +} +fn opts(output: &str) -> CompileOptions { + CompileOptions { + emit_ir_only: true, + output_type: output.into(), + ..Default::default() + } +} + +#[test] +fn final_literal_seed_and_lowering_share_symbols_and_stamp_after_stores_and_patches() { + let _lock = ENV.lock().unwrap_or_else(|e| e.into_inner()); + let _pin = Pin(std::env::var_os("PERRY_CONSTFN_SHAPE")); + std::env::set_var("PERRY_CONSTFN_SHAPE", "1"); + let m = fixture(); + let births = crate::module_birth_shapes(&m, opts("executable")).unwrap(); + assert_eq!( + births.len(), + 1, + "the collecting literal must have a final producer" + ); + let shape = &births[0].shape; + assert_eq!(shape.rep, 3, "only the safe closure lane becomes SPECIAL"); + assert_eq!( + shape.constfn[0].symbol, + "perry_closure_cf_final_order__1$info" + ); + let assigned = + super::super::static_shape_ids::assign_static_shape_ids(births.iter().map(|b| &b.shape)); + let id = assigned[shape]; + let mut options = opts("executable"); + options.static_shape_ids = assigned.into_iter().collect(); + let ir = String::from_utf8(crate::compile_module(&m, options.clone()).unwrap()).unwrap(); + let body = ir + .lines() + .filter(|l| !l.starts_with("declare ")) + .collect::>() + .join("\n"); + let stamp = body + .find("call i64 @js_object_finalize_constfn_static") + .expect("finalizer was emitted"); + assert!( + body.rfind("call void @js_object_set_field").unwrap() < stamp, + "every store must precede promotion" + ); + assert!( + body.rfind("call void @js_closure_set_capture_bits") + .unwrap() + < stamp, + "this patches must precede promotion" + ); + let allocation = body + .find("call i64 @js_object_alloc_with_shape") + .expect("ordinary allocation"); + assert!(allocation < stamp); + assert!( + !body[allocation..body[allocation..].find('\n').unwrap() + allocation] + .contains(&format!("i32 {id},")), + "allocation cannot name the final id" + ); + assert!( + ir.contains("hidden constant") && ir.contains("perry_closure_cf_final_order__1$info"), + "seed body info must be linkable" + ); + let seeds = super::super::static_shape_ids::take_module_static_seeds(); + assert_eq!(seeds, vec![(id, shape.clone())]); + let warm = crate::decode_static_seed(&crate::encode_static_seed(id, shape)).unwrap(); + assert_eq!( + crate::stubs::static_shape_seed_ll(&seeds), + crate::stubs::static_shape_seed_ll(&[warm]), + "cold and sidecar replay must have identical seed references" + ); + options.output_type = "dylib".into(); + let unloadable = String::from_utf8(crate::compile_module(&m, options).unwrap()).unwrap(); + assert!(!unloadable.contains("call i64 @js_object_finalize_constfn_static")); + assert!(super::super::static_shape_ids::take_module_static_seeds().is_empty()); +} + +#[test] +fn literal_proof_rejects_duplicates_and_rebindable_rest_bodies() { + let safe = closure(1, false); + let first = literal_final("p", &[("m".into(), safe.clone())], 0).unwrap(); + let second = literal_final("p", &[("m".into(), closure(2, false))], 0).unwrap(); + assert_ne!(first, second, "exact body symbols split final identity"); + assert_eq!( + first, + literal_final("p", &[("m".into(), safe.clone())], 0).unwrap() + ); + assert!(literal_final( + "p", + &[("m".into(), safe.clone()), ("m".into(), safe.clone())], + 0 + ) + .is_none()); + assert!(literal_final("p", &[("__proto__".into(), safe.clone())], 0).is_none()); + assert!(literal_final("p", &[("m".into(), closure(3, true))], 0).is_none()); + let mut rest = safe; + if let Expr::Closure { params, .. } = &mut rest { + params.push(Param { + id: 4, + name: "args".into(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: true, + arguments_object: None, + }); + } + assert!(literal_final("p", &[("m".into(), rest)], 0).is_none()); +} + +fn empty_class() -> Class { + Class { + id: 7, + name: "__AnonShape_test".into(), + type_params: Vec::new(), + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: Vec::new(), + constructor: None, + methods: Vec::new(), + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + computed_members: Vec::new(), + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + is_nested: false, + alloc_width_hint: 0, + specialized_from: None, + } +} + +#[test] +fn anonymous_record_admission_uses_the_full_constructor_proof() { + let mut class = empty_class(); + class.constructor = Some(Function { + id: 8, + name: "constructor".into(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Any, + body: vec![Stmt::Return(Some(Expr::Object(Vec::new())))], + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }); + assert!(anon_props(&class, &[]).is_none()); + class.constructor.as_mut().unwrap().body.clear(); + assert!( + anon_props(&class, &[]).is_some(), + "the exact synthetic constructor must be admitted" + ); + class + .getters + .push(("m".into(), class.constructor.clone().unwrap())); + assert!( + anon_props(&class, &[]).is_none(), + "descriptor-bearing constructors stay ordinary" + ); +} + +#[test] +fn closed_literal_constructor_emits_a_separate_final_shape() { + let _lock = ENV.lock().unwrap_or_else(|e| e.into_inner()); + let _pin = Pin(std::env::var_os("PERRY_CONSTFN_SHAPE")); + std::env::set_var("PERRY_CONSTFN_SHAPE", "1"); + let mut class = empty_class(); + class.fields.push(perry_hir::ClassField { + name: "m".into(), + key_expr: None, + ty: Type::Any, + init: None, + is_private: false, + is_readonly: false, + decorators: Vec::new(), + }); + class.constructor = Some(Function { + id: 8, + name: "constructor".into(), + type_params: Vec::new(), + params: vec![Param { + id: 9, + name: "m".into(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }], + return_type: Type::Void, + body: vec![Stmt::Expr(Expr::PropertySet { + object: Box::new(Expr::This), + property: "m".into(), + value: Box::new(Expr::LocalGet(9)), + })], + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }); + assert!( + class.is_literal_shape(), + "record constructor proof must be live" + ); + let mut m = Module::new("cf_closed_literal"); + m.init.push(Stmt::Expr(Expr::New { + class_name: class.name.clone(), + args: vec![closure(1, false)], + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + })); + m.classes.push(class); + let births = crate::module_birth_shapes(&m, opts("executable")).unwrap(); + let final_shape = births + .iter() + .find(|b| !b.shape.constfn.is_empty()) + .expect("closed literal final content") + .shape + .clone(); + assert_eq!( + births.len(), + 2, + "ordinary allocation and final content must coexist" + ); + assert_eq!(final_shape.rep, 3); + assert!(births + .iter() + .any(|b| b.shape.constfn.is_empty() && b.shape.rep == 0)); + let assigned = + super::super::static_shape_ids::assign_static_shape_ids(births.iter().map(|b| &b.shape)); + let mut options = opts("executable"); + options.static_shape_ids = assigned.into_iter().collect(); + let ir = String::from_utf8(crate::compile_module(&m, options).unwrap()).unwrap(); + assert_eq!( + ir.matches("call i64 @js_object_finalize_constfn_static") + .count(), + 1, + "completed record must call the finalizer once" + ); + assert!(super::super::static_shape_ids::take_module_static_seeds() + .iter() + .any(|(_, s)| s == &final_shape)); +} + +fn user_class(name: &str, id: u32, method_id: u32) -> Class { + let mut class = empty_class(); + class.name = name.into(); + class.id = id; + class.fields.push(perry_hir::ClassField { + name: format!("m{id}"), + key_expr: None, + ty: Type::Any, + init: Some(closure(method_id, false)), + is_private: false, + is_readonly: false, + decorators: Vec::new(), + }); + class +} + +fn constructor(body: Vec) -> Function { + Function { + id: 100, + name: "constructor".into(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Void, + body, + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + } +} + +#[test] +fn general_class_proof_covers_local_inheritance_and_declines_uncertain_construction() { + use super::super::static_constfn_class::class_final; + let base = user_class("Base", 7, 1); + let mut child = user_class("Child", 8, 2); + child.extends = Some(7); + child.extends_name = Some("Base".into()); + child.constructor = Some(constructor(vec![Stmt::Expr(Expr::SuperCall(Vec::new()))])); + let parents = HashMap::from([("Base".into(), &base)]); + let shape = class_final("p", &child, &parents, 0, 88).unwrap(); + assert_eq!(shape.proto, BirthProto::Class(88)); + assert_eq!(shape.keys, b"m7\0m8\0"); + assert_eq!(shape.rep, 15); + assert_eq!( + shape.constfn.iter().map(|e| e.slot).collect::>(), + vec![0, 1] + ); + assert_eq!(shape.constfn[0].symbol, "perry_closure_p__1$info"); + assert_eq!( + class_final("p", &child, &HashMap::new(), 0, 88).unwrap_err(), + "unresolved named heritage" + ); + child.constructor = Some(constructor(vec![Stmt::Return(None)])); + assert_eq!( + class_final("p", &child, &parents, 0, 88).unwrap_err(), + "constructor control flow or property mutation" + ); + child.constructor = Some(constructor(vec![Stmt::Return(Some(Expr::Object( + Vec::new(), + )))])); + assert!(class_final("p", &child, &parents, 0, 88).is_err()); + child.constructor = None; + child.fields[0].key_expr = Some(Expr::String("m8".into())); + assert_eq!( + class_final("p", &child, &parents, 0, 88).unwrap_err(), + "nonpublic or captured field layout" + ); + child.fields[0].key_expr = None; + child.fields[0].is_private = true; + assert!(class_final("p", &child, &parents, 0, 88).is_err()); + child.fields[0].is_private = false; + child.fields[0].name = "m7".into(); + assert_eq!( + class_final("p", &child, &parents, 0, 88).unwrap_err(), + "no safe closure initializer or uncertain slot order" + ); + child.fields[0].name = "m8".into(); + child.extends_expr = Some(Box::new(Expr::GlobalGet(1))); + assert_eq!( + class_final("p", &child, &parents, 0, 88).unwrap_err(), + "unresolved or external heritage" + ); + let mut mutated = base.clone(); + mutated.constructor = Some(constructor(vec![Stmt::Expr(Expr::PropertySet { + object: Box::new(Expr::This), + property: "added".into(), + value: Box::new(Expr::Number(1.0)), + })])); + assert_eq!( + class_final("p", &mutated, &HashMap::new(), 0, 77).unwrap_err(), + "constructor control flow or property mutation" + ); +} + +#[test] +fn general_class_records_follow_registration_and_finalize_the_completed_result() { + let _lock = ENV.lock().unwrap_or_else(|e| e.into_inner()); + let _pin = Pin(std::env::var_os("PERRY_CONSTFN_SHAPE")); + std::env::set_var("PERRY_CONSTFN_SHAPE", "1"); + let mut class = user_class("User", 7, 1); + class.fields.push(perry_hir::ClassField { + name: "effect".into(), + key_expr: None, + ty: Type::Any, + init: Some(Expr::Call { + callee: Box::new(Expr::FuncRef(90)), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + }), + is_private: false, + is_readonly: false, + decorators: Vec::new(), + }); + class.constructor = Some(constructor(vec![Stmt::Expr(Expr::PropertySet { + object: Box::new(Expr::This), + property: "effect".into(), + value: Box::new(Expr::Object(Vec::new())), + })])); + let mut m = Module::new("cf_user_class"); + let mut effect = constructor(vec![ + Stmt::Expr(Expr::Object(Vec::new())), + Stmt::Return(Some(Expr::Number(1.0))), + ]); + effect.id = 90; + effect.name = "collect".into(); + effect.return_type = Type::Any; + m.functions.push(effect); + m.classes.push(class); + m.init.push(Stmt::Expr(Expr::New { + class_name: "User".into(), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + })); + let births = crate::module_birth_shapes(&m, opts("executable")).unwrap(); + let ordinary = births.iter().find(|b| b.shape.constfn.is_empty()).unwrap(); + let final_content = births.iter().find(|b| !b.shape.constfn.is_empty()).unwrap(); + assert_eq!(births.len(), 2); + assert_eq!(ordinary.shape.keys, final_content.shape.keys); + assert_eq!(ordinary.shape.proto, final_content.shape.proto); + assert_eq!(ordinary.shape.rep, 0); + assert_eq!(final_content.shape.rep, 3); + let assigned = + super::super::static_shape_ids::assign_static_shape_ids(births.iter().map(|b| &b.shape)); + let final_id = assigned[&final_content.shape]; + let mut options = opts("executable"); + options.static_shape_ids = assigned.into_iter().collect(); + let ir = String::from_utf8(crate::compile_module(&m, options.clone()).unwrap()).unwrap(); + let calls = ir + .lines() + .filter(|l| l.contains(" call ")) + .collect::>(); + let mint = calls + .iter() + .position(|l| l.contains("@js_object_final_shape_id_for_class_keys_static_constfn")) + .unwrap(); + assert!( + calls + .iter() + .rposition(|l| l.contains("@js_register_class_name")) + .unwrap() + < mint + ); + let mint_line = calls[mint]; + assert!( + mint_line.contains(&format!("i32 {final_id}, i64 3")), + "{mint_line}" + ); + assert!(mint_line.contains("ptr @perry_constfn_final_cf_user_class__")); + assert!(ir.contains("perry_closure_cf_user_class__1$info = hidden constant")); + let stamp = calls + .iter() + .position(|l| l.contains("@js_object_finalize_constfn_static")) + .unwrap(); + let override_pos = calls + .iter() + .position(|l| l.contains("@js_ctor_return_override")) + .unwrap(); + assert!( + override_pos < stamp, + "completed receiver selection must precede finalization" + ); + assert_eq!( + calls + .iter() + .filter(|l| l.contains("@js_object_finalize_constfn_static")) + .count(), + 1 + ); + for line in calls.iter().filter(|l| l.contains("@js_object_alloc")) { + assert!( + !line.contains(&format!("i32 {final_id}")), + "allocation used final shape: {line}" + ); + } + // Class facts are minted by the cached defining object after registration, + // while startup seed sidecars remain reserved for literal prototypes. + assert!(super::super::static_shape_ids::take_module_static_seeds() + .iter() + .all(|(_, shape)| shape.proto == BirthProto::Literal)); + let warm = String::from_utf8(crate::compile_module(&m, options.clone()).unwrap()).unwrap(); + assert_eq!(ir, warm); + options.output_type = "dylib".into(); + let unloadable = String::from_utf8(crate::compile_module(&m, options).unwrap()).unwrap(); + assert!( + !unloadable.contains("call i32 @js_object_final_shape_id_for_class_keys_static_constfn") + ); + assert!(!unloadable.contains("call i64 @js_object_finalize_constfn_static")); +} + +#[test] +fn class_replacement_returns_never_produce_a_final_record() { + let _lock = ENV.lock().unwrap_or_else(|e| e.into_inner()); + let _pin = Pin(std::env::var_os("PERRY_CONSTFN_SHAPE")); + std::env::set_var("PERRY_CONSTFN_SHAPE", "1"); + let mut class = user_class("Replacement", 7, 1); + class.constructor = Some(constructor(vec![Stmt::Return(Some(Expr::Object( + Vec::new(), + )))])); + let mut m = Module::new("cf_replacement"); + m.init.push(Stmt::Expr(Expr::Object(vec![( + "unrelated".into(), + closure(2, false), + )]))); + m.classes.push(class); + m.init.push(Stmt::Expr(Expr::New { + class_name: "Replacement".into(), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + })); + let births = crate::module_birth_shapes(&m, opts("executable")).unwrap(); + assert!(births + .iter() + .all(|b| b.shape.constfn.is_empty() || b.shape.proto == BirthProto::Literal)); + assert!( + births.iter().any(|b| !b.shape.constfn.is_empty()), + "unrelated final ids keep the finalizer supplier active" + ); + let mut options = opts("executable"); + options.static_shape_ids = + super::super::static_shape_ids::assign_static_shape_ids(births.iter().map(|b| &b.shape)) + .into_iter() + .collect(); + let ir = String::from_utf8(crate::compile_module(&m, options).unwrap()).unwrap(); + assert_eq!( + ir.matches("call i64 @js_object_finalize_constfn_static") + .count(), + 1, + "only the unrelated literal is finalized; the class allocation stays ordinary" + ); +} + +#[test] +fn default_derived_class_finalizes_inherited_and_own_closure_fields() { + let _lock = ENV.lock().unwrap_or_else(|e| e.into_inner()); + let _pin = Pin(std::env::var_os("PERRY_CONSTFN_SHAPE")); + std::env::set_var("PERRY_CONSTFN_SHAPE", "1"); + let base = user_class("Base", 7, 1); + let mut child = user_class("Child", 8, 2); + child.extends = Some(base.id); + child.extends_name = Some(base.name.clone()); + let mut m = Module::new("cf_user_inherit"); + m.classes.extend([base, child]); + m.init.push(Stmt::Expr(Expr::New { + class_name: "Child".into(), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + })); + let births = crate::module_birth_shapes(&m, opts("executable")).unwrap(); + assert_eq!( + births + .iter() + .filter(|b| !b.shape.constfn.is_empty()) + .count(), + 2 + ); + let child_final = &births + .iter() + .find(|b| !b.shape.constfn.is_empty() && b.shape.key_count == 2) + .unwrap() + .shape; + assert_eq!(child_final.keys, b"m7\0m8\0"); + assert_eq!(child_final.rep, 15); + let assigned = + super::super::static_shape_ids::assign_static_shape_ids(births.iter().map(|b| &b.shape)); + let id = assigned[child_final]; + let allocation_ids = + super::super::static_shape_ids::ProgramClassShapeIds::from_births(&births, &assigned); + assert!( + allocation_ids + .0 + .values() + .all(|b| b.shape.constfn.is_empty()), + "general final records cannot enter the class allocation supplier" + ); + let mut options = opts("executable"); + options.static_shape_ids = assigned.into_iter().collect(); + let ir = String::from_utf8(crate::compile_module(&m, options).unwrap()).unwrap(); + let finalizers = ir + .lines() + .filter(|l| l.contains("call i64 @js_object_finalize_constfn_static")) + .collect::>(); + assert_eq!(finalizers.len(), 1); + assert!(finalizers[0].contains(&format!("i32 {id},")) && finalizers[0].contains("i64 15,")); + assert_eq!( + ir.matches("call i32 @js_object_final_shape_id_for_class_keys_static_constfn") + .count(), + 2 + ); +} diff --git a/crates/perry-codegen/src/codegen/static_shape_ids.rs b/crates/perry-codegen/src/codegen/static_shape_ids.rs index 32229c9608..9cadedaf61 100644 --- a/crates/perry-codegen/src/codegen/static_shape_ids.rs +++ b/crates/perry-codegen/src/codegen/static_shape_ids.rs @@ -44,6 +44,15 @@ pub struct TypedMasks { pub pointer_words: Vec, } +/// One compile-time ConstFn body fact. `symbol` is the defining body's +/// stable LLVM info symbol (without `@`), never an ASLR address. The linker +/// resolves it to the one `JsFunctionInfo` for that body in every agent. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ConstFnBirth { + pub slot: u8, + pub symbol: String, +} + /// The content of one compiler-visible birth shape. #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct BirthShape { @@ -60,6 +69,9 @@ pub struct BirthShape { /// an importer's all-`Any` stub of the same keys are two contents, and /// the stub never adopts the definer's id. pub rep: u64, + /// Sorted, unique body symbols for SPECIAL lanes. The current class-birth + /// collector leaves this empty; post-construction producers populate it. + pub constfn: Vec, } impl BirthShape { @@ -75,8 +87,15 @@ impl BirthShape { /// The facts the runtime mints for this content, without the masks: a /// typed layout and a structural mint of the same class share them. The /// rep is a runtime fact, so it is part of them. - pub(crate) fn structure(&self) -> (&[u8], u32, u32, &BirthProto, u64) { - (&self.keys, self.key_count, self.live, &self.proto, self.rep) + pub(crate) fn structure(&self) -> (&[u8], u32, u32, &BirthProto, u64, &[ConstFnBirth]) { + ( + &self.keys, + self.key_count, + self.live, + &self.proto, + self.rep, + &self.constfn, + ) } /// A stable 64-bit FNV-1a over the content (never `RandomState`: the id @@ -115,6 +134,17 @@ impl BirthShape { eat(&[3]); eat(&self.rep.to_le_bytes()); } + // Preserve every old content hash when there is no ConstFn fact. + // Body names, never load addresses, determine static ids. + if !self.constfn.is_empty() { + eat(&[4]); + eat(&(self.constfn.len() as u32).to_le_bytes()); + for entry in &self.constfn { + eat(&[entry.slot]); + eat(&(entry.symbol.len() as u32).to_le_bytes()); + eat(entry.symbol.as_bytes()); + } + } h } @@ -351,6 +381,7 @@ pub(crate) fn class_birth( }, typed: None, rep: class_birth_reps.get(global_name).copied().unwrap_or(0), + constfn: Vec::new(), }); ClassBirth { class_id, @@ -367,6 +398,10 @@ thread_local! { /// content (the facts the id names: a resolved definer id names the same /// structure). Set by `compile_module` for every module (empty when the /// driver assigned none). + static MODULE_FINAL_IDS: RefCell> = RefCell::new(HashMap::new()); + /// Final records named by this module's finalizers or class-registration + /// mints, including class prototypes that cannot use startup literal seeds. + static MODULE_FINAL_USES: RefCell> = RefCell::new(BTreeMap::new()); static MODULE_STATIC_IDS: RefCell> = RefCell::new(HashMap::new()); /// The seedable static ids this module's GUARDS embedded, with their @@ -410,9 +445,17 @@ pub(crate) fn set_module_static_ids( }) .collect() }; + MODULE_FINAL_IDS.with(|m| { + *m.borrow_mut() = assigned + .iter() + .filter(|(shape, _)| !shape.constfn.is_empty()) + .cloned() + .collect() + }); MODULE_STATIC_IDS.with(|m| *m.borrow_mut() = map); MODULE_PROGRAM_IDS.with(|m| *m.borrow_mut() = program.clone()); MODULE_SEEDS.with(|s| s.borrow_mut().clear()); + MODULE_FINAL_USES.with(|s| s.borrow_mut().clear()); } /// Note that a guard embeds `id` as an immediate: a seedable content joins @@ -425,6 +468,35 @@ fn note_guard_id(id: u32, seed: Option<&BirthShape>) { } } +pub(crate) fn has_static_final_shapes() -> bool { + MODULE_FINAL_IDS.with(|m| !m.borrow().is_empty()) +} + +pub(crate) fn disable_static_final_shapes() { + MODULE_FINAL_IDS.with(|m| m.borrow_mut().clear()); +} + +/// Final ids are requested only after construction, never by allocation guards. +pub(crate) fn static_final_shape_id(shape: &BirthShape) -> Option { + let id = MODULE_FINAL_IDS.with(|m| m.borrow().get(shape).copied())?; + note_guard_id(id, Some(shape)); + MODULE_FINAL_USES.with(|s| { + s.borrow_mut().insert(id, shape.clone()); + }); + Some(id) +} + +/// Only final shapes named by emitted finalizers or class mints need body references. +pub(crate) fn module_final_seeds() -> Vec<(BirthShape, u32)> { + MODULE_FINAL_USES.with(|s| { + s.borrow() + .iter() + .filter(|(_, shape)| !shape.constfn.is_empty()) + .map(|(id, shape)| (shape.clone(), *id)) + .collect() + }) +} + /// Drain the seed set of the module just compiled on this thread: every /// seedable static id its guards embedded, with its content. The driver /// persists it beside the module's cached object, so a cache hit replays the @@ -437,22 +509,41 @@ pub fn take_module_static_seeds() -> Vec<(u32, BirthShape)> { /// part of the object-cache key: an entry written in another format is a /// miss, never a line this decoder reads as other facts (a pinned /// `PERRY_OBJECT_CACHE_BUILD_ID` keeps the build id across compilers). -pub const STATIC_SEED_FORMAT: &str = "2"; +pub const STATIC_SEED_FORMAT: &str = "3"; -/// One seed as a line of the object cache's seed sidecar: -/// ` `, -/// the rep as `0x`-prefixed hex. Every field the seed mints from is in the -/// line: a warm link seeds exactly the facts the cold one did. +/// One seed as a line of the object cache's sidecar: +/// ` `. +/// `body_entries` is `-` or comma-separated `@` pairs. +/// ConstFn entries describe opt-in post-construction final shapes. Warm cache +/// replay preserves the same body references as a cold executable link. pub fn encode_static_seed(id: u32, shape: &BirthShape) -> String { let hex: String = shape.keys.iter().map(|b| format!("{b:02x}")).collect(); + let bodies = if shape.constfn.is_empty() { + "-".to_string() + } else { + shape + .constfn + .iter() + .map(|entry| { + let symbol: String = entry + .symbol + .as_bytes() + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + format!("{}@{symbol}", entry.slot) + }) + .collect::>() + .join(",") + }; format!( - "{id} {} {} {hex} {:#x}", + "{id} {} {} {hex} {:#x} {bodies}", shape.key_count, shape.live, shape.rep ) } -/// The inverse of [`encode_static_seed`]; `None` for a malformed line -/// (including a line of another format, which lacks the rep field). +/// Decode the exact current sidecar format. A missing body field, malformed +/// symbol, unsorted/duplicate slot, or SPECIAL/metadata mismatch is a miss. pub fn decode_static_seed(line: &str) -> Option<(u32, BirthShape)> { let mut it = line.split_ascii_whitespace(); let id = it.next()?.parse().ok()?; @@ -460,6 +551,7 @@ pub fn decode_static_seed(line: &str) -> Option<(u32, BirthShape)> { let live = it.next()?.parse().ok()?; let hex = it.next()?; let rep = u64::from_str_radix(it.next()?.strip_prefix("0x")?, 16).ok()?; + let bodies = it.next()?; if it.next().is_some() || hex.is_empty() || hex.len() % 2 != 0 { return None; } @@ -467,6 +559,52 @@ pub fn decode_static_seed(line: &str) -> Option<(u32, BirthShape)> { .step_by(2) .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).ok()) .collect::>>()?; + let constfn = if bodies == "-" { + Vec::new() + } else { + let mut entries = Vec::new(); + for text in bodies.split(',') { + let (slot, encoded) = text.split_once('@')?; + let slot: u8 = slot.parse().ok()?; + if slot >= 32 || encoded.is_empty() || encoded.len() % 2 != 0 { + return None; + } + if entries + .last() + .is_some_and(|entry: &ConstFnBirth| entry.slot >= slot) + { + return None; + } + let bytes = (0..encoded.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&encoded[i..i + 2], 16).ok()) + .collect::>>()?; + let symbol = String::from_utf8(bytes).ok()?; + if !symbol + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_.$".contains(&b)) + { + return None; + } + if (rep >> (u32::from(slot) * 2)) & 3 != 3 { + return None; + } + entries.push(ConstFnBirth { slot, symbol }); + } + entries + }; + let mut special = 0u32; + for slot in 0..32 { + if (rep >> (slot * 2)) & 3 == 3 { + special |= 1 << slot; + } + } + let covered = constfn + .iter() + .fold(0u32, |mask, entry| mask | (1 << entry.slot)); + if special != covered { + return None; + } Some(( id, BirthShape { @@ -476,6 +614,7 @@ pub fn decode_static_seed(line: &str) -> Option<(u32, BirthShape)> { proto: BirthProto::Literal, typed: None, rep, + constfn, }, )) } @@ -487,11 +626,89 @@ pub(crate) fn static_shape_id_for_keys_global(keys_global: &str) -> Option MODULE_STATIC_IDS.with(|m| { let m = m.borrow(); let (id, shape) = m.get(keys_global)?; + if !shape.constfn.is_empty() { + return None; + } note_guard_id(*id, Some(shape)); Some(*id) }) } +/// A contained receiver proof proves offsets, not a function body's invariant. +/// An inherited method can receive a subclass layout: match the key at this +/// slot across completed contents instead of treating allocation class as the +/// only possible receiver. Such boxed stores must use the checked slot funnel. +pub(crate) fn slot_may_be_constfn(keys_global: &str, slot: u32) -> bool { + let birth = MODULE_STATIC_IDS.with(|m| m.borrow().get(keys_global).map(|(_, s)| s.clone())); + let Some(birth) = birth else { + return false; + }; + let name = birth.keys.split(|&b| b == 0).nth(slot as usize); + MODULE_FINAL_IDS.with(|m| { + m.borrow().keys().any(|s| { + s.constfn.iter().any(|i| i.slot as u32 == slot) + && s.keys.split(|&b| b == 0).nth(slot as usize) == name + }) + }) +} + +/// Guard-only compatible completed identities. Allocation suppliers continue +/// returning the ordinary birth id. Match all structural facts and preserve +/// every base representation; a written SPECIAL slot cannot use a raw store. +pub(crate) fn compatible_final_shape_ids(expected: &str, written_slots: &[u32]) -> Vec { + let Ok(expected) = expected.parse::() else { + return Vec::new(); + }; + let birth = MODULE_STATIC_IDS + .with(|m| { + m.borrow() + .values() + .find(|(id, _)| *id == expected) + .map(|(_, s)| s.clone()) + }) + .or_else(|| { + MODULE_PROGRAM_IDS.with(|m| { + m.borrow() + .0 + .values() + .find(|d| d.id == expected) + .map(|d| d.shape.clone()) + }) + }); + let Some(birth) = birth else { + return Vec::new(); + }; + let candidates: Vec<(BirthShape, u32)> = MODULE_FINAL_IDS.with(|m| { + m.borrow() + .iter() + .filter_map(|(shape, &id)| { + let ordinary_rep = shape + .constfn + .iter() + .fold(shape.rep, |rep, entry| rep & !(3u64 << (2 * entry.slot))); + (shape.keys == birth.keys + && shape.key_count == birth.key_count + && shape.live == birth.live + && shape.proto == birth.proto + && ordinary_rep == birth.rep + && !shape + .constfn + .iter() + .any(|i| written_slots.contains(&(i.slot as u32)))) + .then(|| (shape.clone(), id)) + }) + .collect() + }); + let mut ids = Vec::new(); + for (shape, id) in candidates { + note_guard_id(id, Some(&shape)); + ids.push(id); + } + ids.sort_unstable(); + ids.dedup(); + ids +} + /// The static supplier of a loop region (DESIGN §4.1): the static id of /// `keys_global` and the inline slot of each of `keys` in the birth shape /// that id names, when every key is an inline data slot the region word can @@ -504,16 +721,20 @@ pub(crate) fn static_shape_id_for_keys_global(keys_global: &str) -> Option /// inline key of the birth shape, or when a key in `boxed_mask` (a bare /// store of a value not proven a canonical double) sits on a non-`Any` lane /// of the birth rep: the runtime's pack refuses that word too (charter step -/// 5). A returned id is a guard immediate: it joins the module's seed set -/// like any other. +/// 5). The third result is R in region-key order: only identity F64 lanes +/// of this exact birth ShapeId set a bit. A returned id is a guard immediate: +/// it joins the module's seed set like any other. pub(crate) fn static_region_slots( keys_global: &str, keys: &[String], boxed_mask: u32, -) -> Option<(u32, Vec)> { +) -> Option<(u32, Vec, u32)> { MODULE_STATIC_IDS.with(|m| { let m = m.borrow(); let (id, shape) = m.get(keys_global)?; + if !shape.constfn.is_empty() { + return None; + } let names: Vec<&[u8]> = shape .keys .strip_suffix(&[0]) @@ -538,15 +759,32 @@ pub(crate) fn static_region_slots( { return None; } + let r_mask = slots.iter().enumerate().fold(0u32, |mask, (i, &slot)| { + if (shape.rep >> (2 * slot)) & 0b11 == 0b01 { + mask | (1 << i) + } else { + mask + } + }); note_guard_id(*id, Some(shape)); - Some((*id, slots)) + Some((*id, slots, r_mask)) }) } -/// The static id this module's mint of `keys_global` requests (the same id -/// its guards embed; a mint alone does not need a seed). +/// The static id this module's mint of `keys_global` requests. A literal's +/// key-cache builder already mints its plain layout before the class mint, so +/// it needs a startup seed even when no guard embeds this id. Declared-class +/// prototypes differ from the plain key-cache layout and do not need a seed. pub(crate) fn requested_shape_id_for_keys_global(keys_global: &str) -> Option { - MODULE_STATIC_IDS.with(|m| m.borrow().get(keys_global).map(|(id, _)| *id)) + MODULE_STATIC_IDS.with(|m| { + m.borrow() + .get(keys_global) + .filter(|(_, shape)| shape.constfn.is_empty()) + .map(|(id, shape)| { + note_guard_id(*id, Some(shape)); + *id + }) + }) } /// The static id behind ANOTHER module's shape-id global `shape_id_global` @@ -564,6 +802,9 @@ pub(crate) fn static_shape_id_for_foreign_global( { return None; } + if !d.shape.constfn.is_empty() { + return None; + } note_guard_id(d.id, Some(&d.shape)); Some(d.id) }) diff --git a/crates/perry-codegen/src/codegen/static_shape_ids_tests.rs b/crates/perry-codegen/src/codegen/static_shape_ids_tests.rs index ca396503f7..b92a055675 100644 --- a/crates/perry-codegen/src/codegen/static_shape_ids_tests.rs +++ b/crates/perry-codegen/src/codegen/static_shape_ids_tests.rs @@ -8,6 +8,7 @@ fn class(keys: &str, count: u32, cid: u32) -> BirthShape { proto: BirthProto::Class(cid), typed: None, rep: 0, + constfn: Vec::new(), } } @@ -277,6 +278,61 @@ fn an_f64_birth_rep_is_content_and_a_stub_never_adopts_it() { assert!(lit.is_seedable()); } +#[test] +fn constfn_body_symbols_are_seedable_final_static_content() { + let body = |symbol: &str| BirthShape { + proto: BirthProto::Literal, + rep: 0b11, + constfn: vec![ConstFnBirth { + slot: 0, + symbol: symbol.to_string(), + }], + ..class("method\0", 1, 0) + }; + let first = body("perry_closure_m__first$info"); + let second = body("perry_closure_m__second$info"); + assert_ne!(first.content_hash(), second.content_hash()); + assert_ne!(first.structure(), second.structure()); + assert!( + first.is_seedable(), + "final literal shapes have a body-aware seed" + ); + let line = encode_static_seed(0x1000_0099, &first); + assert_eq!(decode_static_seed(&line), Some((0x1000_0099, first))); + assert_eq!(decode_static_seed("268435609 1 1 6d6574686f6400 0x3"), None); + assert_eq!( + decode_static_seed("268435609 1 1 6d6574686f6400 0x0 0@61"), + None + ); + assert_eq!( + decode_static_seed("268435609 1 1 6d6574686f6400 0x3 0@61,0@62"), + None + ); +} + +#[test] +fn constfn_birth_cannot_publish_a_static_guard_or_seed() { + let shape = BirthShape { + proto: BirthProto::Literal, + rep: 0b11, + constfn: vec![ConstFnBirth { + slot: 0, + symbol: "perry_closure_m__method$info".to_string(), + }], + ..class("method\0", 1, 0) + }; + let key = "perry_class_keys_m__method"; + MODULE_STATIC_IDS.with(|m| { + m.borrow_mut().insert(key.to_string(), (0x1000_0099, shape)); + }); + MODULE_SEEDS.with(|s| s.borrow_mut().clear()); + assert_eq!(static_shape_id_for_keys_global(key), None); + assert_eq!(requested_shape_id_for_keys_global(key), None); + assert_eq!(static_region_slots(key, &["method".into()], 0), None); + assert!(take_module_static_seeds().is_empty()); + MODULE_STATIC_IDS.with(|m| m.borrow_mut().clear()); +} + /// The seed sidecar carries the birth rep: a warm link replays exactly the /// facts a cold one seeded. A line without the rep (another format) is /// malformed, never an all-`Any` seed of the same keys. @@ -368,3 +424,139 @@ fn class_birth_names_anon_shapes_as_literals_and_skips_class_zero() { assert_eq!(o.class_id, 0); assert!(o.shape.is_none()); } + +#[test] +fn compatible_final_guards_preserve_allocation_identity_and_refuse_special_writes() { + let ordinary = BirthShape { + rep: 1 << 2, + ..class("m\0x\0", 2, 71) + }; + let completed = BirthShape { + rep: 3 | (1 << 2), + constfn: vec![ConstFnBirth { + slot: 0, + symbol: "guard_body$info".into(), + }], + ..ordinary.clone() + }; + let wrong_number = BirthShape { + rep: 3, + ..completed.clone() + }; + let key = "perry_class_keys_guard__C".to_string(); + MODULE_STATIC_IDS.with(|m| { + *m.borrow_mut() = [(key.clone(), (SHAPE_ID_BASE + 4, ordinary.clone()))] + .into_iter() + .collect(); + }); + MODULE_FINAL_IDS.with(|m| { + *m.borrow_mut() = [ + (completed, SHAPE_ID_BASE + 5), + (wrong_number, SHAPE_ID_BASE + 6), + ] + .into_iter() + .collect(); + }); + let (region_id, slots, r_mask) = static_region_slots(&key, &["x".into(), "m".into()], 0) + .expect("ordinary birth supplies exact numeric slots"); + assert_eq!(region_id, SHAPE_ID_BASE + 4); + assert_eq!(slots, vec![1, 0]); + assert_eq!(r_mask, 1, "the method lane never supplies a Number fact"); + assert!(static_region_slots(&key, &["x".into()], 1).is_none()); + assert_eq!( + requested_shape_id_for_keys_global(&key), + Some(SHAPE_ID_BASE + 4), + "allocation supplier cannot request final id" + ); + assert_eq!( + compatible_final_shape_ids(&(SHAPE_ID_BASE + 4).to_string(), &[]), + vec![SHAPE_ID_BASE + 5] + ); + assert_eq!( + compatible_final_shape_ids(&(SHAPE_ID_BASE + 4).to_string(), &[1]), + vec![SHAPE_ID_BASE + 5], + "numeric stores preserve completed facts" + ); + assert!( + compatible_final_shape_ids(&(SHAPE_ID_BASE + 4).to_string(), &[0]).is_empty(), + "CF stores require checked deprecation before writing" + ); + assert!(slot_may_be_constfn(&key, 0)); + assert!(!slot_may_be_constfn(&key, 1)); + MODULE_STATIC_IDS.with(|m| m.borrow_mut().clear()); + MODULE_FINAL_IDS.with(|m| m.borrow_mut().clear()); + MODULE_SEEDS.with(|m| m.borrow_mut().clear()); +} + +#[test] +fn region_static_r_is_the_exact_birth_shapes_f64_key_mask() { + let shape = BirthShape { + rep: 0b01 | (0b01 << 4), + ..class("ra\0rb\0rc\0", 3, 0x517) + }; + let global = "p7_region_keys".to_string(); + MODULE_STATIC_IDS.with(|m| { + m.borrow_mut() + .insert(global.clone(), (SHAPE_ID_BASE + 917, shape)); + }); + let keys = vec!["rc".to_string(), "rb".to_string(), "ra".to_string()]; + let (_, slots, r) = static_region_slots(&global, &keys, 0).expect("birth keys are inline"); + assert_eq!(slots, vec![2, 1, 0]); + assert_eq!(r, 0b101, "R follows key order, not birth slot order"); + assert!( + static_region_slots(&global, &keys, 0b001).is_none(), + "a boxed store to an F64 birth lane is refused" + ); + MODULE_STATIC_IDS.with(|m| { + m.borrow_mut().remove(&global); + }); + take_module_static_seeds(); +} + +#[test] +fn literal_key_cache_mints_require_a_seed_even_without_a_guard() { + let literal = BirthShape { + proto: BirthProto::Literal, + ..class("x\0m\0", 2, 7) + }; + let declared = class("x\0m\0", 2, 8); + let assigned = assign_static_shape_ids([&literal, &declared]); + let entries = vec![ + ( + "perry_class_keys_probe____AnonShape_a".into(), + "x\0m\0".into(), + 2, + vec![], + vec![], + ), + ( + "perry_class_keys_probe__Declared".into(), + "x\0m\0".into(), + 2, + vec![], + vec![], + ), + ]; + let classes = HashMap::from([("__AnonShape_a".into(), 7), ("Declared".into(), 8)]); + set_module_static_ids( + "probe", + &entries, + &HashMap::new(), + &HashMap::new(), + &classes, + &assigned.clone().into_iter().collect::>(), + &ProgramClassShapeIds::default(), + ); + assert_eq!( + requested_shape_id_for_keys_global(&entries[0].0), + Some(assigned[&literal]) + ); + assert_eq!( + requested_shape_id_for_keys_global(&entries[1].0), + Some(assigned[&declared]) + ); + assert_eq!( + take_module_static_seeds(), + vec![(assigned[&literal], literal)] + ); +} diff --git a/crates/perry-codegen/src/codegen/string_pool.rs b/crates/perry-codegen/src/codegen/string_pool.rs index e627e8a9c5..ab51e31a22 100644 --- a/crates/perry-codegen/src/codegen/string_pool.rs +++ b/crates/perry-codegen/src/codegen/string_pool.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use crate::block::LlBlock; use crate::module::LlModule; use crate::strings::StringPool; -use crate::types::{DOUBLE, I32, I64, PTR, VOID}; +use crate::types::{DOUBLE, I32, I64, I8, PTR, VOID}; use super::ctor_arity::constructor_layout_params; use super::helpers::{sanitize_member, scoped_static_method_name}; @@ -25,13 +25,21 @@ use super::spec_function_length; /// runtime registry; no SSA value flows between ops — so splitting at op /// boundaries is safe and order-preserving (chunks run in sequence, ops in order /// within a chunk). +#[derive(Default)] +struct InitChunks { + ops: usize, + current: Option, + names: Vec, +} + struct InitChunker<'a> { llmod: &'a mut LlModule, base_name: String, ops_per_chunk: usize, - ops_in_current: usize, - cur_idx: usize, - chunk_names: Vec, + // Literal infrastructure may run before cyclic dependencies. Declared + // class metadata retains its existing module-body initialization boundary. + literals: bool, + phases: [InitChunks; 2], } impl<'a> InitChunker<'a> { @@ -40,65 +48,64 @@ impl<'a> InitChunker<'a> { llmod, base_name, ops_per_chunk: ops_per_chunk.max(1), - // Force a fresh chunk on the first op. - ops_in_current: usize::MAX, - cur_idx: 0, - chunk_names: Vec::new(), + literals: true, + phases: Default::default(), } } - /// Start a fresh chunk function if the current one is full. Call ONCE at the - /// top of each loop iteration (one independent init op), before - /// [`current_block`]. Closes the previous chunk with `ret void`. - /// The module, for a definition an init op registers (the chunk being - /// filled is addressed by index, so appending functions is fine). fn module(&mut self) -> &mut LlModule { self.llmod } fn roll_if_full(&mut self) { - if self.ops_in_current >= self.ops_per_chunk { - if !self.chunk_names.is_empty() { + let phase = usize::from(!self.literals); + let state = &mut self.phases[phase]; + if state.current.is_none() || state.ops >= self.ops_per_chunk { + if let Some(current) = state.current { self.llmod - .function_mut(self.cur_idx) + .function_mut(current) .unwrap() .block_mut(0) .unwrap() .ret_void(); } - let name = format!("{}_chunk{}", self.base_name, self.chunk_names.len()); + let name = format!( + "{}_{}_chunk{}", + self.base_name, + if self.literals { "literal" } else { "class" }, + state.names.len() + ); self.llmod .define_function(&name, VOID, vec![]) .create_block("entry"); - self.cur_idx = self.llmod.function_count() - 1; - self.chunk_names.push(name); - self.ops_in_current = 0; + state.current = Some(self.llmod.function_count() - 1); + state.names.push(name); + state.ops = 0; } } - /// The current chunk's entry block, for emitting one op's instructions. - /// Counts as one op (a logical init step may emit several instructions onto - /// it). Always preceded by [`roll_if_full`]. fn current_block(&mut self) -> &mut LlBlock { - self.ops_in_current += 1; + let state = &mut self.phases[usize::from(!self.literals)]; + state.ops += 1; self.llmod - .function_mut(self.cur_idx) + .function_mut(state.current.unwrap()) .unwrap() .block_mut(0) .unwrap() } - /// Close the final chunk and return all chunk function names, in order. - fn finish(self) -> Vec { - if !self.chunk_names.is_empty() { - self.llmod - .function_mut(self.cur_idx) - .unwrap() - .block_mut(0) - .unwrap() - .ret_void(); - } - self.chunk_names + fn finish(self) -> [Vec; 2] { + self.phases.map(|state| { + if let Some(current) = state.current { + self.llmod + .function_mut(current) + .unwrap() + .block_mut(0) + .unwrap() + .ret_void(); + } + state.names + }) } } @@ -120,6 +127,7 @@ pub(super) fn emit_string_pool( llmod: &mut LlModule, strings: &StringPool, module_prefix: &str, + agent_strings_tls: bool, // #9188 follow-up: which registration spelling the name/source loops below // may use. `_static` hands the registry the `@.str.N` constant itself // instead of a slice to copy, which is sound only while this image stays @@ -244,9 +252,13 @@ pub(super) fn emit_string_pool( entry.bytes_global )); } - // #10399: the string pool is populated by each module's init, which - // runs once per thread when the program has a Worker. - llmod.add_internal_module_state_global(&entry.handle_global, DOUBLE, "0.0"); + // Worker module init and perry/thread's explicit string bootstrap each + // populate this slot in the allocating agent's own arena. + if agent_strings_tls { + llmod.add_internal_thread_local_global(&entry.handle_global, DOUBLE, "0.0"); + } else { + llmod.add_internal_global(&entry.handle_global, DOUBLE, "0.0"); + } } // Per-class packed-keys constants (rodata) — referenced by the @@ -447,7 +459,7 @@ pub(super) fn emit_string_pool( .unwrap_or(4000); let mut chunker = InitChunker::new( llmod, - format!("__perry_init_strings_{}", module_prefix), + format!("__perry_agent_strings_{}", module_prefix), ops_per_chunk, ); @@ -496,6 +508,39 @@ pub(super) fn emit_string_pool( blk.call_void("js_gc_register_global_root", &[(I64, &addr_i64)]); } + let [string_chunks, no_class_chunks] = chunker.finish(); + debug_assert!(no_class_chunks.is_empty()); + let agent_strings_name = format!("__perry_prepare_agent_strings_{}", module_prefix); + let ready = format!("__perry_agent_strings_ready_{}", module_prefix); + if agent_strings_tls { + llmod.add_internal_thread_local_global(&ready, I8, "0"); + } else { + llmod.add_internal_global(&ready, I8, "0"); + } + let prepare_strings = llmod.define_function(&agent_strings_name, VOID, vec![]); + prepare_strings.create_block("entry"); + prepare_strings.create_block("prepare"); + prepare_strings.create_block("done"); + let prepare_label = prepare_strings.block_mut(1).unwrap().label.clone(); + let done_label = prepare_strings.block_mut(2).unwrap().label.clone(); + let blk = prepare_strings.block_mut(0).unwrap(); + let prepared = blk.load(I8, &format!("@{}", ready)); + let prepared = blk.icmp_ne(I8, &prepared, "0"); + blk.cond_br(&prepared, &done_label, &prepare_label); + let blk = prepare_strings.block_mut(1).unwrap(); + for name in &string_chunks { + blk.call_void(name, &[]); + } + blk.store(I8, "1", &format!("@{}", ready)); + blk.br(&done_label); + prepare_strings.block_mut(2).unwrap().ret_void(); + + let mut chunker = InitChunker::new( + llmod, + format!("__perry_init_strings_{}", module_prefix), + ops_per_chunk, + ); + // An image that can be UNLOADED cannot lend its rodata to a registry that // never drops entries. Perry compiles TypeScript to a dylib plugin as well // as an executable, and `perry_plugin_unload` ends in `dlclose` — after @@ -586,6 +631,7 @@ pub(super) fn emit_string_pool( // the user wrote. This is a distinct edge from the parent one on purpose: // `CLASS_REGISTRY`'s chain also resolves `super()`, static-method lookup // and vtable dispatch, so it must keep pointing at the real base. + chunker.literals = false; let mut origin_pairs: Vec<(u32, u32)> = Vec::new(); for (name, &cid) in class_ids.iter() { let Some(class) = classes.get(name) else { @@ -626,6 +672,7 @@ pub(super) fn emit_string_pool( } anon_shape_ids.sort_unstable(); anon_shape_ids.dedup(); + chunker.literals = true; for cid in anon_shape_ids { chunker.roll_if_full(); let blk = chunker.current_block(); @@ -641,16 +688,14 @@ pub(super) fn emit_string_pool( // module init; every `new ClassName()` call from then on does a // single global load + inline allocator call (no SHAPE_CACHE // lookup, no js_build_class_keys_array overhead). + let literal_classes: std::collections::HashSet<_> = classes + .values() + .filter(|class| class.name.starts_with("__AnonShape_")) + .filter_map(|class| class_ids.get(&class.name).copied()) + .collect(); for (idx, (global_name, packed, field_count, _raw_mask_words, _pointer_mask_words)) in class_keys_init_data.iter().enumerate() { - chunker.roll_if_full(); - let blk = chunker.current_block(); - // The birth's class id, typed-ness and live bound come from the ONE - // derivation the driver's pre-pass also uses to name this birth's - // content (`static_shape_ids::class_birth`); `requested` is that - // content's static id — the definer's for a structural stub of the - // definer's facts — (0 = none). let birth = super::static_shape_ids::class_birth( module_prefix, &class_keys_init_data[idx], @@ -658,6 +703,16 @@ pub(super) fn emit_string_pool( class_birth_reps, class_ids, ); + // Only synthetic ordinary-object layouts are safe before dependency + // bodies. User-class keys, prototypes and methods stay in the late phase. + chunker.literals = literal_classes.contains(&birth.class_id); + chunker.roll_if_full(); + let blk = chunker.current_block(); + // The birth's class id, typed-ness and live bound come from the ONE + // derivation the driver's pre-pass also uses to name this birth's + // content (`static_shape_ids::class_birth`); `requested` is that + // content's static id — the definer's for a structural stub of the + // definer's facts — (0 = none). let class_id = birth.class_id; let requested = super::static_shape_ids::requested_shape_id_for_keys_global(global_name) .unwrap_or(0) @@ -809,6 +864,7 @@ pub(super) fn emit_string_pool( // where `Square extends Rectangle extends Shape`) terminate // prematurely. We emit one call per inheriting class, sorted by // class id for deterministic ordering. + chunker.literals = false; let mut parent_pairs: Vec<(u32, u32)> = Vec::new(); for (name, &cid) in class_ids.iter() { if let Some(class) = classes.get(name) { @@ -1574,7 +1630,74 @@ pub(super) fn emit_string_pool( ); } - let chunk_names = chunker.finish(); + // Final class records follow all class/prototype registrations. They + // coexist with the ordinary allocation ids and never feed header images. + if super::static_constfn::has_final_shapes() { + let defined_classes: HashMap<_, _> = module_classes + .iter() + .filter_map(|class| class_ids.get(&class.name).map(|cid| (*cid, class))) + .collect(); + for entry in class_keys_init_data { + let birth = super::static_shape_ids::class_birth( + module_prefix, + entry, + class_header_image_inits, + class_birth_reps, + class_ids, + ); + let Some(ordinary) = birth.shape else { + continue; + }; + let Some(class) = defined_classes.get(&birth.class_id).copied() else { + continue; + }; + let Ok(shape) = super::static_constfn_class::class_final( + module_prefix, + class, + classes, + ordinary.rep, + birth.class_id, + ) else { + continue; + }; + if shape.keys != ordinary.keys + || shape.live != ordinary.live + || shape.proto != ordinary.proto + { + continue; + } + let Some(id) = super::static_shape_ids::static_final_shape_id(&shape) else { + continue; + }; + chunker.roll_if_full(); + let blk = chunker.current_block(); + // Registration calls above can collect; load the canonical keys + // afresh from their registered root immediately before the mint. + let keys = blk.load(I64, &format!("@{}", entry.0)); + blk.call( + I32, + "js_object_final_shape_id_for_class_keys_static_constfn", + &[ + (I64, &keys), + (I32, &shape.key_count.to_string()), + (I32, &shape.live.to_string()), + (I32, &birth.class_id.to_string()), + (I32, &id.to_string()), + (I64, &shape.rep.to_string()), + ( + PTR, + &format!( + "@{}", + super::static_constfn::entries_symbol(module_prefix, id) + ), + ), + (I32, &shape.constfn.len().to_string()), + ], + ); + } + } + + let [literal_chunks, class_chunks] = chunker.finish(); record_fn_info_facts( llmod, module_prefix, @@ -1598,11 +1721,42 @@ pub(super) fn emit_string_pool( user_fn_wrapper_strict, }, ); + // A cyclic importer can call a hoisted factory before this module body. + // Prepare literal strings/function info/ordinary-object layouts first, + // once per arena. Workers can enter __init_body directly, so the body + // also reaches this guarded preparation without allocating a second pool. + let prepare_name = format!("__perry_prepare_literals_{}", module_prefix); + let prepared = format!("__perry_literals_ready_{}", module_prefix); + if super::program_has_worker() { + llmod.add_internal_thread_local_global(&prepared, I8, "0"); + } else { + llmod.add_internal_global(&prepared, I8, "0"); + } + let prepare_fn = llmod.define_function(&prepare_name, VOID, vec![]); + prepare_fn.create_block("entry"); + prepare_fn.create_block("prepare"); + prepare_fn.create_block("done"); + let prepare_label = prepare_fn.block_mut(1).unwrap().label.clone(); + let done_label = prepare_fn.block_mut(2).unwrap().label.clone(); + let blk = prepare_fn.block_mut(0).unwrap(); + let ready = blk.load(I8, &format!("@{}", prepared)); + let ready = blk.icmp_ne(I8, &ready, "0"); + blk.cond_br(&ready, &done_label, &prepare_label); + let blk = prepare_fn.block_mut(1).unwrap(); + blk.call_void(&agent_strings_name, &[]); + for cname in &literal_chunks { + blk.call_void(cname, &[]); + } + blk.store(I8, "1", &format!("@{}", prepared)); + blk.br(&done_label); + prepare_fn.block_mut(2).unwrap().ret_void(); + let init_name = format!("__perry_init_strings_{}", module_prefix); let init_fn = llmod.define_function(&init_name, VOID, vec![]); - let _ = init_fn.create_block("entry"); + init_fn.create_block("entry"); let blk = init_fn.block_mut(0).unwrap(); - for cname in &chunk_names { + blk.call_void(&prepare_name, &[]); + for cname in &class_chunks { blk.call_void(cname, &[]); } blk.ret_void(); diff --git a/crates/perry-codegen/src/collectors/mod.rs b/crates/perry-codegen/src/collectors/mod.rs index 1a3ea444fa..0d2ce741dc 100644 --- a/crates/perry-codegen/src/collectors/mod.rs +++ b/crates/perry-codegen/src/collectors/mod.rs @@ -117,6 +117,10 @@ pub(crate) use proven_this::{ tower_route_profitable as pshape_tower_route_profitable, }; pub(crate) use ptr_numarray::{NumArrayDensity, NumArrayLocal}; +pub(crate) use ptr_shape::{ + collect_numeric_by_construction_locals_in_region, region_number_flow_reads, + region_store_value_is_number, RegionNumberAssumptions, +}; pub(crate) use ptr_shape::{ptr_shape_locals_enabled, PtrShapeLocal}; pub(crate) use ptr_shape_callbacks::collect_array_callback_shapes; pub(crate) use ptr_shape_returns::collect_exported_return_shapes; diff --git a/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs b/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs index 8c3a14671d..370b7885c3 100644 --- a/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs +++ b/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs @@ -43,6 +43,7 @@ fn ir_opts(is_entry: bool) -> CompileOptions { target: None, is_entry_module: is_entry, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/collectors/ptr_shape.rs b/crates/perry-codegen/src/collectors/ptr_shape.rs index dda88b6e52..be9256f82b 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape.rs @@ -1968,6 +1968,10 @@ mod numeric; use numeric::{ collect_numeric_by_construction_locals, prove_group_numeric_fields, prove_numeric_fields, }; +pub(crate) use numeric::{ + collect_numeric_by_construction_locals_in_region, region_number_flow_reads, + region_store_value_is_number, RegionNumberAssumptions, +}; // #8105: the same locals fixpoint, consumed outside the `Ptr` pass by // `collectors/number_by_construction.rs`. pub(in crate::collectors) use numeric::collect_numeric_by_construction_locals as collect_numeric_by_construction_locals_for_type_analysis; diff --git a/crates/perry-codegen/src/collectors/ptr_shape_numeric.rs b/crates/perry-codegen/src/collectors/ptr_shape_numeric.rs index 1e3ef0550f..7567e98a7e 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape_numeric.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape_numeric.rs @@ -413,6 +413,13 @@ pub(super) fn prove_group_numeric_fields<'a>( // ── #7770: numeric-by-construction locals ────────────────────────────────── +/// Extra leaves and entry candidates for one guarded region's F clone. +pub(crate) struct RegionNumberAssumptions<'a> { + pub(crate) entry_candidates: &'a HashSet, + pub(crate) static_numbers: &'a HashSet, + pub(crate) f64_reads: &'a HashSet, +} + /// Locals whose every write is number-producing by construction — above all /// the loop counter (`let i = 0` + `i++`) that feeds a provenance /// `new C(i, i + 1)`. @@ -448,6 +455,38 @@ pub(in crate::collectors) fn collect_numeric_by_construction_locals<'a>( // completely `o`'s shape was proven. Empty for every pre-existing caller. shape_members: &HashSet, shape_numeric_fields: &HashSet, +) -> HashSet { + collect_numeric_by_construction_locals_in_region( + stmts, + boxed_vars, + module_globals, + not_bigint_locals, + const_local_inits, + numeric_ta_views, + shape_members, + shape_numeric_fields, + None, + ) +} + +pub(crate) fn collect_numeric_by_construction_locals_in_region<'a>( + stmts: &'a [Stmt], + boxed_vars: &HashSet, + module_globals: &HashMap, + not_bigint_locals: &HashSet, + const_local_inits: &HashMap>, + // #8619: view bindings proven to hold a numeric-kind typed array (spec-ABI + // `TaPtr` params). Empty for the `Ptr` type-analysis caller. + numeric_ta_views: &HashSet, + // #10777: shape-proven receivers visible to THIS walk, and the property + // names numeric on all of them. Both were hardcoded empty here, so + // `expr_numeric_by_construction`'s `PropertyGet` arm — gated on + // `members.contains(id)` — could never fire for a function-scope walk. An + // accumulator written `h = h + o.a` was therefore never admitted, however + // completely `o`'s shape was proven. Empty for every pre-existing caller. + shape_members: &HashSet, + shape_numeric_fields: &HashSet, + region: Option<&RegionNumberAssumptions<'_>>, ) -> HashSet { // ONE write walker for both fixpoints (`collect_not_bigint_locals` and // this one) — see its doc for why sharing is load-bearing. `None` = a @@ -464,6 +503,12 @@ pub(in crate::collectors) fn collect_numeric_by_construction_locals<'a>( // constructors without trusting their erased annotation. let mut stable_local_inits = const_local_inits.clone(); for (&id, local_writes) in &writes { + // An entry-tested loop-carried local can have just one F-body write. + // That write is not a stable initializer and must be judged through + // the running fixed-point assumption (h = h + x). + if region.is_some_and(|r| r.entry_candidates.contains(&id)) { + continue; + } if let [Some(init)] = local_writes.as_slice() { stable_local_inits.entry(id).or_insert(Some(*init)); } @@ -472,17 +517,28 @@ pub(in crate::collectors) fn collect_numeric_by_construction_locals<'a>( let empty_fields: HashSet = shape_numeric_fields.clone(); let mut numeric: HashSet = let_bound .into_iter() + .chain( + region + .into_iter() + .flat_map(|r| r.entry_candidates.iter().copied()), + ) .filter(|id| !boxed_vars.contains(id) && !module_globals.contains_key(id)) .collect(); + if let Some(r) = region { + numeric.extend(r.static_numbers.iter().copied()); + } loop { let mut drop: Vec = Vec::new(); for &id in &numeric { + if region.is_some_and(|r| r.static_numbers.contains(&id)) { + continue; + } let ok = writes .get(&id) .map(|ws| { ws.iter().all(|w| match w { None => false, - Some(e) => expr_numeric_by_construction( + Some(e) => expr_numeric_by_construction_with_region( e, &ParamEnv::None, &empty_members, @@ -492,12 +548,13 @@ pub(in crate::collectors) fn collect_numeric_by_construction_locals<'a>( &numeric, numeric_ta_views, 0, + region.map(|r| r.f64_reads), ), }) }) - // A `let_bound` id always has its `Let` recorded; treat a - // missing entry as unproven rather than as vacuously true. - .unwrap_or(false); + // Only a strictly tested entry candidate may have no local + // write inside F; its incoming value is the guarded leaf. + .unwrap_or_else(|| region.is_some_and(|r| r.entry_candidates.contains(&id))); if !ok { drop.push(id); } @@ -512,6 +569,70 @@ pub(in crate::collectors) fn collect_numeric_by_construction_locals<'a>( numeric } +/// Trace Number-consuming local uses back through the shared exhaustive +/// write inventory to the property reads feeding them. The caller intersects +/// these expression identities with the planner's fresh bare reads, so a +/// read after an E2 call never becomes an R leaf. +pub(crate) fn region_number_flow_reads( + stmts: &[Stmt], + roots: &HashSet, +) -> (HashSet, HashSet, HashSet) { + fn deps(e: &Expr, reads: &mut HashSet, locals: &mut Vec) { + match e { + Expr::PropertyGet { .. } => { + reads.insert(e as *const Expr as usize); + return; + } + Expr::LocalGet(id) => { + locals.push(*id); + return; + } + _ => {} + } + perry_hir::walker::walk_expr_children(e, &mut |child| deps(child, reads, locals)); + } + + let mut writes = HashMap::new(); + let mut bound = HashSet::new(); + super::super::not_bigint_locals::collect_writes(stmts, &mut writes, &mut bound); + let mut reads = HashSet::new(); + let mut seen = HashSet::new(); + let mut pending: Vec = roots.iter().copied().collect(); + while let Some(id) = pending.pop() { + if !seen.insert(id) { + continue; + } + if let Some(ws) = writes.get(&id) { + for value in ws.iter().flatten() { + deps(value, &mut reads, &mut pending); + } + } + } + (reads, seen, bound) +} + +/// Reuse the Number-by-construction expression rule when the region planner +/// decides whether a bare store is compatible with an R-proven F64 lane. +pub(crate) fn region_store_value_is_number( + value: &Expr, + f64_reads: &HashSet, + numeric_locals: &HashSet, + not_bigint_locals: &HashSet, +) -> bool { + expr_numeric_by_construction_with_region( + value, + &ParamEnv::None, + &HashSet::new(), + &HashSet::new(), + not_bigint_locals, + &HashMap::new(), + numeric_locals, + &HashSet::new(), + 0, + Some(f64_reads), + ) +} + // ── The expression-level proof ───────────────────────────────────────────── /// Number-by-construction: the expression's runtime value is a JS Number for @@ -535,13 +656,48 @@ pub(super) fn expr_numeric_by_construction( // pass). numeric_ta_views: &HashSet, depth: usize, +) -> bool { + expr_numeric_by_construction_with_region( + e, + param_env, + members, + numeric_fields, + not_bigint_locals, + const_local_inits, + numeric_locals, + numeric_ta_views, + depth, + None, + ) +} + +#[allow(clippy::too_many_arguments)] +fn expr_numeric_by_construction_with_region( + e: &Expr, + param_env: &ParamEnv<'_>, + members: &HashSet, + numeric_fields: &HashSet, + not_bigint_locals: &HashSet, + const_local_inits: &HashMap>, + numeric_locals: &HashSet, + // #8619: view bindings PROVEN to permanently hold a numeric-kind typed + // array — a spec-ABI `TaPtr` parameter (the entry contract binds the raw + // header of a proven numeric non-view typed array). A read + // `view_id[numeric_index]` is then a Number (in-bounds) or `undefined` + // (OOB) by construction, never a pointer/string, which the Add rule below + // launders into a genuine Number. Empty on every path that is not a + // specialized-entry local proof (the class-field provers, the `Ptr` + // pass). + numeric_ta_views: &HashSet, + depth: usize, + region_f64_reads: Option<&HashSet>, ) -> bool { if depth > 16 { return false; } use perry_hir::BinaryOp; let rec = |x: &Expr| { - expr_numeric_by_construction( + expr_numeric_by_construction_with_region( x, param_env, members, @@ -551,6 +707,7 @@ pub(super) fn expr_numeric_by_construction( numeric_locals, numeric_ta_views, depth + 1, + region_f64_reads, ) }; // A numeric index into one of these compiler-owned constructors can only @@ -628,6 +785,12 @@ pub(super) fn expr_numeric_by_construction( numeric_storage && rec(index) }; match e { + Expr::PropertyGet { .. } + if region_f64_reads + .is_some_and(|reads| reads.contains(&(e as *const Expr as usize))) => + { + true + } Expr::Number(_) | Expr::Integer(_) | Expr::PodLayoutSizeOf { .. } @@ -738,7 +901,7 @@ pub(super) fn expr_numeric_by_construction( return !sites.is_empty() && sites.iter().all(|args| { args.get(pos).map(|a| { - expr_numeric_by_construction( + expr_numeric_by_construction_with_region( a, &ParamEnv::None, members, @@ -748,6 +911,7 @@ pub(super) fn expr_numeric_by_construction( numeric_locals, numeric_ta_views, depth + 1, + region_f64_reads, ) }) == Some(true) }); @@ -762,7 +926,7 @@ pub(super) fn expr_numeric_by_construction( // A single-Let const temp: chase its init (function // scope, so no parameter mapping applies to it). if let Some(Some(init)) = const_local_inits.get(id) { - return expr_numeric_by_construction( + return expr_numeric_by_construction_with_region( init, &ParamEnv::None, members, @@ -772,6 +936,7 @@ pub(super) fn expr_numeric_by_construction( numeric_locals, numeric_ta_views, depth + 1, + region_f64_reads, ); } // #7770: a local every one of whose writes is @@ -819,3 +984,108 @@ pub(super) fn expr_provably_not_bigint(e: &Expr, not_bigint_locals: &HashSet Expr { + Expr::PropertyGet { + object: Box::new(Expr::LocalGet(OBJECT)), + property: "x".to_string(), + byte_offset: 0, + } + } + + fn let_read(id: u32) -> Stmt { + Stmt::Let { + id, + name: format!("n{id}"), + ty: perry_hir::types::Type::Any, + mutable: false, + init: Some(read()), + } + } + + fn add_to_acc(id: u32) -> Stmt { + Stmt::Expr(Expr::LocalSet( + ACC, + Box::new(Expr::Binary { + op: perry_hir::BinaryOp::Add, + left: Box::new(Expr::LocalGet(ACC)), + right: Box::new(Expr::LocalGet(id)), + }), + )) + } + + fn number_set(stmts: &[Stmt], fresh_read: usize) -> HashSet { + let boxed = HashSet::new(); + let globals = HashMap::new(); + let empty_ids = HashSet::new(); + let empty_fields = HashSet::new(); + let inits = HashMap::new(); + let entry = HashSet::from([ACC]); + let reads = HashSet::from([fresh_read]); + let region = RegionNumberAssumptions { + entry_candidates: &entry, + static_numbers: &empty_ids, + f64_reads: &reads, + }; + collect_numeric_by_construction_locals_in_region( + stmts, + &boxed, + &globals, + &empty_ids, + &inits, + &empty_ids, + &empty_ids, + &empty_fields, + Some(®ion), + ) + } + + #[test] + fn an_e2_stale_second_read_drops_the_loop_carried_number_fact() { + let mut stmts = vec![let_read(FRESH), add_to_acc(FRESH)]; + let fresh_ptr = match &stmts[0] { + Stmt::Let { init: Some(e), .. } => e as *const Expr as usize, + _ => unreachable!(), + }; + let first = number_set(&stmts, fresh_ptr); + assert!(first.contains(&FRESH) && first.contains(&ACC)); + + // The intervening call makes the second slot read stale in the + // region planner. Only the first read's exact Expr identity is an + // F64 leaf; the second write must withdraw ACC from N_F. + stmts.push(Stmt::Expr(Expr::Call { + callee: Box::new(Expr::LocalGet(99)), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + })); + stmts.push(let_read(STALE)); + stmts.push(add_to_acc(STALE)); + let fresh_ptr = match &stmts[0] { + Stmt::Let { init: Some(e), .. } => e as *const Expr as usize, + _ => unreachable!(), + }; + let stale_ptr = match &stmts[3] { + Stmt::Let { init: Some(e), .. } => e as *const Expr as usize, + _ => unreachable!(), + }; + let roots = HashSet::from([ACC]); + let (flows, locals, bound) = region_number_flow_reads(&stmts, &roots); + assert!(flows.contains(&fresh_ptr) && flows.contains(&stale_ptr)); + assert!(locals.contains(&ACC) && locals.contains(&FRESH) && locals.contains(&STALE)); + assert!(bound.contains(&FRESH) && bound.contains(&STALE) && !bound.contains(&ACC)); + let after = number_set(&stmts, fresh_ptr); + assert!(after.contains(&FRESH)); + assert!(!after.contains(&STALE)); + assert!(!after.contains(&ACC), "the stale write must drop ACC"); + } +} diff --git a/crates/perry-codegen/src/collectors/receiver_regions.rs b/crates/perry-codegen/src/collectors/receiver_regions.rs index bc119d323e..84e5adb018 100644 --- a/crates/perry-codegen/src/collectors/receiver_regions.rs +++ b/crates/perry-codegen/src/collectors/receiver_regions.rs @@ -3,14 +3,15 @@ //! //! # Why this exists //! -//! Phase 1 found sixteen separate receiver-keyed fact mechanisms on `FnCtx`. +//! Phase 1 found sixteen separate receiver-keyed fact mechanisms on `FnCtx`; +//! P8 removed the class-field-loop twin, leaving fifteen in the active inventory. //! The original issue singled out six (`cached_lengths`, //! `bounded_index_pairs`, `packed_f64_loop_facts`, //! `masked_window_array_facts`, `buffer_view_slots`, and the //! `packed_receiver_*` trio); Phase 4 has now moved all six into this table. //! The expanded audit also records `int_range_facts`, //! `bounded_buffer_index_pairs`, `guarded_buffer_index_pairs`, -//! `element_shape_loop_facts`, `class_field_loop_facts`, +//! `element_shape_loop_facts`, //! `versioned_indexed_loop_facts`, `stable_packed_loop_facts`, //! `string_window_array_facts`, `buffer_data_slots`, and `class_keys_slots`. //! Historically, each answered the same two questions diff --git a/crates/perry-codegen/src/collectors/receiver_regions_tests.rs b/crates/perry-codegen/src/collectors/receiver_regions_tests.rs index 5c449f99b6..1afd5941b6 100644 --- a/crates/perry-codegen/src/collectors/receiver_regions_tests.rs +++ b/crates/perry-codegen/src/collectors/receiver_regions_tests.rs @@ -989,20 +989,13 @@ fn inventory() -> Vec { excludes_try: true, unwind_safe_by: "body must be a single LocalSet, so no handler can exist in extent", }, - TableRow { - table: "class_field_loop_facts", - claim: ReceiverClaim::Address, - boundary: FactBoundary::ScopeId, - excludes_try: true, - unwind_safe_by: "single-statement body plus a post-hoc contains_gc_unsafe_call scan \ - that discards the clone if any call was emitted", - }, TableRow { table: "element_shape_loop_facts", claim: ReceiverClaim::Address, boundary: FactBoundary::ScopeId, excludes_try: true, - unwind_safe_by: "same double lock as class_field_loop_facts", + unwind_safe_by: "matcher rejects handlers; emitted clone is entered only after \ + contains_gc_unsafe_call proves every block call-free", }, TableRow { table: "receiver_descriptors", @@ -1211,9 +1204,13 @@ fn the_inventory_covers_every_claim_kind_and_every_boundary_mechanism() { } assert_eq!( rows.len(), - 16, + 15, "inventory size changed — see FnCtx declarations" ); + assert!( + !rows.iter().any(|r| r.table == "class_field_loop_facts"), + "the removed class-loop fact table must not remain an active mechanism" + ); } #[test] diff --git a/crates/perry-codegen/src/concat_site_cache.rs b/crates/perry-codegen/src/concat_site_cache.rs index 7785d989c1..ed930946e5 100644 --- a/crates/perry-codegen/src/concat_site_cache.rs +++ b/crates/perry-codegen/src/concat_site_cache.rs @@ -48,7 +48,9 @@ //! an unproven operand keeps the plain fused call and the process-wide memo. //! //! The table is emitted through `typed_parse_rodata`, the per-function -//! deferred raw-global sink every lowering context already drains. +//! deferred raw-global sink every lowering context already drains. Worker +//! programs emit the table in TLS: its heap strings and registered root-cell +//! addresses belong to the current agent, including the empty-slot state. //! `PERRY_CONCAT_SITE_CACHE=0` removes the lane at build time. use anyhow::Result; @@ -175,8 +177,17 @@ pub(crate) fn try_lower_concat_site_cached( let site_id = ctx.ic_site_counter; ctx.ic_site_counter += 1; let table_name = concat_site_global_name(ctx, site_id); + // Both cached strings and their root registration belong to one agent. + // A process-global hit can otherwise reuse a retired worker's heap, and + // simultaneous workers can race to fill/register the same cell. + let tls = if crate::codegen::program_has_worker() || crate::codegen::program_has_thread_agents() + { + "thread_local " + } else { + "" + }; ctx.typed_parse_rodata.push(format!( - "@{table_name} = private global {CONCAT_SITE_TABLE_TY} zeroinitializer" + "@{table_name} = private {tls}global {CONCAT_SITE_TABLE_TY} zeroinitializer" )); let table_ref = format!("@{table_name}"); diff --git a/crates/perry-codegen/src/expr/array_push_guard_tests.rs b/crates/perry-codegen/src/expr/array_push_guard_tests.rs index fe68b0681f..463712c3f4 100644 --- a/crates/perry-codegen/src/expr/array_push_guard_tests.rs +++ b/crates/perry-codegen/src/expr/array_push_guard_tests.rs @@ -47,6 +47,7 @@ fn ir_opts() -> CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/expr/barrier_stem_census_tests.rs b/crates/perry-codegen/src/expr/barrier_stem_census_tests.rs index 7f60a1a4bc..311b67e779 100644 --- a/crates/perry-codegen/src/expr/barrier_stem_census_tests.rs +++ b/crates/perry-codegen/src/expr/barrier_stem_census_tests.rs @@ -385,6 +385,34 @@ fn verify_gate_instance( /// The uniform floor, over EVERY instance of the stem's gates in `ir`. /// `Err` is the red verdict; every message names what broke. pub(super) fn verify_stem_ir(ir: &str, stem: &str, kind: StemKind) -> Result<(), String> { + // Block labels and SSA registers are unique only inside their function. + // A specialized copy may reuse every label and register of its original. + let functions: Vec<_> = function_bodies(ir) + .filter(|f| { + ["barrier.", "barrier.maybe.", "gc_bookkeeping."] + .iter() + .any(|suffix| !all_numbered_labels(f, &format!("{stem}.{suffix}")).is_empty()) + }) + .collect(); + if functions.is_empty() { + return Err(format!("no `{stem}.barrier.` block in the emitted IR")); + } + for function in functions { + verify_stem_function(function, stem, kind) + .map_err(|e| format!("{}: {e}", function.lines().next().unwrap_or("function")))?; + } + Ok(()) +} + +/// Function bodies include the header and stop at the closing brace. No +/// lookup below may resolve a label or SSA definition in another function. +fn function_bodies(ir: &str) -> impl Iterator { + ir.split("\ndefine ") + .skip(1) + .map(|f| f.split_once("\n}").map_or(f, |(body, _)| body)) +} + +fn verify_stem_function(ir: &str, stem: &str, kind: StemKind) -> Result<(), String> { let barrier_labels = all_numbered_labels(ir, &format!("{stem}.barrier.")); if barrier_labels.is_empty() { return Err(format!( @@ -448,6 +476,10 @@ const VAL_ID: u32 = 22; /// runtime-key fallback, and `v: Any` is what puts the store on the live-test /// tier at all (same fixture reasoning as `index_set_barrier_tests::setter`). fn idxset_inbounds_ir() -> String { + idxset_inbounds_ir_for_target(None) +} + +fn idxset_inbounds_ir_for_target(target: Option<&str>) -> String { let mut m = Module::new("idxset_inbounds_census.ts"); m.functions = vec![Function { id: 1, @@ -507,7 +539,9 @@ fn idxset_inbounds_ir() -> String { was_unrolled: false, }]; m.init_kind = ModuleInitKind::Eager; - String::from_utf8(compile_module(&m, ir_opts()).expect("module compiles")) + let mut opts = ir_opts(); + opts.target = target.map(str::to_string); + String::from_utf8(compile_module(&m, opts).expect("module compiles")) .expect("LLVM IR should be UTF-8") } @@ -924,19 +958,21 @@ fn sabotage_hardwiring_the_gate_goes_red_for_every_stem() { for &(stem, kind) in VERIFIED_BARRIER_STEMS { let ir = probe_ir(stem); verify_stem_ir(&ir, stem, kind).expect("pristine IR must verify first"); - let label = all_numbered_labels(&ir, &format!("{stem}.barrier.")) - .into_iter() - .next() - .expect("a gated barrier block exists"); - let (branch, _) = branch_into_exact(&ir, &label).expect("gated branch exists"); - let cond = live_branch_condition(&branch).expect("pristine branch is live"); - let hardwired = branch.replacen(&cond, "true", 1); - let doctored = ir.replacen(&branch, &hardwired, 1); - let doctored = assert_changed(&ir, &doctored, "hardwire branch true"); - assert!( - verify_stem_ir(&doctored, stem, kind).is_err(), - "stem {stem:?}: `br i1 true` with a dead predicate must be caught" - ); + for function in function_bodies(&ir) { + for label in all_numbered_labels(function, &format!("{stem}.barrier.")) { + let (branch, _) = branch_into_exact(function, &label).expect("gated branch exists"); + let cond = live_branch_condition(&branch).expect("pristine branch is live"); + let hardwired = branch.replacen(&cond, "true", 1); + let changed_function = function.replacen(&branch, &hardwired, 1); + let doctored = ir.replacen(function, &changed_function, 1); + let doctored = assert_changed(&ir, &doctored, "hardwire branch true"); + assert!( + verify_stem_ir(&doctored, stem, kind).is_err(), + "stem {stem:?} in {}: hardwired gate must be caught", + function.lines().next().unwrap() + ); + } + } } } @@ -993,19 +1029,21 @@ fn sabotage_bypassing_the_gate_goes_red_for_every_stem() { for &(stem, kind) in VERIFIED_BARRIER_STEMS { let ir = probe_ir(stem); verify_stem_ir(&ir, stem, kind).expect("pristine IR must verify first"); - let label = all_numbered_labels(&ir, &format!("{stem}.barrier.")) - .into_iter() - .next() - .expect("a gated barrier block exists"); - let (branch, _) = branch_into_exact(&ir, &label).expect("gated branch exists"); - let false_target = operand(&branch, 2).expect("branch has a false target"); - let bypass = format!("br label {false_target}"); - let doctored = ir.replacen(branch.trim_start(), &bypass, 1); - let doctored = assert_changed(&ir, &doctored, "bypass gate"); - assert!( - verify_stem_ir(&doctored, stem, kind).is_err(), - "stem {stem:?}: an unconditionally-bypassed gate must be caught" - ); + for function in function_bodies(&ir) { + for label in all_numbered_labels(function, &format!("{stem}.barrier.")) { + let (branch, _) = branch_into_exact(function, &label).expect("gated branch exists"); + let false_target = operand(&branch, 2).expect("branch has a false target"); + let bypass = format!("br label {false_target}"); + let changed_function = function.replacen(&branch, &bypass, 1); + let doctored = ir.replacen(function, &changed_function, 1); + let doctored = assert_changed(&ir, &doctored, "bypass gate"); + assert!( + verify_stem_ir(&doctored, stem, kind).is_err(), + "stem {stem:?} in {}: bypassed gate must be caught", + function.lines().next().unwrap() + ); + } + } } } @@ -1054,3 +1092,59 @@ fn sabotage_moving_the_store_into_the_guard_goes_red_for_the_store_ic() { "a slot store that only the pointer arm performs must be caught" ); } + +/// Repeated block labels AND SSA registers across function copies must never +/// let an intact copy conceal a broken one, on any directory-lookup target. +#[test] +fn identical_labels_in_other_functions_cannot_validate_a_sabotaged_gate() { + assert_default_barrier_env_not_disabled(); + let stem = "idxset.inbounds"; + let kind = StemKind::ValueAndGenerationTested; + for target in [ + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu", + "x86_64-pc-windows-msvc", + ] { + let ir = idxset_inbounds_ir_for_target(Some(target)); + verify_stem_ir(&ir, stem, kind).expect("pristine target IR must verify"); + let function = function_bodies(&ir) + .find(|f| !all_numbered_labels(f, &format!("{stem}.barrier.")).is_empty()) + .expect("fixture must reach the barrier in a function"); + let start = function.find('@').unwrap(); + let end = function[start..].find('(').unwrap() + start; + let mut clone = function.to_string(); + clone.replace_range(start..end, "@barrier_contract_clone"); + let repeated = format!("{ir}\ndefine {clone}\n}}\n"); + verify_stem_ir(&repeated, stem, kind).expect("both intact copies must verify"); + let label = all_numbered_labels(&clone, &format!("{stem}.barrier.")).remove(0); + let (branch, _) = branch_into_exact(&clone, &label).unwrap(); + let cond = live_branch_condition(&branch).unwrap(); + let call_body = block_body_exact(&clone, &label).unwrap(); + let call = call_body + .lines() + .find(|l| l.contains(BARRIER_CALL)) + .unwrap(); + for (name, broken) in [ + ( + "bypass", + clone.replacen( + &branch, + &format!("br label {}", operand(&branch, 2).unwrap()), + 1, + ), + ), + ( + "hardwire", + clone.replacen(&branch, &branch.replacen(&cond, "true", 1), 1), + ), + ("delete call", clone.replacen(call, "", 1)), + ] { + let doctored = repeated.replacen(&clone, &broken, 1); + assert_changed(&repeated, &doctored, name); + assert!( + verify_stem_ir(&doctored, stem, kind).is_err(), + "{target}: {name} hidden by another function" + ); + } + } +} diff --git a/crates/perry-codegen/src/expr/binary.rs b/crates/perry-codegen/src/expr/binary.rs index bb503ecc92..6b72429f83 100644 --- a/crates/perry-codegen/src/expr/binary.rs +++ b/crates/perry-codegen/src/expr/binary.rs @@ -23,7 +23,7 @@ use crate::type_analysis::{ }; use crate::types::{DOUBLE, I1, I128, I32, I64}; -use crate::rooting::with_operands_rooted; +use crate::rooting::{self, with_operands_rooted, EmittedValue, Repr, RootedGroup}; use super::{is_known_i32_range, lower_expr, FnCtx}; @@ -238,7 +238,7 @@ fn lower_guarded_numeric_add(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result // `width` is provenance-proven — and the tripwire took whole // application builds down: pi's `graphemeWidth`, cc's cli bundle.) let Some(all_num) = cond else { - return Ok(rebuild_add_tree(ctx, expr, values, &mut 0, true)); + return Ok(rebuild_numeric_add_tree(ctx, expr, values, &mut 0)); }; let fast_idx = ctx.new_block("guarded_add.numeric"); @@ -250,13 +250,27 @@ fn lower_guarded_numeric_add(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result ctx.block().cond_br(&all_num, &fast_label, &slow_label); ctx.current_block = fast_idx; - let fast_val = rebuild_add_tree(ctx, expr, values, &mut 0, true); + let fast_val = rebuild_numeric_add_tree(ctx, expr, values, &mut 0); let fast_end = ctx.block().label.clone(); ctx.block().br(&merge_label); ctx.current_block = slow_idx; crate::expr::emit_versioned_loop_callback_deopt(ctx); - let slow_val = rebuild_add_tree(ctx, expr, values, &mut 0, false); + let slow_val = rooting::with_rooted_group(ctx, values.len(), |ctx, group| { + // Capture the already-evaluated leaves, including global reads: + // coercion may both relocate them and overwrite their bindings. + // Leaves consumed by the first call have no preceding window; + // that helper protects its own inputs during the call. + let mut protect = Vec::with_capacity(values.len()); + let _ = dynamic_add_leaf_windows(expr, &mut false, &mut protect); + let leaves: Vec<_> = values + .iter() + .zip(protect) + .map(|(value, protect)| group.adopt_emitted(ctx, Repr::Boxed, value, protect)) + .collect(); + let result = rebuild_rooted_dynamic_add_tree(ctx, expr, group, &leaves, &mut 0, false); + Ok(group.reread_emitted(ctx, result)) + })?; let slow_end = ctx.block().label.clone(); ctx.block().br(&merge_label); @@ -539,7 +553,7 @@ fn dynamic_add_tree_benefits_shared_guard(expr: &Expr) -> bool { /// /// The fold departs from the specification only in WHEN it reads such a /// leaf. The conversions themselves still run in specification order: the -/// cold arm (`rebuild_add_tree(.., fast = false)`) calls the spec-`+` helper +/// cold arm (`rebuild_rooted_dynamic_add_tree`) calls the spec-`+` helper /// node for node over the lowered values. So a tree is faithful exactly when /// every leaf the specification reads after an earlier conversion is one /// whose read time cannot be observed (`add_leaf_is_evaluation_invariant`). @@ -637,14 +651,12 @@ fn add_leaf_is_evaluation_invariant(ctx: &FnCtx<'_>, leaf: &Expr) -> bool { } /// Rebuild the `+` tree over already-lowered leaf values, node for node, so the -/// original associativity survives. `fast` picks the inline `fadd`; otherwise -/// every node goes through the spec-`+` helper. -fn rebuild_add_tree( +/// original associativity survives. This arm contains only inline `fadd`s. +fn rebuild_numeric_add_tree( ctx: &mut FnCtx<'_>, expr: &Expr, values: &[String], next_leaf: &mut usize, - fast: bool, ) -> String { if let Expr::Binary { op: BinaryOp::Add, @@ -652,23 +664,86 @@ fn rebuild_add_tree( right, } = expr { - let l = rebuild_add_tree(ctx, left, values, next_leaf, fast); - let r = rebuild_add_tree(ctx, right, values, next_leaf, fast); - return if fast { - ctx.block().fadd(&l, &r) - } else { - ctx.block().call( - DOUBLE, - "js_dynamic_string_or_number_add", - &[(DOUBLE, &l), (DOUBLE, &r)], - ) - }; + let l = rebuild_numeric_add_tree(ctx, left, values, next_leaf); + let r = rebuild_numeric_add_tree(ctx, right, values, next_leaf); + return ctx.block().fadd(&l, &r); } let value = values[*next_leaf].clone(); *next_leaf += 1; value } +/// Rebuild the original cold `+` tree through root handles. A left subtree's +/// result needs its own root when computing the right subtree can collect; +/// rooting the leaves alone cannot protect this newly-produced value. +fn rebuild_rooted_dynamic_add_tree( + ctx: &mut FnCtx<'_>, + expr: &Expr, + group: &mut RootedGroup<'_>, + leaves: &[EmittedValue], + next_leaf: &mut usize, + protect_result: bool, +) -> EmittedValue { + if let Expr::Binary { + op: BinaryOp::Add, + left, + right, + } = expr + { + let right_collects = matches!( + right.as_ref(), + Expr::Binary { + op: BinaryOp::Add, + .. + } + ); + let l = + rebuild_rooted_dynamic_add_tree(ctx, left, group, leaves, next_leaf, right_collects); + let r = rebuild_rooted_dynamic_add_tree(ctx, right, group, leaves, next_leaf, false); + // No register snapshot crosses the recursive right-hand calls. + // The helper owns both inputs during this consuming call. + let l = group.reread_emitted(ctx, l); + let r = group.reread_emitted(ctx, r); + let result = ctx.block().call( + DOUBLE, + "js_dynamic_string_or_number_add", + &[(DOUBLE, &l), (DOUBLE, &r)], + ); + return group.adopt_emitted(ctx, Repr::Boxed, &result, protect_result); + } + let leaf = leaves[*next_leaf]; + *next_leaf += 1; + leaf +} + +/// Which captured leaves are consumed after an earlier dynamic-add call? +/// Follow the same left/right/postorder call order as the cold rebuild, so +/// inputs used only by the first call need no extra roots. Intermediate +/// results have separate windows, handled by `protect_result` above. +fn dynamic_add_leaf_windows( + expr: &Expr, + called: &mut bool, + protect: &mut Vec, +) -> Option { + if let Expr::Binary { + op: BinaryOp::Add, + left, + right, + } = expr + { + let l = dynamic_add_leaf_windows(left, called, protect); + let r = dynamic_add_leaf_windows(right, called, protect); + for leaf in [l, r].into_iter().flatten() { + protect[leaf] = *called; + } + *called = true; + return None; + } + let index = protect.len(); + protect.push(false); + Some(index) +} + /// May the flattened `p1 + p2 + … + pN` chain be handed to /// `js_string_concat_chain`, which formats EVERY part as a string? (#7837) /// diff --git a/crates/perry-codegen/src/expr/call_spread_short.rs b/crates/perry-codegen/src/expr/call_spread_short.rs index 6104022e48..d733cde2c6 100644 --- a/crates/perry-codegen/src/expr/call_spread_short.rs +++ b/crates/perry-codegen/src/expr/call_spread_short.rs @@ -302,9 +302,12 @@ pub(crate) fn try_lower<'f, 'e>( let cid_ok = ctx .block() .icmp_eq(I32, &live_class, &candidate.class_id.to_string()); - let shape_ok = ctx - .block() - .icmp_eq(I32, &live_shape, &expected_shapes[candidate_no]); + let shape_ok = crate::typed_shape::emit_compatible_shape_eq( + ctx.block(), + &live_shape, + &expected_shapes[candidate_no], + &[], + ); let target_ok = ctx.block().and(I1, &cid_ok, &shape_ok); ctx.block().cond_br(&target_ok, &target_label, &miss_label); diff --git a/crates/perry-codegen/src/expr/class_field_barrier_tests.rs b/crates/perry-codegen/src/expr/class_field_barrier_tests.rs index fb7cb9c682..647d62062a 100644 --- a/crates/perry-codegen/src/expr/class_field_barrier_tests.rs +++ b/crates/perry-codegen/src/expr/class_field_barrier_tests.rs @@ -65,6 +65,7 @@ pub(super) fn ir_opts() -> CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/expr/class_field_inline_guard.rs b/crates/perry-codegen/src/expr/class_field_inline_guard.rs index 46e0c52a5e..fb195d8328 100644 --- a/crates/perry-codegen/src/expr/class_field_inline_guard.rs +++ b/crates/perry-codegen/src/expr/class_field_inline_guard.rs @@ -18,8 +18,7 @@ //! arm puts an unknown external call inside the loop body, which //! clobber-blocks LICM for every load in the check. Per-access cost is //! therefore paid on every iteration. The hoisted form exists as the #5093 -//! versioned-loop preheader check (`emit_class_field_loop_preheader_check`, -//! sound only for call-free clone bodies), and statically-proven receivers +//! region preheader checks (`stmt::region_loop`), and statically-proven receivers //! skip the diamond entirely (`collectors/ptr_shape.rs`). Do not "fix" this //! by de-volatilizing the gate: it buys nothing (the calls still block LICM) //! and weakens the mid-loop sticky-flip visibility guarantee for loops whose @@ -39,10 +38,7 @@ use super::FnCtx; // Mirror of the runtime constants the inline check reproduces. Kept as literal // decimals because the emitted IR is textual. -const GC_TYPE_OBJECT: &str = "2"; const GC_FLAG_FORWARDED_I8: &str = "-128"; // 0x80 as i8 -/// `OBJ_FLAG_HAS_DESCRIPTORS | OBJ_FLAG_STABLE_TOMBSTONES`. -const OBJ_FLAG_READ_FAST_PATH_BLOCKED: &str = "3072"; /// `OBJ_FLAG_FROZEN | OBJ_FLAG_STABLE_TOMBSTONES | /// OBJ_FLAG_HAS_DESCRIPTORS`. Numeric proof is a different ShapeId, so the /// exact shape comparison below excludes it. @@ -274,125 +270,6 @@ pub(crate) fn emit_plain_finite_number_check( blk.icmp_ne(I64, &exp, F64_EXP_MASK) } -/// #5093 loop versioning: emit the whole-loop shape check in a versioned -/// loop's preheader. -/// -/// This is the hoisted form of [`emit_class_field_inline_precheck`]: the same -/// strict subset of the runtime `class_field_fast_contract`, evaluated ONCE -/// before loop entry, branching to `fast_label` (the fast clone's preheader) -/// when the monomorphic shape holds and to `slow_label` (the slow clone's -/// preheader, i.e. today's guarded loop) otherwise. Evaluating it once is -/// sound only because the fast clone's body is call-free (matcher-enforced in -/// `stmt/loops.rs`): with no calls there is no allocation, so no GC can move -/// the object or run any of the runtime paths that mutate class_id / -/// keys_array / field_count / the typed-layout intact bit / the frozen bit / -/// the process-global enable flag mid-loop. -/// -/// No typed-layout bit is tested (charter step 5, P4: raw-f64 fields are `F64` -/// birth lanes of the compared id); `require_not_frozen` adds the frozen-bit check (any write in the -/// loop). Per-store value checks are NOT emitted here — the fast clone's -/// stores keep their inline plain-finite check and side-exit to `slow_label`. -/// -/// Returns `(obj_ptr, shape_ok)`: the SSA name of the receiver object pointer -/// (`inttoptr` of `obj_handle`) and the accumulated `i1` shape predicate, -/// both emitted in the deref block. The deref block is deliberately left -/// UNTERMINATED with `ctx.current_block` pointing at it: the caller lowers -/// the fast clone first, verifies it really came out call-free -/// (`LlBlock::contains_gc_unsafe_call`), and only then terminates the deref -/// block — `cond_br(shape_ok, fast, slow)` on success, or an unconditional -/// branch to the slow clone if some unpredicted lowering path emitted a call -/// (never enter a fast clone whose call-freeness is unproven). The deref -/// block dominates the fast preheader, so the fast clone may use `obj_ptr` -/// directly for raw slot access. -#[allow(clippy::too_many_arguments)] -pub(crate) fn emit_class_field_loop_preheader_check( - ctx: &mut FnCtx, - obj_bits: &str, - obj_handle: &str, - expected_class_id: &str, - expected_shape_id: &str, - require_not_frozen: bool, - slow_label: &str, -) -> (String, String) { - let deref_idx = ctx.new_block("class_field_loop.preheader.deref"); - let deref_label = ctx.block_label(deref_idx); - - // Gate: enable flag first (volatile — the runtime flips it sticky 0 -> 1 - // when descriptors / typed feedback / verify mode come into use), then - // prove the receiver is a real heap object before dereferencing. - { - let blk = ctx.block(); - let flag = blk.load_volatile(I8, "@PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED"); - let flag_ok = blk.icmp_eq(I8, &flag, "0"); - // POINTER tag and above the handle band: the fused receiver test. - let ptr_safe = - crate::expr::receiver_range::emit_fused_receiver_test(blk, obj_bits).is_object_pointer; - let can_inline = blk.and(I1, &ptr_safe, &flag_ok); - blk.cond_br(&can_inline, &deref_label, slow_label); - } - - ctx.current_block = deref_idx; - { - let blk = ctx.block(); - let obj_ptr = blk.inttoptr(I64, obj_handle); - - // GcHeader (precedes the object by 8 bytes): obj_type @-8 (i8), - // gc_flags @-7 (i8), _reserved @-6 (i16). - let gtype_ptr = blk.gep(I8, &obj_ptr, &[(I64, "-8")]); - let gtype = blk.load(I8, >ype_ptr); - let gtype_ok = blk.icmp_eq(I8, >ype, GC_TYPE_OBJECT); - - let gflags_ptr = blk.gep(I8, &obj_ptr, &[(I64, "-7")]); - let gflags = blk.load(I8, &gflags_ptr); - let fwd = blk.and(I8, &gflags, GC_FLAG_FORWARDED_I8); - let not_fwd = blk.icmp_eq(I8, &fwd, "0"); - - let res_ptr = blk.gep(I8, &obj_ptr, &[(I64, "-6")]); - let reserved = blk.load(I16, &res_ptr); - - // ObjectHeader: class_id @0 and authoritative ShapeId @4 (#8113 — the - // two leading offsets moved down 4 when `object_type` was deleted). - // Matching the immutable descriptor proves the live-slot bound and key - // order. - let cid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "0")]); - let class_id = blk.load(I32, &cid_ptr); - let cid_ok = blk.icmp_eq(I32, &class_id, expected_class_id); - - let sid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "4")]); - let shape_id = blk.load(I32, &sid_ptr); - let shape_ok = blk.icmp_eq(I32, &shape_id, expected_shape_id); - - let mut acc = blk.and(I1, >ype_ok, ¬_fwd); - acc = blk.and(I1, &acc, &cid_ok); - acc = blk.and(I1, &acc, &shape_ok); - - // #5654: a receiver that has ever had a property / accessor descriptor - // installed on it needs the guard's descriptor-aware dispatch (an - // accessor must fire on reads, a non-writable slot must reject - // stores). Instance-level installs no longer flip the process-global - // gate, so the hoisted check must vet the per-object flag — once, for - // the whole loop: installing a descriptor mid-loop would require a - // runtime call, which the call-free fast clone cannot make. - let blocked = blk.and(I16, &reserved, OBJ_FLAG_READ_FAST_PATH_BLOCKED); - let unblocked = blk.icmp_eq(I16, &blocked, "0"); - acc = blk.and(I1, &acc, &unblocked); - - // Charter step 5, P4: a raw-f64 field needs no per-object bit. The - // site is raw only for an `F64` lane of every compared id's birth rep - // (`class_field_site_raw_f64`), and an object carrying such an id holds - // a Number in that lane by the shape's invariant. - - if require_not_frozen { - let blocked = blk.and(I16, &reserved, OBJ_FLAG_WRITE_FAST_PATH_BLOCKED); - let write_fast_path_ok = blk.icmp_eq(I16, &blocked, "0"); - acc = blk.and(I1, &acc, &write_fast_path_ok); - } - - // No terminator: the caller branches after verifying the fast clone. - (obj_ptr, acc) - } -} - /// #7142: the inline shape re-check that licenses routing a class-id dispatch /// tower case to a proven-receiver method clone. /// @@ -476,7 +353,8 @@ pub(crate) fn emit_proven_shape_recheck( // exact immutable layout and receiver-kind descriptor (#8113 offsets). let sid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "4")]); let shape_id = blk.load(I32, &sid_ptr); - let shape_ok = blk.icmp_eq(I32, &shape_id, expected_shape_id); + let shape_ok = + crate::typed_shape::emit_compatible_shape_eq(blk, &shape_id, expected_shape_id, &[]); let mut acc = blk.and(I1, &flag_ok, ¬_fwd); acc = blk.and(I1, &acc, &unlatched); @@ -581,7 +459,14 @@ pub(crate) fn emit_class_field_inline_precheck( // one 64-bit compare against `(shape << 32) | class_id`. let identity = blk.load(I64, &obj_ptr); let declared = expected_class_identity(blk, expected_class_id, &live_shape); - let mut ok = blk.icmp_eq(I64, &identity, &declared); + let mut ok = crate::typed_shape::emit_compatible_class_shape_eq( + blk, + &identity, + expected_class_id, + &live_shape, + &declared, + &[field_index], + ); for arm in subclass_arms { let arm_shape = crate::typed_shape::class_shape_id_operand_on_block( blk, @@ -590,21 +475,38 @@ pub(crate) fn emit_class_field_inline_precheck( ); let arm_expected = expected_class_identity(blk, &arm.class_id.to_string(), &arm_shape); - let arm_ok = blk.icmp_eq(I64, &identity, &arm_expected); + let arm_ok = crate::typed_shape::emit_compatible_class_shape_eq( + blk, + &identity, + &arm.class_id.to_string(), + &arm_shape, + &arm_expected, + &[field_index], + ); ok = blk.or(I1, &ok, &arm_ok); } ok } else { let sid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "4")]); let shape_id = blk.load(I32, &sid_ptr); - let mut ok = blk.icmp_eq(I32, &shape_id, &live_shape); + let mut ok = crate::typed_shape::emit_compatible_shape_eq( + blk, + &shape_id, + &live_shape, + &[field_index], + ); for arm in subclass_arms { let arm_shape = crate::typed_shape::class_shape_id_operand_on_block( blk, &arm.keys_global, true, ); - let arm_ok = blk.icmp_eq(I32, &shape_id, &arm_shape); + let arm_ok = crate::typed_shape::emit_compatible_shape_eq( + blk, + &shape_id, + &arm_shape, + &[field_index], + ); ok = blk.or(I1, &ok, &arm_ok); } ok @@ -757,18 +659,27 @@ pub(crate) fn emit_class_field_read_precheck( // one 64-bit compare against `(shape << 32) | class_id`. let identity = blk.load(I64, &obj_ptr); let declared = expected_class_identity(blk, expected_class_id, &live_shape); - blk.icmp_eq(I64, &identity, &declared) + crate::typed_shape::emit_compatible_class_shape_eq( + blk, + &identity, + expected_class_id, + &live_shape, + &declared, + &[], + ) } else { let sid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "4")]); let shape_id = blk.load(I32, &sid_ptr); - let mut ok = blk.icmp_eq(I32, &shape_id, &live_shape); + let mut ok = + crate::typed_shape::emit_compatible_shape_eq(blk, &shape_id, &live_shape, &[]); for arm in subclass_arms { let arm_shape = crate::typed_shape::class_shape_id_operand_on_block( blk, &arm.keys_global, true, ); - let arm_ok = blk.icmp_eq(I32, &shape_id, &arm_shape); + let arm_ok = + crate::typed_shape::emit_compatible_shape_eq(blk, &shape_id, &arm_shape, &[]); ok = blk.or(I1, &ok, &arm_ok); } ok @@ -785,7 +696,14 @@ pub(crate) fn emit_class_field_read_precheck( ); let arm_expected = expected_class_identity(blk, &arm.class_id.to_string(), &arm_shape); - let arm_ok = blk.icmp_eq(I64, &identity, &arm_expected); + let arm_ok = crate::typed_shape::emit_compatible_class_shape_eq( + blk, + &identity, + &arm.class_id.to_string(), + &arm_shape, + &arm_expected, + &[], + ); ok = blk.or(I1, &ok, &arm_ok); } } diff --git a/crates/perry-codegen/src/expr/class_method_arguments_object_tests.rs b/crates/perry-codegen/src/expr/class_method_arguments_object_tests.rs index 305a3cfcbb..7adec8f974 100644 --- a/crates/perry-codegen/src/expr/class_method_arguments_object_tests.rs +++ b/crates/perry-codegen/src/expr/class_method_arguments_object_tests.rs @@ -59,6 +59,7 @@ fn ir_opts() -> CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/expr/closure.rs b/crates/perry-codegen/src/expr/closure.rs index f8d46d2a95..c38481801a 100644 --- a/crates/perry-codegen/src/expr/closure.rs +++ b/crates/perry-codegen/src/expr/closure.rs @@ -204,7 +204,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // Compute the closure function name BEFORE taking the // mutable block borrow. - let func_name = format!("perry_closure_{}__{}", ctx.strings.module_prefix(), func_id); + let func_name = + crate::fn_info::closure_body_symbol(ctx.strings.module_prefix(), *func_id); // Closures may reserve extra lexical slots after ordinary // captures. Keep `this` last because the runtime's diff --git a/crates/perry-codegen/src/expr/collecting_root_tests.rs b/crates/perry-codegen/src/expr/collecting_root_tests.rs new file mode 100644 index 0000000000..4865d3df32 --- /dev/null +++ b/crates/perry-codegen/src/expr/collecting_root_tests.rs @@ -0,0 +1,1082 @@ +//! Caller roots on collecting field-store, nested-add and constructor paths. +use crate::testing::{root_slots::function_slice, temp_slots}; +use crate::{compile_module, user_function_symbol}; +use perry_hir::types::Type; +use perry_hir::{BinaryOp, Class, ClassField, Expr, Function, Module, Param, Stmt}; +use std::collections::{BTreeMap, BTreeSet}; + +fn probe(params: Vec, result: Expr) -> Function { + Function { + id: 1, + name: "probe".to_string(), + type_params: Vec::new(), + params: params + .into_iter() + .enumerate() + .map(|(i, ty)| Param { + id: i as u32 + 1, + name: format!("p{i}"), + ty, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }) + .collect(), + return_type: Type::Any, + body: vec![Stmt::Return(Some(result))], + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + } +} + +fn ir_for(mut module: Module, function: Function) -> String { + module.functions = vec![function]; + let symbol = user_function_symbol(&module.name, "probe"); + let ir = String::from_utf8( + compile_module(&module, crate::temp_root_coverage::entry_opts()) + .expect("collecting-root fixture compiles"), + ) + .unwrap(); + function_slice(&ir, &symbol).to_string() +} + +fn blocks(ir: &str) -> BTreeMap<&str, String> { + let mut out = BTreeMap::new(); + let mut label = None; + for line in ir.lines() { + if !line.starts_with(char::is_whitespace) && line.ends_with(':') { + label = Some(line.trim_end_matches(':')); + } else if let Some(label) = label { + out.entry(label) + .or_insert_with(String::new) + .push_str(&format!("{line}\n")); + } + } + out +} + +#[derive(Clone, Copy, Debug)] +struct Site<'a> { + label: &'a str, + index: usize, + text: &'a str, +} + +struct ColdCfg<'a> { + ir: &'a str, + blocks: &'a BTreeMap<&'a str, String>, + start: &'a str, + stop: &'a str, +} + +fn block<'a>(blocks: &'a BTreeMap<&str, String>, prefix: &str, ir: &str) -> (&'a str, &'a str) { + let matches: Vec<_> = blocks + .iter() + .filter(|(label, _)| label.starts_with(prefix)) + .collect(); + assert_eq!(matches.len(), 1, "expected one block {prefix}:\n{ir}"); + (*matches[0].0, matches[0].1.as_str()) +} + +fn registers(text: &str) -> impl Iterator { + text.split(|c: char| !(c.is_alphanumeric() || c == '%' || c == '.' || c == '_')) + .filter(|token| token.starts_with('%')) +} + +fn successors(body: &str) -> impl Iterator { + // Read terminator edges only; phi predecessors are not successor edges. + body.lines() + .map(str::trim) + .filter(|line| line.starts_with("br ")) + .flat_map(|line| line.split("label %").skip(1)) + .map(|tail| { + tail.split(|c: char| !(c.is_alphanumeric() || c == '.' || c == '_')) + .next() + .unwrap() + }) +} + +impl<'a> ColdCfg<'a> { + fn reachable(&self, start: &'a str, skip: Option<&str>) -> BTreeSet<&'a str> { + let mut pending = vec![start]; + let mut seen = BTreeSet::new(); + while let Some(label) = pending.pop() { + if label == self.stop || Some(label) == skip || !seen.insert(label) { + continue; + } + let body = self + .blocks + .get(label) + .unwrap_or_else(|| panic!("missing CFG target {label}:\n{}", self.ir)); + pending.extend(successors(body)); + } + seen + } + + fn sites(&self) -> Vec> { + self.reachable(self.start, None) + .into_iter() + .flat_map(|label| { + self.blocks[label] + .lines() + .enumerate() + .map(move |(index, text)| Site { + label, + index, + text: text.trim(), + }) + }) + .collect() + } + + fn definition(&self, reg: &str) -> Site<'a> { + let needle = format!("{reg} = "); + self.blocks + .iter() + .find_map(|(label, body)| { + body.lines().enumerate().find_map(|(index, text)| { + text.trim().starts_with(&needle).then_some(Site { + label, + index, + text: text.trim(), + }) + }) + }) + .unwrap_or_else(|| panic!("missing definition {reg}:\n{}", self.ir)) + } + + fn dominates(&self, before: Site<'_>, after: Site<'_>) -> bool { + let region = self.reachable(self.start, None); + if !region.contains(before.label) || !region.contains(after.label) { + return false; + } + if before.label == after.label { + return before.index < after.index; + } + !self + .reachable(self.start, Some(before.label)) + .contains(after.label) + } + + fn assert_before(&self, before: Site<'_>, after: Site<'_>, what: &str) { + assert!( + self.dominates(before, after), + "{what}: {before:?} must dominate {after:?} through the cold CFG:\n{}", + self.ir + ); + } + + fn calls(&self, helper: &str) -> Vec> { + let needle = format!("@{helper}("); + let mut pending: Vec<_> = self + .sites() + .into_iter() + .filter(|site| site.text.contains("call ") && site.text.contains(&needle)) + .collect(); + let mut ordered = Vec::new(); + // Order by control flow, never by block emission order. Reject branches + // whose calls do not have the fixture's expected sequential relation. + while !pending.is_empty() { + let first = pending + .iter() + .position(|candidate| { + pending.iter().all(|other| { + (candidate.label == other.label && candidate.index == other.index) + || self.dominates(*candidate, *other) + }) + }) + .unwrap_or_else(|| { + panic!( + "@{helper} calls lack a dominance order: {pending:?}\n{}", + self.ir + ) + }); + ordered.push(pending.remove(first)); + } + ordered + } + + // Only representation-preserving wrappers are accepted between a root + // load and an operand, including native RS4GC's opaque identity asm. + fn root_read(&self, operand: &str, consumer: Site<'_>) -> (&'a str, Site<'a>) { + let (slot, read) = self.slot_read(operand, consumer); + assert!(expression_temp_slots(self.ir, self.blocks).contains(slot), + "cold operand must read an expression root, not its mutable source binding: {read:?}\n{}", self.ir); + (slot, read) + } + + fn slot_read(&self, operand: &str, consumer: Site<'_>) -> (&'a str, Site<'a>) { + assert!( + temp_slots::derives_from_slot_load(self.ir, operand, 16), + "{operand} must derive from a rooted load:\n{}", + self.ir + ); + let mut reg = operand; + for _ in 0..16 { + let site = self.definition(reg); + let def = site.text.split_once(" = ").unwrap().1; + if def.starts_with("load ") { + self.assert_before(site, consumer, "root reread must dominate its consumer"); + let slot = def + .rsplit_once(", ptr ") + .unwrap() + .1 + .split(',') + .next() + .unwrap() + .trim(); + return (slot, site); + } + assert!( + def.starts_with("bitcast ") + || def.starts_with("ptrtoint ") + || def.starts_with("inttoptr ") + || def.starts_with("and i64 ") + || (def.starts_with("or i64 ") + && def.ends_with(crate::nanbox::POINTER_TAG_I64)) + || (def.starts_with("call i64 asm \"\"") && def.contains("\"=r,0\"")), + "unexpected root operand transformation: {site:?}\n{}", + self.ir + ); + reg = registers(def).next().unwrap(); + } + panic!("no root load for {operand}:\n{}", self.ir) + } + + fn publications(&self, slot: &str) -> Vec> { + self.sites() + .into_iter() + .filter(|site| { + site.text.starts_with("store ") + && !is_clear(site.text) + && store_parts(site.text).is_some_and(|(_, target)| target == slot) + }) + .collect() + } + + fn publication_before(&self, slot: &str, consumer: Site<'_>) -> Site<'a> { + let publications: Vec<_> = self + .publications(slot) + .into_iter() + .filter(|store| self.dominates(*store, consumer)) + .collect(); + assert_eq!( + publications.len(), + 1, + "one cold publication of {slot} must dominate {consumer:?}:\n{}", + self.ir + ); + publications[0] + } +} + +fn store_parts(line: &str) -> Option<(&str, &str)> { + let rest = line.strip_prefix("store ")?; + let (value, slot) = rest.split_once(", ptr ")?; + Some((value, slot.split(',').next().unwrap().trim())) +} + +fn is_clear(line: &str) -> bool { + line.starts_with("store i64 0,") || line.starts_with("store ptr addrspace(1) null,") +} + +fn derives_from(ir: &str, value: &str, ancestor: &str, depth: usize) -> bool { + if value == ancestor { + return true; + } + if depth == 0 { + return false; + } + let needle = format!("{value} = "); + ir.lines() + .map(str::trim) + .find_map(|line| line.strip_prefix(&needle)) + .is_some_and(|def| registers(def).any(|reg| derives_from(ir, reg, ancestor, depth - 1))) +} + +fn expression_temp_slots(ir: &str, blocks: &BTreeMap<&str, String>) -> BTreeSet { + let (entry, _) = block(blocks, "entry.", ir); + temp_slots::temp_root_slots(ir) + .into_iter() + .filter(|slot| { + // Native roots zero-seed parameter allocas too. Exempt only roots + // whose sole publication is an entry store of a function argument; + // an expression root later parking that argument remains a temp. + let stores: Vec<_> = blocks + .iter() + .flat_map(|(label, body)| { + body.lines().map(str::trim).filter_map(move |line| { + store_parts(line) + .filter(|(_, target)| *target == slot) + .filter(|_| !is_clear(line)) + .map(move |(value, _)| (*label, value)) + }) + }) + .collect(); + !(stores.len() == 1 + && stores[0].0 == entry + && registers(ir.lines().next().unwrap()).any(|argument| { + registers(stores[0].1).any(|reg| derives_from(ir, reg, argument, 8)) + })) + }) + .collect() +} + +fn assert_hot_has_no_temp_traffic( + ir: &str, + blocks: &BTreeMap<&str, String>, + start: &str, + stop: &str, +) { + let cfg = ColdCfg { + ir, + blocks, + start, + stop, + }; + let slots = expression_temp_slots(ir, blocks); + for label in cfg.reachable(start, None) { + let body = &blocks[label]; + for token in registers(body) { + assert!( + !slots.contains(token), + "hot block {label} touches temp root {token}:\n{ir}" + ); + } + assert!( + !body.contains("js_gc_temp_root_"), + "hot block has runtime root traffic:\n{ir}" + ); + } +} + +fn field_store_ir(field_ty: Type) -> String { + // A declared number alone has an Any birth lane. Use an actual early + // numeric initializer so this fixture reaches the raw-f64 store arm. + let init = (field_ty == Type::Number).then_some(Expr::Number(0.0)); + let mut module = Module::new("collecting_field_store.ts"); + module.classes = vec![Class { + id: 101, + name: "Boxed".to_string(), + type_params: Vec::new(), + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: vec![ClassField { + name: "v".to_string(), + key_expr: None, + ty: field_ty, + init, + is_private: false, + is_readonly: false, + decorators: Vec::new(), + }], + constructor: None, + methods: Vec::new(), + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + computed_members: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + is_nested: false, + alloc_width_hint: 0, + specialized_from: None, + }]; + ir_for( + module, + probe( + vec![Type::Named("Boxed".to_string()), Type::Any], + Expr::PropertySet { + object: Box::new(Expr::LocalGet(1)), + property: "v".to_string(), + value: Box::new(Expr::LocalGet(2)), + }, + ), + ) +} + +#[test] +fn collecting_field_guard_refreshes_store_fallback_and_assignment_only_on_cold_paths() { + crate::temp_root_coverage::under_both_lowerings(|mode| { + for field_ty in [Type::Any, Type::Number] { + let numeric = field_ty == Type::Number; + let ir = field_store_ir(field_ty); + let blocks = blocks(&ir); + let (hot_label, _) = block(&blocks, "class_field_set.fast.", &ir); + let (merge_label, merge) = block(&blocks, "class_field_set.merge.", &ir); + let (guard_entry, _) = block(&blocks, "class_field_inline.guardcall.", &ir); + let (cold_label, _) = block(&blocks, "class_field_set.cold_fast.", &ir); + let (cold_merge_label, _) = block(&blocks, "class_field_set.cold_merge.", &ir); + let cfg = ColdCfg { + ir: &ir, + blocks: &blocks, + start: guard_entry, + stop: merge_label, + }; + let guards = cfg.calls("js_typed_feedback_class_field_set_guard"); + assert_eq!(guards.len(), 1, "{mode}: one collecting guard:\n{ir}"); + let guard = guards[0]; + let guard_body = &blocks[guard.label]; + assert!( + successors(guard_body).any(|label| label == cold_label), + "{mode}: collecting guard must enter rooted cold store:\n{ir}" + ); + assert!( + !cfg.reachable(guard_entry, None).contains(hot_label), + "collecting edge must not enter unrooted hot store:\n{ir}" + ); + assert!( + blocks.values().any(|body| body.contains("br i1 ") + && successors(body).any(|label| label == hot_label)), + "inline store must remain reachable:\n{ir}" + ); + assert_hot_has_no_temp_traffic(&ir, &blocks, hot_label, merge_label); + let guard_args = + temp_slots::call_operands(guard.text, "js_typed_feedback_class_field_set_guard") + .unwrap(); + let (receiver_slot, _) = cfg.root_read(&guard_args[1], guard); + let (rhs_slot, _) = cfg.root_read(&guard_args[6], guard); + assert_ne!( + receiver_slot, rhs_slot, + "receiver and RHS need distinct live roots:\n{ir}" + ); + for slot in [receiver_slot, rhs_slot] { + cfg.publication_before(slot, guard); + } + + // Inspect every store reachable from the guard-pass entry until + // its cold merge. Number fields legitimately store either the + // reread or the NaN canonicalizer's result. + let store_cfg = ColdCfg { + ir: &ir, + blocks: &blocks, + start: cold_label, + stop: cold_merge_label, + }; + let stores: Vec<_> = store_cfg + .sites() + .into_iter() + .filter(|site| site.text.starts_with("store double ")) + .collect(); + assert!( + !stores.is_empty(), + "cold guard-pass arm must store a value:\n{ir}" + ); + let mut canonical_stores = 0; + for store in stores { + let (value, target) = store_parts(store.text).unwrap(); + let stored = value.strip_prefix("double ").unwrap(); + let definition = cfg.definition(stored); + let rhs = if definition + .text + .contains("call double @js_array_numeric_value_to_raw_f64(") + { + assert!(numeric, "only Number fields canonicalize raw f64:\n{ir}"); + canonical_stores += 1; + cfg.assert_before(definition, store, "canonicalizer result must reach store"); + temp_slots::call_operands(definition.text, "js_array_numeric_value_to_raw_f64") + .unwrap()[0] + .clone() + } else { + stored.to_string() + }; + let (slot, reload) = cfg.root_read(&rhs, store); + assert_eq!( + slot, rhs_slot, + "guard-pass store must consume refreshed RHS:\n{ir}" + ); + cfg.assert_before( + guard, + reload, + "store RHS must be reread after collecting guard", + ); + let receiver_load = cfg.sites().into_iter().find(|site| { + site.text.contains(" = load ") + && site.text.rsplit_once(", ptr ").is_some_and(|(_, tail)| tail.split(',').next().unwrap().trim() == receiver_slot) + && cfg.dominates(guard, *site) && cfg.dominates(*site, store) + && derives_from(&ir, target, site.text.split_once(" = ").unwrap().0, 16) + }).unwrap_or_else(|| panic!("store address must derive from post-guard receiver reread: {store:?}\n{ir}")); + cfg.assert_before(guard, receiver_load, "receiver must refresh after guard"); + } + if numeric { + assert!( + canonical_stores > 0, + "Number cold path must retain non-finite/boxed-number canonicalization:\n{ir}" + ); + } + + let fallbacks = cfg.calls("js_class_field_set_fallback"); + assert_eq!(fallbacks.len(), 1, "one setter fallback:\n{ir}"); + let fallback = fallbacks[0]; + let fallback_args = + temp_slots::call_operands(fallback.text, "js_class_field_set_fallback").unwrap(); + for (operand, expected) in [ + (&fallback_args[1], receiver_slot), + (&fallback_args[3], rhs_slot), + ] { + let (slot, reload) = cfg.root_read(operand, fallback); + assert_eq!( + slot, expected, + "fallback must read same captured root:\n{ir}" + ); + cfg.assert_before( + guard, + reload, + "fallback reread must follow collecting guard", + ); + } + assert!( + blocks[fallback.label].contains("load double, ptr @"), + "fallback must reload immutable key handle:\n{ir}" + ); + + let phi = merge + .lines() + .find(|line| line.contains("phi double")) + .unwrap(); + let incoming: Vec<_> = phi + .split('[') + .skip(1) + .map(|tail| { + let (value, predecessor) = tail.split_once(',').unwrap(); + ( + value.trim(), + predecessor + .split(']') + .next() + .unwrap() + .trim() + .trim_start_matches('%'), + ) + }) + .filter(|(_, predecessor)| cfg.reachable(guard_entry, None).contains(predecessor)) + .collect(); + assert_eq!( + incoming.len(), + 1, + "assignment has one cold result edge:\n{ir}" + ); + let (cold_result, cold_end) = incoming[0]; + let result_use = Site { + label: cold_end, + index: blocks[cold_end].lines().count(), + text: "cold result edge", + }; + let (slot, result_read) = cfg.root_read(cold_result, result_use); + assert_eq!(slot, rhs_slot, "assignment must return captured RHS:\n{ir}"); + // The fallback need not dominate the join: both alternatives enter + // it. Require the join to be reachable from each and the reload + // to reside there, after a possible setter collection. + assert_eq!( + result_read.label, cold_merge_label, + "assignment reread belongs in cold merge:\n{ir}" + ); + let fallback_cfg = ColdCfg { + ir: &ir, + blocks: &blocks, + start: fallback.label, + stop: merge_label, + }; + fallback_cfg.assert_before( + fallback, + result_read, + "assignment must reread after the possible setter collection", + ); + for slot in [receiver_slot, rhs_slot] { + let clears: Vec<_> = cfg + .sites() + .into_iter() + .filter(|site| { + is_clear(site.text) + && store_parts(site.text).is_some_and(|(_, target)| target == slot) + }) + .collect(); + assert_eq!( + clears.len(), + 1, + "cold group must release {slot} exactly once:\n{ir}" + ); + cfg.assert_before( + result_read, + clears[0], + "assignment reread must precede root release", + ); + cfg.assert_before( + clears[0], + result_use, + "release must precede cold result edge", + ); + } + } + }); +} + +fn add(left: Expr, right: Expr) -> Expr { + Expr::Binary { + op: BinaryOp::Add, + left: Box::new(left), + right: Box::new(right), + } +} + +const ADD_HELPER: &str = "js_dynamic_string_or_number_add"; + +#[test] +fn single_dynamic_add_has_no_prior_call_window_or_added_temp_roots() { + crate::temp_root_coverage::under_both_lowerings(|mode| { + let ir = ir_for( + Module::new("collecting_single_add.ts"), + probe( + vec![Type::Any; 2], + add(Expr::LocalGet(1), Expr::LocalGet(2)), + ), + ); + let blocks = blocks(&ir); + let (slow, _) = block(&blocks, "guarded_add.dynamic.", &ir); + let (merge, _) = block(&blocks, "guarded_add.merge.", &ir); + let cfg = ColdCfg { + ir: &ir, + blocks: &blocks, + start: slow, + stop: merge, + }; + assert_eq!( + cfg.calls(ADD_HELPER).len(), + 1, + "{mode}: one consuming add:\n{ir}" + ); + assert_hot_has_no_temp_traffic(&ir, &blocks, slow, merge); + assert!(expression_temp_slots(&ir, &blocks).is_empty(), + "single consuming add has no earlier collection window or expression-root allocations:\n{ir}"); + }); +} + +#[test] +fn right_nested_add_preserves_captured_leaf_across_inner_coercion() { + crate::temp_root_coverage::under_both_lowerings(|mode| { + let ir = ir_for( + Module::new("collecting_nested_add.ts"), + probe( + vec![Type::Any; 3], + add(Expr::LocalGet(1), add(Expr::LocalGet(2), Expr::LocalGet(3))), + ), + ); + let blocks = blocks(&ir); + let (fast_label, fast) = block(&blocks, "guarded_add.numeric.", &ir); + let (merge_label, _) = block(&blocks, "guarded_add.merge.", &ir); + assert_eq!( + fast.matches("fadd double").count(), + 2, + "{mode}: numeric tree:\n{ir}" + ); + assert_hot_has_no_temp_traffic(&ir, &blocks, fast_label, merge_label); + let (slow, _) = block(&blocks, "guarded_add.dynamic.", &ir); + let cfg = ColdCfg { + ir: &ir, + blocks: &blocks, + start: slow, + stop: merge_label, + }; + let calls = cfg.calls(ADD_HELPER); + assert_eq!( + calls.len(), + 2, + "{mode}: inner and outer consuming calls:\n{ir}" + ); + let outer = temp_slots::call_operands(calls[1].text, ADD_HELPER).unwrap(); + let (slot, reread) = cfg.root_read(&outer[0], calls[1]); + cfg.publication_before(slot, calls[0]); + cfg.assert_before( + calls[0], + reread, + "saved leaf must be reread after inner coercion", + ); + let inner_result = calls[0].text.split_once(" = ").unwrap().0; + assert!( + derives_from(&ir, &outer[1], inner_result, 16), + "outer right must consume inner result:\n{ir}" + ); + }); +} + +#[test] +fn balanced_add_roots_left_intermediate_across_right_subtree() { + crate::temp_root_coverage::under_both_lowerings(|mode| { + let ir = ir_for( + Module::new("collecting_balanced_add.ts"), + probe( + vec![Type::Any; 4], + add( + add(Expr::LocalGet(1), Expr::LocalGet(2)), + add(Expr::LocalGet(3), Expr::LocalGet(4)), + ), + ), + ); + let blocks = blocks(&ir); + let (fast, fast_body) = block(&blocks, "guarded_add.numeric.", &ir); + let (merge, _) = block(&blocks, "guarded_add.merge.", &ir); + assert_eq!( + fast_body.matches("fadd double").count(), + 3, + "{mode}: numeric tree:\n{ir}" + ); + assert_hot_has_no_temp_traffic(&ir, &blocks, fast, merge); + let (slow, _) = block(&blocks, "guarded_add.dynamic.", &ir); + let cfg = ColdCfg { + ir: &ir, + blocks: &blocks, + start: slow, + stop: merge, + }; + let calls = cfg.calls(ADD_HELPER); + assert_eq!(calls.len(), 3, "{mode}: left, right and outer calls:\n{ir}"); + let intermediate = calls[0].text.split_once(" = ").unwrap().0; + let slot = temp_slots::temp_root_slot_holding(&ir, intermediate) + .unwrap_or_else(|| panic!("left intermediate must enter an expression root:\n{ir}")); + let publication = cfg.publication_before(&slot, calls[1]); + cfg.assert_before( + calls[0], + publication, + "left intermediate publication must follow its producer", + ); + let (stored, _) = store_parts(publication.text).unwrap(); + assert!( + registers(stored).any(|reg| derives_from(&ir, reg, intermediate, 16)), + "root dominating right call must hold left intermediate:\n{ir}" + ); + let outer = temp_slots::call_operands(calls[2].text, ADD_HELPER).unwrap(); + let (reread_slot, reread) = cfg.root_read(&outer[0], calls[2]); + assert_eq!( + reread_slot, slot, + "outer left must read intermediate root:\n{ir}" + ); + cfg.assert_before( + calls[1], + reread, + "left intermediate reread must follow right subtree collection", + ); + let right_result = calls[1].text.split_once(" = ").unwrap().0; + assert!( + derives_from(&ir, &outer[1], right_result, 16), + "outer right must consume right-subtree result:\n{ir}" + ); + }); +} + +fn imported_constructor_ir(walk_ancestor: bool, has_rest: bool, has_arguments: bool) -> String { + let mut opts = crate::temp_root_coverage::entry_opts(); + opts.imported_classes.push(crate::ImportedClass { + name: "WorkerMade".to_string(), + local_alias: None, + namespace: None, + source_prefix: "worker_producer_ts".to_string(), + constructor_param_count: (usize::from(has_rest) + usize::from(has_arguments)).max(1), + has_own_constructor: true, + constructor_has_rest: has_rest, + constructor_has_synthetic_arguments: has_arguments, + has_instance_fields: true, + method_names: Vec::new(), + proven_this_method_names: Vec::new(), + proven_this_tower_method_names: Vec::new(), + method_return_types: Vec::new(), + method_param_counts: Vec::new(), + method_has_rest: Vec::new(), + method_has_synthetic_arguments: Vec::new(), + method_arguments_length_only: Vec::new(), + static_field_names: Vec::new(), + static_method_names: Vec::new(), + static_method_return_types: Vec::new(), + static_method_param_counts: Vec::new(), + static_method_has_rest: Vec::new(), + static_method_has_user_rest: Vec::new(), + static_method_has_synthetic_arguments: Vec::new(), + getter_names: Vec::new(), + getter_return_types: Vec::new(), + setter_names: Vec::new(), + parent_name: None, + field_names: vec!["v".to_string(), "w".to_string()], + field_types: vec![Type::Any, Type::Any], + source_class_id: Some(101), + return_shape_imports: Vec::new(), + object_literal: None, + }); + let mut module = Module::new("collecting_imported_constructor.ts"); + if walk_ancestor { + module.classes.push(Class { + id: 102, + name: "Leaf".to_string(), + type_params: Vec::new(), + extends: Some(101), + extends_name: Some("WorkerMade".to_string()), + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: Vec::new(), + constructor: None, + methods: Vec::new(), + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + computed_members: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + is_nested: false, + alloc_width_hint: 0, + specialized_from: None, + }); + } + module.functions.push(probe( + vec![Type::Any], + Expr::New { + class_name: if walk_ancestor { "Leaf" } else { "WorkerMade" }.to_string(), + // A real pointer-bearing argument, not an undefined padding value. + args: vec![Expr::LocalGet(1), Expr::Object(Vec::new())], + type_args: Vec::new(), + cap_args_appended: 0, + byte_offset: 0, + }, + )); + let symbol = user_function_symbol(&module.name, "probe"); + let ir = + String::from_utf8(compile_module(&module, opts).expect("imported ctor fixture compiles")) + .unwrap(); + function_slice(&ir, &symbol).to_string() +} + +#[test] +fn imported_constructor_receiver_refreshes_after_initializers_and_call_preparation() { + crate::temp_root_coverage::under_both_lowerings(|mode| { + for walk_ancestor in [false, true] { + for (has_rest, has_arguments) in + [(false, false), (true, false), (false, true), (true, true)] + { + let packed = has_rest || has_arguments; + let ir = imported_constructor_ir(walk_ancestor, has_rest, has_arguments); + let blocks = blocks(&ir); + let (entry, _) = block(&blocks, "entry.", &ir); + let cfg = ColdCfg { + ir: &ir, + blocks: &blocks, + start: entry, + stop: "__end_of_function__", + }; + let ctor = "worker_producer_ts__WorkerMade_constructor"; + let calls = cfg.calls(ctor); + assert_eq!(calls.len(), 1, "{mode}: one imported ctor dispatch:\n{ir}"); + let call = calls[0]; + let operands = temp_slots::call_operands(call.text, ctor).unwrap(); + let (this_slot, read) = cfg.slot_read(&operands[0], call); + // Fixed arguments keep their existing root; packed arrays + // must each own a distinct expression root through dispatch. + let fixed_arg = (!packed).then(|| cfg.slot_read(&operands[1], call)); + let packed_reads: Vec<_> = if packed { + operands[1..] + .iter() + .map(|arg| cfg.root_read(arg, call)) + .collect() + } else { + Vec::new() + }; + assert_eq!( + packed_reads.len(), + usize::from(has_rest) + usize::from(has_arguments) + ); + if packed_reads.len() == 2 { + assert_ne!( + packed_reads[0].0, packed_reads[1].0, + "rest and arguments must retain separate arrays:\n{ir}" + ); + } + if mode == "native roots" { + assert!( + cfg.definition(this_slot) + .text + .contains("alloca ptr addrspace(1)"), + "constructor this-slot must be a native GC root:\n{ir}" + ); + } else { + assert!( + crate::testing::root_slots::bound_slots(&ir).contains_key(this_slot), + "constructor this-slot must be bound in the shadow frame:\n{ir}" + ); + } + let allocation = cfg + .sites() + .into_iter() + .find(|site| site.text.contains(" = call i64 @js_object_alloc_class_")) + .expect("fixture must allocate a class instance"); + let allocated = allocation.text.split_once(" = ").unwrap().0; + let publications: Vec<_> = cfg + .publications(this_slot) + .into_iter() + .filter(|site| { + registers(store_parts(site.text).unwrap().0) + .any(|value| derives_from(&ir, value, allocated, 16)) + }) + .collect(); + assert_eq!( + publications.len(), + 1, + "allocation must publish into this root:\n{ir}" + ); + let publication = publications[0]; + cfg.assert_before( + allocation, + publication, + "root publication follows allocation", + ); + cfg.assert_before(publication, call, "root publication dominates constructor"); + for helper in [ + "js_class_value", + "js_typed_feedback_class_field_set_guard", + "js_class_field_set_fallback", + "js_array_alloc", + ] { + let sites: Vec<_> = cfg + .sites() + .into_iter() + .filter(|site| site.text.contains(&format!("@{helper}("))) + .filter(|site| { + let path = ColdCfg { + start: site.label, + ..cfg + }; + path.reachable(site.label, None).contains(call.label) + }) + .collect(); + if helper != "js_array_alloc" || packed { + assert!( + !sites.is_empty(), + "{mode}: collecting subject {helper} must be live:\n{ir}" + ); + } + for site in sites { + cfg.assert_before( + publication, + site, + "this root precedes collecting preparation", + ); + let path = ColdCfg { + start: site.label, + ..cfg + }; + path.assert_before( + site, + read, + "receiver reread follows collecting preparation", + ); + if let Some((_, arg_read)) = fixed_arg { + path.assert_before( + site, + arg_read, + "fixed argument reread follows collecting preparation", + ); + } + } + } + for (slot, packed_read) in packed_reads { + let publications = cfg.publications(slot); + // The pool can reuse a released field-initializer root. + // Identify the array's publication by its allocation; + // earlier uses of the same slot are separate lifetimes. + let initial: Vec<_> = cfg + .sites() + .into_iter() + .filter(|site| site.text.contains(" = call i64 @js_array_alloc(")) + .flat_map(|producer| { + publications.iter().copied().filter_map(move |publication| { + registers(store_parts(publication.text).unwrap().0) + .any(|value| { + derives_from( + cfg.ir, + value, + producer.text.split_once(" = ").unwrap().0, + 16, + ) + }) + .then_some((producer, publication)) + }) + }) + .collect(); + assert_eq!(initial.len(), 1, + "{mode}: ancestor={walk_ancestor}, rest={has_rest}, arguments={has_arguments}: one initial packed-array publication in {slot}; publications={publications:?}\n{ir}"); + let (producer, first) = initial[0]; + for update in publications.iter().copied().filter(|site| { + cfg.dominates(producer, *site) + && !(site.label == first.label && site.index == first.index) + }) { + cfg.assert_before(first, update, "array publication dominates its updates"); + } + cfg.assert_before( + producer, + first, + "packed allocation precedes root publication", + ); + cfg.assert_before(first, call, "packed root survives through dispatch"); + for helper in ["js_array_alloc", "js_array_push_f64", "js_class_value"] { + for site in cfg.sites().into_iter().filter(|site| { + site.text.contains(&format!("@{helper}(")) + && cfg.dominates(producer, *site) + }) { + cfg.assert_before( + first, + site, + "packed root precedes subsequent collecting calls", + ); + let path = ColdCfg { + start: site.label, + ..cfg + }; + path.assert_before( + site, + packed_read, + "packed argument reread follows collecting preparation", + ); + } + } + let clears: Vec<_> = cfg + .sites() + .into_iter() + .filter(|site| { + is_clear(site.text) + && cfg.dominates(call, *site) + && store_parts(site.text).is_some_and(|(_, target)| target == slot) + }) + .collect(); + assert_eq!(clears.len(), 1, "packed root releases once:\n{ir}"); + cfg.assert_before( + call, + clears[0], + "packed root releases after constructor dispatch", + ); + } + // Existing field-store hot blocks keep their direct stores; + // this repair adds no root publication or reread in those arms. + for (label, body) in &blocks { + if label.starts_with("class_field_set.fast.") { + let stop = successors(body).next().expect("hot store must rejoin"); + assert_hot_has_no_temp_traffic(&ir, &blocks, label, stop); + } + } + } + } + }); +} diff --git a/crates/perry-codegen/src/expr/compare.rs b/crates/perry-codegen/src/expr/compare.rs index 40b9eef35f..2147e62591 100644 --- a/crates/perry-codegen/src/expr/compare.rs +++ b/crates/perry-codegen/src/expr/compare.rs @@ -1165,6 +1165,9 @@ fn lower_typeof_literal_inline( } pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { + if let Some(value) = crate::stmt::region_loop::try_lower_numeric_compare(ctx, expr, lower)? { + return Ok(value); + } match expr { Expr::Compare { op, left, right } => { // `typeof` always yields a string, so loose and strict equality diff --git a/crates/perry-codegen/src/expr/conforming_layout_note_tests.rs b/crates/perry-codegen/src/expr/conforming_layout_note_tests.rs index f442c9a9fb..bf93d877aa 100644 --- a/crates/perry-codegen/src/expr/conforming_layout_note_tests.rs +++ b/crates/perry-codegen/src/expr/conforming_layout_note_tests.rs @@ -14,6 +14,7 @@ fn ir_opts() -> CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/expr/element_shape_guard.rs b/crates/perry-codegen/src/expr/element_shape_guard.rs index fa6f91a9c3..d2d4cad19b 100644 --- a/crates/perry-codegen/src/expr/element_shape_guard.rs +++ b/crates/perry-codegen/src/expr/element_shape_guard.rs @@ -164,8 +164,7 @@ pub(crate) struct ElementShapeGuardOutputs { /// Emit the once-per-loop element-shape guard into the current block chain. /// /// Leaves `ctx.current_block` on an UNTERMINATED block holding the accumulated -/// `i1` predicate, exactly like -/// [`super::class_field_inline_guard::emit_class_field_loop_preheader_check`]: +/// `i1` predicate: /// the caller lowers the fast clone, proves it call-free, and only then /// terminates with `cond_br(shape_ok, fast, slow)`. Never entering a clone /// whose call-freeness is unproven is the whole revocation argument. @@ -610,7 +609,8 @@ pub(crate) fn emit_element_deref_with_residual( // #8113: the ShapeId moved from header offset 8 to 4. let sid_ptr = blk.gep(I8, &elem_ptr, &[(I64, "4")]); let shape_id = blk.load(I32, &sid_ptr); - let shape_ok = blk.icmp_eq(I32, &shape_id, &fact.expected_shape_id); + let shape_ok = + crate::typed_shape::emit_compatible_shape_eq(blk, &shape_id, &fact.expected_shape_id, &[]); let ok = blk.and(I1, &hdr_ok, &shape_ok); // The side exit resumes the CURRENT iteration in the slow clone; no effect diff --git a/crates/perry-codegen/src/expr/index_get.rs b/crates/perry-codegen/src/expr/index_get.rs index c762f32c48..1849e5bed9 100644 --- a/crates/perry-codegen/src/expr/index_get.rs +++ b/crates/perry-codegen/src/expr/index_get.rs @@ -1064,6 +1064,10 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { versioned_handle, ctx.i32_counter_slots.get(index_id).cloned(), ) { + crate::expr::store_census::bump( + ctx, + crate::expr::store_census::ELEM_READ_VERSIONED_INDEXED, + ); let idx_i32 = ctx.block().load(I32, &index_slot); return Ok(guarded_array::lower_trusted_plain_array_index_get( ctx, 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 c4722c122b..b928c6e72a 100644 --- a/crates/perry-codegen/src/expr/index_get/guarded_array.rs +++ b/crates/perry-codegen/src/expr/index_get/guarded_array.rs @@ -734,19 +734,22 @@ pub(super) fn lower_guarded_array_index_get( } ctx.current_block = fast_idx; - crate::expr::store_census::bump(ctx, crate::expr::store_census::ELEM_READ_FAST); - let fast_blk = ctx.block(); let arr_handle = match (&inline_fast_handle, &runtime_fast_handle) { - (Some((inline_handle, inline_pred)), Some((runtime_handle, runtime_pred))) => fast_blk.phi( - I64, - &[ - (inline_handle.as_str(), inline_pred.as_str()), - (runtime_handle.as_str(), runtime_pred.as_str()), - ], - ), + (Some((inline_handle, inline_pred)), Some((runtime_handle, runtime_pred))) => { + ctx.block().phi( + I64, + &[ + (inline_handle.as_str(), inline_pred.as_str()), + (runtime_handle.as_str(), runtime_pred.as_str()), + ], + ) + } (Some((handle, _)), None) | (None, Some((handle, _))) => handle.clone(), (None, None) => unreachable!("guarded array fast block has no predecessor handle"), }; + // The handle PHI must precede the census load/add/store in this join. + crate::expr::store_census::bump(ctx, crate::expr::store_census::ELEM_READ_FAST); + let fast_blk = ctx.block(); let fast_val = if require_numeric_layout { // The guard on the way into this block (inline tier or the runtime // `numeric_array_index_get_guard`) already proved: a plain, diff --git a/crates/perry-codegen/src/expr/index_get_claim_tests.rs b/crates/perry-codegen/src/expr/index_get_claim_tests.rs index d65859528c..dcffdb5d9c 100644 --- a/crates/perry-codegen/src/expr/index_get_claim_tests.rs +++ b/crates/perry-codegen/src/expr/index_get_claim_tests.rs @@ -81,9 +81,8 @@ fn numeric_key_on_a_declared_array_keeps_the_guarded_array_tier() { ); } -#[test] -fn numeric_layout_oob_array_read_returns_undefined_inline() { - let ir = ir_for( +fn numeric_layout_oob_array_read_ir() -> String { + ir_for( "numeric_layout_oob_array_read", vec![ Stmt::Let { @@ -115,7 +114,12 @@ fn numeric_layout_oob_array_read_returns_undefined_inline() { }), }, ], - ); + ) +} + +#[test] +fn numeric_layout_oob_array_read_returns_undefined_inline() { + let ir = numeric_layout_oob_array_read_ir(); assert!( ir.contains("arr.guard.oob") && ir.contains("9222246136947933185"), "a numeric-layout OOB read must inline the undefined tag:\n{ir}" @@ -126,6 +130,58 @@ fn numeric_layout_oob_array_read_returns_undefined_inline() { ); } +#[test] +fn instrumented_numeric_array_read_keeps_handle_phi_first() { + // Census enablement is cached process-wide. Use a fresh test process so + // this test also exercises the instrument when the suite defaults to OFF. + const CHILD: &str = "NUMERIC_PHI_TEST_CHILD"; + if std::env::var_os(CHILD).is_none() { + let current = std::thread::current(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([current.name().unwrap(), "--exact", "--test-threads=1"]) + .env(CHILD, "1") + .env("PERRY_STORE_CENSUS", "1") + .output() + .unwrap(); + assert!( + output.status.success(), + "instrumented array read failed: {}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed")); + return; + } + assert!(super::store_census::enabled()); + let ir = numeric_layout_oob_array_read_ir(); + let mut in_fast = false; + let mut instructions = Vec::new(); + for line in ir.lines() { + if !line.starts_with(char::is_whitespace) && line.ends_with(':') { + if in_fast { + break; + } + in_fast = line.starts_with("arr.fast."); + } else if in_fast && !line.trim().is_empty() { + instructions.push(line.trim()); + } + } + assert!(!instructions.is_empty(), "numeric fast route absent:\n{ir}"); + assert!( + instructions[0].contains(" = phi i64 ") + && instructions[0].contains("%arr.guard.numeric_in_bounds.") + && instructions[0].contains("%arr.guard.cold."), + "the admitted inline/cold handles must merge before any ordinary instruction: {instructions:?}" + ); + assert!( + instructions + .iter() + .skip(1) + .any(|line| line.contains("@PERRY_STORE_CENSUS")), + "the fast route's census must actually be emitted: {instructions:?}" + ); +} + /// The ordered block labels of ONE dynamic element-read site, with the /// per-site numeric suffix stripped. fn dynamic_index_site_blocks(ir: &str) -> Vec { diff --git a/crates/perry-codegen/src/expr/method_site.rs b/crates/perry-codegen/src/expr/method_site.rs index 35b369b254..6539a1e1c4 100644 --- a/crates/perry-codegen/src/expr/method_site.rs +++ b/crates/perry-codegen/src/expr/method_site.rs @@ -91,6 +91,7 @@ pub(crate) fn emit_method_site( let spill_offset = crate::runtime_abi::OBJECT_META_SPILL_OFFSET.to_string(); let array_header = crate::runtime_abi::ARRAY_HEADER_SIZE.to_string(); let index_mask = crate::runtime_abi::METHOD_SITE_INDEX_MASK.to_string(); + let constfn_bit = crate::runtime_abi::METHOD_SITE_CONSTFN.to_string(); let entry_size = crate::runtime_abi::METHOD_SITE_ENTRY_SIZE; let header = crate::target_layout::object_header_size_bytes(ctx.target_triple) as i64; // `ClosureHeader` (64-bit only here): the info pointer, and the GcHeader @@ -112,6 +113,7 @@ pub(crate) fn emit_method_site( let kind_idx = ctx.new_block("msite.kind"); let own_idx = ctx.new_block("msite.own"); let own_fn_idx = ctx.new_block("msite.own_fn"); + let constfn_idx = ctx.new_block("msite.constfn"); let value_idx = ctx.new_block("msite.value"); let inh_idx = ctx.new_block("msite.inherited"); let inh_value_idx = ctx.new_block("msite.inherited_value"); @@ -122,6 +124,7 @@ pub(crate) fn emit_method_site( let kind_l = ctx.block_label(kind_idx); let own_l = ctx.block_label(own_idx); let own_fn_l = ctx.block_label(own_fn_idx); + let constfn_l = ctx.block_label(constfn_idx); let value_l = ctx.block_label(value_idx); let inh_l = ctx.block_label(inh_idx); let inh_value_l = ctx.block_label(inh_value_idx); @@ -209,8 +212,8 @@ pub(crate) fn emit_method_site( ctx.current_block = idx; } } - // kind: an own inline slot (top two bits clear), else inherited (bit 63) - // or an own spill slot (bit 62). + // kind: an own inline slot when bits 59..63 are clear; otherwise route + // inherited, spill, function-bag and ConstFn tags explicitly. ctx.current_block = kind_idx; let entry = { let incoming: Vec<(&str, &str)> = found @@ -222,6 +225,7 @@ pub(crate) fn emit_method_site( let other_idx = ctx.new_block("msite.other"); let other2_idx = ctx.new_block("msite.other2"); let other3_idx = ctx.new_block("msite.other3"); + let other4_idx = ctx.new_block("msite.other4"); let bag_idx = ctx.new_block("msite.fn_bag"); let bag2_idx = ctx.new_block("msite.fn_bag_load"); let spill_idx = ctx.new_block("msite.spill"); @@ -231,6 +235,7 @@ pub(crate) fn emit_method_site( let other_l = ctx.block_label(other_idx); let other2_l = ctx.block_label(other2_idx); let other3_l = ctx.block_label(other3_idx); + let other4_l = ctx.block_label(other4_idx); let bag_l = ctx.block_label(bag_idx); let bag2_l = ctx.block_label(bag2_idx); let spill_l = ctx.block_label(spill_idx); @@ -241,13 +246,13 @@ pub(crate) fn emit_method_site( let blk = ctx.block(); let sp = blk.gep(crate::types::I8, &entry, &[(I64, &abi_slot)]); let s = blk.load(I64, &sp); - let top = blk.lshr(I64, &s, "61"); + let top = blk.lshr(I64, &s, "59"); let tagged = blk.icmp_ne(I64, &top, "0"); blk.cond_br(&tagged, &other_l, &own_l); s }; - // other: inherited (bit 63), own spill (bit 62) or function bag (bit 61); - // any other kind bit is not one this site knows, and misses. + // other: inherited (bit 63), own spill (bit 62), function bag (bit 61) + // or ConstFn (bit 59). Bit 60 is reserved; unknown tags miss. ctx.current_block = other_idx; { let blk = ctx.block(); @@ -268,7 +273,16 @@ pub(crate) fn emit_method_site( let blk = ctx.block(); let bag_bit = blk.lshr(I64, &slot, "61"); let is_bag = blk.icmp_ne(I64, &bag_bit, "0"); - blk.cond_br(&is_bag, &bag_l, &miss_l); + blk.cond_br(&is_bag, &bag_l, &other4_l); + } + ctx.current_block = other4_idx; + { + let blk = ctx.block(); + // Admit exactly bit 59. Bit 60 is reserved and must never turn an + // unknown tagged entry into an unchecked ConstFn call. + let tag = blk.lshr(I64, &slot, "59"); + let is_constfn = blk.icmp_eq(I64, &tag, "1"); + blk.cond_br(&is_constfn, &own_l, &miss_l); } // function bag: the receiver's own-property object, then its inline slot. // The keyed Function ShapeId the word matched is canonical per that @@ -298,12 +312,29 @@ pub(crate) fn emit_method_site( let (inline_v, inline_end) = { let blk = ctx.block(); let base = emit_field_ptr(blk, &biased, header); - let vp = blk.gep(I64, &base, &[(I64, &slot)]); + let own_index = blk.and(I64, &slot, &index_mask); + let vp = blk.gep(I64, &base, &[(I64, &own_index)]); let v = blk.load(I64, &vp); let end = blk.label.clone(); - blk.br(&value_l); + let bit = blk.and(I64, &slot, &constfn_bit); + let is_constfn = blk.icmp_ne(I64, &bit, "0"); + blk.cond_br(&is_constfn, &constfn_l, &value_l); (v, end) }; + // ConstFn: the shape compare proves the current slot is a closure of this + // body's info. Load that closure for its captures; no heap-kind or info + // load occurs on this hit path. + ctx.current_block = constfn_idx; + let (constfn_handle, constfn_func, constfn_end) = { + let blk = ctx.block(); + let ub = blk.sub(I64, &inline_v, &(RECEIVER_BIAS as i64).to_string()); + let h = emit_handle(blk, &ub); + let fp = blk.gep(crate::types::I8, &entry, &[(I64, &abi_code)]); + let f = blk.load(I64, &fp); + let end = blk.label.clone(); + blk.br(&call_l); + (h, f, end) + }; // own spill: meta -> spill buffer -> element, bounds-checked. ctx.current_block = spill_idx; let meta = { @@ -390,7 +421,7 @@ pub(crate) fn emit_method_site( u }; ctx.current_block = own_fn_idx; - let (own_handle, own_func, _own_end) = { + let (own_handle, own_func, own_end) = { let blk = ctx.block(); let kp = emit_field_ptr(blk, &own_ub, kind_offset); let kind = blk.load(crate::types::I16, &kp); @@ -443,7 +474,14 @@ pub(crate) fn emit_method_site( }; // call: the body directly, with the receiver as its `this` parameter. ctx.current_block = call_idx; - let fptr = ctx.block().inttoptr(I64, &own_func); + let handle = ctx.block().phi( + I64, + &[(&own_handle, &own_end), (&constfn_handle, &constfn_end)], + ); + let func = ctx + .block() + .phi(I64, &[(&own_func, &own_end), (&constfn_func, &constfn_end)]); + let fptr = ctx.block().inttoptr(I64, &func); let mut call_args: Vec = lowered_args.to_vec(); // Pad with `undefined` up to the arity the prime admits, so a body that // declares a few more parameters than this call passes is entered @@ -457,7 +495,7 @@ pub(crate) fn emit_method_site( let hit_value = crate::expr::body_call::emit_js_body_call( ctx.block(), crate::expr::body_call::JsBody::Pointer(&fptr), - &own_handle, + &handle, &recv_bits, &call_args, ); diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 8a1f82b929..50aad6a0e3 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -1089,19 +1089,6 @@ pub(crate) struct FnCtx<'a> { /// removed the moment the local leaves that scope. pub suppressed_cleared_shadow_slots: std::collections::HashSet, - /// #5093: scoped loop-versioning facts for monomorphic class-field loops. - /// Pushed only around the FAST clone of `lower_class_field_versioned_for` - /// (`stmt/loops.rs`): the loop preheader already proved the receiver's - /// exact class shape (class_id, keys identity, field_count, typed-layout - /// intact bit, not-frozen, inline-guard enable flag), and the matcher - /// proved the fast body is call-free (no allocation ⇒ no GC ⇒ the cached - /// `obj_ptr` cannot move and the shape cannot change mid-loop). Inside - /// that clone, `recv.field` GET/SET on a tracked raw-f64 field lowers to - /// a bare GEP+load/store on `obj_ptr` with no guard and no fallback call; - /// SET keeps an inline plain-finite-number check that side-exits to the - /// slow clone's preheader BEFORE committing any side effect of the - /// current iteration. - pub class_field_loop_facts: Vec, /// Step 4b (#10884): loop / body regions whose body is not lowered yet /// (`stmt::region_loop`), and the facts active while an F-body lowers. pub region_loops: Vec, @@ -2154,30 +2141,6 @@ pub(crate) struct StringWindowArrayFact { pub max_idx_exclusive: i64, } -/// #5093: one fact per (receiver, versioned loop). See -/// `FnCtx::class_field_loop_facts` for the safety argument. -#[derive(Debug, Clone)] -pub(crate) struct ClassFieldLoopFact { - /// LocalId of the loop-invariant receiver (plain local or module global). - pub recv_local_id: u32, - pub scope_id: u32, - /// Class the preheader check proved exactly (by class_id compare). - pub class_name: String, - /// SSA name of the receiver object pointer, `inttoptr`'d in the - /// preheader's deref block. Dominates every block of the fast clone and - /// is stable for the clone's whole lifetime because the fast body is - /// call-free (no allocation ⇒ no GC ⇒ no evacuation). - pub obj_ptr: String, - /// Slow clone's preheader label. A raw-f64 store whose value fails the - /// inline plain-finite check branches here; the slow clone re-executes - /// the current iteration from scratch (no side effect has committed yet). - pub side_exit_label: String, - /// property name -> packed slot index. Every entry is a declared raw-f64 - /// candidate field validated by the matcher via - /// `class_field_global_index` / `class_field_declared_type`. - pub fields: std::collections::BTreeMap, -} - /// #10123: where the fast clone's element index comes from. /// /// The class-keyed arm admits [`Self::Counter`] only — the preheader's @@ -2501,23 +2464,6 @@ pub(crate) fn element_shape_loop_fact_for_property_get<'f>( } } -/// Find the innermost active class-field loop fact covering -/// `(recv_local_id, class_name, property)`. Returns the fact and the packed -/// slot index of the field. -pub(crate) fn class_field_loop_fact_lookup<'f>( - facts: &'f [ClassFieldLoopFact], - recv_local_id: u32, - class_name: &str, - property: &str, -) -> Option<(&'f ClassFieldLoopFact, u32)> { - facts.iter().rev().find_map(|fact| { - if fact.recv_local_id != recv_local_id || fact.class_name != class_name { - return None; - } - fact.fields.get(property).map(|idx| (fact, *idx)) - }) -} - /// Build a linker-unique inline-cache global name. /// /// `ic_site_counter` is only module-wide. LLVM codegen-unit splitting can diff --git a/crates/perry-codegen/src/expr/object_literal.rs b/crates/perry-codegen/src/expr/object_literal.rs index 66aa693eaf..dc9849f1e1 100644 --- a/crates/perry-codegen/src/expr/object_literal.rs +++ b/crates/perry-codegen/src/expr/object_literal.rs @@ -387,7 +387,9 @@ pub(crate) fn lower_object_literal( ); } } - Ok(nanbox_pointer_inline(ctx.block(), obj_handle)) + let final_handle = + crate::codegen::static_constfn::finalize_literal(ctx, props, 0, obj_handle); + Ok(nanbox_pointer_inline(ctx.block(), &final_handle)) }, ); } diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index c0f4ffaf45..41645ac524 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -1491,24 +1491,6 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { ctx.class_ids.get(&class_name), ctx.class_keys_globals.get(&class_name).cloned(), ) { - // #5093 loop versioning: inside the fast clone of a - // class-field versioned loop, a tracked field read on - // the proven receiver lowers to a bare slot load on - // the preheader-cached object pointer — no shape - // check, no guard call, no fallback (the preheader - // proved the shape once and the call-free clone keeps - // it true; see stmt/loops.rs). - let loop_fact_ptr = match object.as_ref() { - Expr::LocalGet(recv_id) => crate::expr::class_field_loop_fact_lookup( - &ctx.class_field_loop_facts, - *recv_id, - &class_name, - property, - ) - .filter(|(_, loop_idx)| *loop_idx == field_index) - .map(|(fact, _)| fact.obj_ptr.clone()), - _ => None, - }; // Representation-selection Phase 3b: shape-proven // Ptr local (collectors/ptr_shape.rs). The // guard diamond is statically proven away — emit the @@ -1591,54 +1573,6 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { ); return Ok(val); } - if let Some(obj_ptr) = loop_fact_ptr { - let field_idx_str = field_index.to_string(); - let header_skip = - crate::target_layout::object_header_size_bytes(ctx.target_triple) - .to_string(); - let blk = ctx.block(); - let fields_base = blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); - let field_ptr = blk.gep(DOUBLE, &fields_base, &[(I64, &field_idx_str)]); - let val = blk.load(DOUBLE, &field_ptr); - let fast = LoweredValue { - semantic: SemanticKind::JsNumber, - rep: NativeRep::F64, - llvm_ty: DOUBLE, - value: val.clone(), - }; - ctx.record_lowered_value_with_access_mode_and_facts( - "ClassFieldGet", - None, - "class_field_get.loop_raw_f64_load", - &fast, - Some(BoundsState::Guarded { - guard_id: "class_field_loop_preheader_check".to_string(), - }), - None, - Some(BufferAccessMode::CheckedNative), - None, - None, - None, - vec![raw_f64_layout_fact( - None, - "consumed", - "class_field_loop_preheader_check", - None, - )], - Vec::new(), - false, - false, - vec![ - format!("class={}", class_name), - format!("field={}", property), - format!("field_index={}", field_idx_str), - "receiver_proof=loop_preheader_shape_check".to_string(), - "field_layout=raw_f64_slot_array".to_string(), - "loop_versioning=class_field_fast_clone".to_string(), - ], - ); - return Ok(val); - } let requires_raw_f64 = crate::expr::class_field_inline_guard::class_field_site_raw_f64( ctx, diff --git a/crates/perry-codegen/src/expr/property_get/front_contract_tests.rs b/crates/perry-codegen/src/expr/property_get/front_contract_tests.rs new file mode 100644 index 0000000000..9cf4706637 --- /dev/null +++ b/crates/perry-codegen/src/expr/property_get/front_contract_tests.rs @@ -0,0 +1,365 @@ +//! The miss front includes target-specific directory lookup blocks. Follow +//! their CFG and the call operand rather than assuming a single block or ELF. + +use super::{tower_block, tower_blocks, tower_cond_br}; +type Blocks = [(String, Vec)]; + +fn targets(body: &[String]) -> Vec<&str> { + body.last() + .into_iter() + .flat_map(|line| line.split("label %").skip(1)) + .map(|label| label.trim_end_matches([',', ' '])) + .collect() +} + +fn predecessors<'a>(blocks: &'a Blocks, label: &str) -> Vec<&'a str> { + blocks + .iter() + .filter(|(_, body)| targets(body).contains(&label)) + .map(|(name, _)| name.as_str()) + .collect() +} + +fn verify_front_flow(blocks: &Blocks) -> Result { + let calls: Vec<_> = blocks + .iter() + .enumerate() + .filter(|(_, (_, body))| { + body.iter() + .any(|line| line.contains("call double @js_object_get_field_ic_front(")) + }) + .collect(); + let [(index, (call_label, call_body))] = calls.as_slice() else { + return Err(format!("expected exactly one front call: {calls:?}")); + }; + let (entry, _) = tower_block(blocks, "pic.miss.front"); + let (token, token_body) = tower_block(blocks, "pic.token"); + if predecessors(blocks, entry) != [token] || tower_cond_br(token_body).2 != entry { + return Err("the front entry must be dominated by the token compare".into()); + } + // Every branch between the token miss and the front call resolves the + // directory; both lookup failures still call the front using the empty + // directory. No path may escape to a collecting exit or bypass the call. + if call_label != entry { + let (tsd, _) = tower_block(blocks, "agent_ptr.hot_tls.tsd"); + let (fast, _) = tower_block(blocks, "agent_ptr.hot_tls.fast"); + let (slow, _) = tower_block(blocks, "agent_ptr.hot_tls.slow"); + let (join, _) = tower_block(blocks, "agent_ptr.join"); + if call_label != join { + return Err(format!( + "the directory lookup must join at the front call: {call_label}" + )); + } + for (label, expected_targets, expected_preds) in [ + (entry, vec![tsd, slow], vec![token]), + (tsd, vec![fast, slow], vec![entry]), + (fast, vec![join], vec![tsd]), + (slow, vec![join], vec![entry, tsd]), + ] { + let body = &blocks.iter().find(|(l, _)| l == label).unwrap().1; + if targets(body) != expected_targets || predecessors(blocks, label) != expected_preds { + return Err(format!("directory CFG changed at {label}: {body:?}")); + } + if body + .iter() + .any(|line| line.contains("@js_object_get_field")) + { + return Err(format!( + "directory lookup must not call a property exit: {body:?}" + )); + } + } + let lookup_blocks: Vec<_> = blocks + .iter() + .filter(|(l, _)| l.starts_with("agent_ptr.")) + .map(|(l, _)| l.as_str()) + .collect(); + if lookup_blocks != [tsd, fast, slow, join] { + return Err(format!( + "directory lookup must keep exactly four blocks: {lookup_blocks:?}" + )); + } + if predecessors(blocks, join) != [fast, slow] { + return Err( + "the front call must have only the two directory lookup predecessors".into(), + ); + } + } + if !call_body + .last() + .is_some_and(|line| line.starts_with("br i1 %")) + { + return Err("front decline must use a live conditional branch".into()); + } + let (cond, served, declined) = tower_cond_br(call_body); + let call = call_body + .iter() + .find(|line| line.contains("call double @js_object_get_field_ic_front(")) + .unwrap(); + let answer = call.split_once(" = ").unwrap().0; + let bits = call_body + .iter() + .find_map(|line| { + let (reg, rhs) = line.split_once(" = ")?; + (rhs == format!("bitcast double {answer} to i64")).then_some(reg) + }) + .ok_or("the decline must classify the front's own answer")?; + if !call_body.iter().any(|line| { + line == &format!( + "{cond} = icmp ne i64 {bits}, {}", + crate::nanbox::TAG_HOLE_I64 + ) + }) || !served.starts_with("pget.recv_merge.") + || !declined.starts_with("pic.miss.call.") + { + return Err(format!( + "the front must branch on its answer's TAG_HOLE decline: {call_body:?}" + )); + } + let (_, merge) = tower_block(blocks, "pget.recv_merge"); + if !merge.iter().any(|line| { + line.contains(" = phi double ") && line.contains(&format!("[ {answer}, %{call_label} ]")) + }) { + return Err( + "the merge must take the served answer from the actual front call block".into(), + ); + } + if predecessors(blocks, &declined) + .iter() + .any(|p| *p != call_label && !p.starts_with("pget.recv_")) + { + return Err( + "the slow call is reached only from the front decline or receiver failure".into(), + ); + } + Ok(*index) +} + +pub(super) fn front_call_block(blocks: &Blocks) -> (&str, &[String]) { + let index = verify_front_flow(blocks).unwrap_or_else(|e| panic!("{e}: {blocks:?}")); + (&blocks[index].0, &blocks[index].1) +} + +fn def<'a>(blocks: &'a Blocks, reg: &str) -> Result<&'a str, String> { + blocks + .iter() + .flat_map(|(_, body)| body) + .find_map(|line| { + let (lhs, rhs) = line.split_once(" = ")?; + (lhs == reg).then_some(rhs) + }) + .ok_or_else(|| format!("no definition of {reg} in the tower function")) +} + +/// Prove the front's first operand is the directory read, including Apple's +/// guarded phi and Windows' TEB-derived block. Nearby unrelated TLS text is +/// insufficient: every step must define the operand passed to the call. +pub(super) fn verify_front_directory(blocks: &Blocks) -> Result<(), String> { + let index = verify_front_flow(blocks)?; + let call = blocks[index] + .1 + .iter() + .find(|line| line.contains("call double @js_object_get_field_ic_front(")) + .unwrap(); + let dir = call + .split_once("(ptr ") + .unwrap() + .1 + .split(',') + .next() + .unwrap(); + let mut value = dir; + let rhs = def(blocks, value)?; + if let Some(phi) = rhs.strip_prefix("phi ptr [ ") { + let (fast_value, rest) = phi.split_once(", %").ok_or("directory phi's fast value")?; + let (fast, slow) = rest + .split_once(" ], [ ") + .ok_or("directory phi's two incoming edges")?; + let (slow_label, _) = tower_block(blocks, "agent_ptr.hot_tls.slow"); + let (fast_label, _) = tower_block(blocks, "agent_ptr.hot_tls.fast"); + if fast != fast_label || slow != format!("@PERRY_EMPTY_SHAPE_DIR, %{slow_label} ]") { + return Err(format!( + "directory phi must select the slot or empty fallback: {rhs}" + )); + } + value = fast_value; + } + let rhs = def(blocks, value)?; + if rhs == "call ptr @perry_shape_dir_cell()" { + return Ok(()); + } + let slot = rhs + .strip_prefix("load ptr, ptr ") + .ok_or("directory must be a pointer load")?; + let at = def(blocks, slot)?; + let block = at + .strip_prefix("getelementptr i8, ptr ") + .and_then(|s| s.strip_suffix(", i64 0")) + .ok_or_else(|| format!("directory must come from agent pointer slot zero: {at}"))?; + if block == "@PERRY_AGENT_PTRS" { + return Ok(()); + } + let rhs = def(blocks, block)?; + if let Some(field) = rhs.strip_prefix("load ptr, ptr ") { + let field = def(blocks, field)?; + if !field.starts_with("getelementptr i8, ptr %") + || !field.ends_with(&format!( + ", i64 {}", + crate::runtime_abi::HOT_TLS_AGENT_PTRS_OFFSET + )) + { + return Err(format!( + "Apple directory must use HotTls.agent_ptrs: {field}" + )); + } + let hot = field + .strip_prefix("getelementptr i8, ptr ") + .unwrap() + .split(',') + .next() + .unwrap(); + let slot = def(blocks, hot)? + .strip_prefix("load ptr, ptr ") + .ok_or("Apple TSD slot load")?; + let addr = def(blocks, slot)? + .strip_prefix("inttoptr i64 ") + .and_then(|s| s.strip_suffix(" to ptr")) + .ok_or("Apple TSD slot address")?; + let (base, offset) = def(blocks, addr)? + .strip_prefix("add i64 ") + .and_then(|s| s.split_once(", ")) + .ok_or("Apple TSD index")?; + let tsd = def(blocks, base)? + .strip_prefix("and i64 ") + .and_then(|s| s.strip_suffix(", -8")) + .ok_or("Apple TSD base mask")?; + if def(blocks, tsd)? != "call i64 asm sideeffect \"mrs $0, tpidrro_el0\", \"=r\"()" { + return Err("Apple directory must derive from the current thread pointer".into()); + } + let key = def(blocks, offset)? + .strip_prefix("shl i64 ") + .and_then(|s| s.strip_suffix(", 3")) + .ok_or("Apple TSD key offset")?; + if def(blocks, key)? != "load atomic i64, ptr @PERRY_HOT_TSD_KEY monotonic, align 8" { + return Err("Apple directory must use the published TSD key".into()); + } + for (prefix, predicate) in [ + ("pic.miss.front", format!("icmp ne i64 {key}, -1")), + ("agent_ptr.hot_tls.tsd", format!("icmp ne ptr {hot}, null")), + ] { + let (_, body) = tower_block(blocks, prefix); + if !body.last().is_some_and(|l| l.starts_with("br i1 %")) { + return Err(format!( + "Apple lookup must consult its live {prefix} predicate" + )); + } + let cond = tower_cond_br(body).0; + if def(blocks, &cond)? != predicate { + return Err(format!("wrong Apple lookup guard in {prefix}")); + } + } + return Ok(()); + } + // Windows: TLS array -> image's indexed TLS block -> SECREL offset. + let (image, offset) = rhs + .strip_prefix("getelementptr i8, ptr ") + .and_then(|s| s.split_once(", i64 ")) + .ok_or("Windows agent block address")?; + let offset = def(blocks, offset)? + .strip_prefix("zext i32 ") + .and_then(|s| s.strip_suffix(" to i64")) + .ok_or("SECREL extension")?; + if def(blocks, offset)? != "load i32, ptr @PERRY_AGENT_PTRS_SECREL" { + return Err("wrong Windows SECREL".into()); + } + let entry = def(blocks, image)? + .strip_prefix("load ptr, ptr ") + .ok_or("Windows image TLS block load")?; + let (array, index) = def(blocks, entry)? + .strip_prefix("getelementptr ptr, ptr ") + .and_then(|s| s.split_once(", i64 ")) + .ok_or("Windows TLS image entry")?; + let index = def(blocks, index)? + .strip_prefix("zext i32 ") + .and_then(|s| s.strip_suffix(" to i64")) + .ok_or("TLS index extension")?; + if def(blocks, index)? != "load i32, ptr @_tls_index" + || def(blocks, array)? + != "load ptr, ptr addrspace(256) inttoptr (i64 88 to ptr addrspace(256)), align 8" + { + return Err( + "Windows directory must derive from this thread's TEB and image TLS index".into(), + ); + } + Ok(()) +} + +#[test] +fn front_contract_rejects_lookup_bypasses_wrong_slots_and_wrong_declines() { + for target in [ + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu", + "x86_64-pc-windows-msvc", + ] { + let mut opts = super::ir_opts(false, None); + opts.target = Some(target.into()); + let ir = String::from_utf8( + crate::compile_module(&super::module_with_nullish_read(), opts).unwrap(), + ) + .unwrap(); + let blocks = tower_blocks(&ir); + let index = verify_front_flow(&blocks).unwrap(); + verify_front_directory(&blocks).unwrap(); + let mut wrong = blocks.clone(); + let cond = tower_cond_br(&wrong[index].1).0; + let term = wrong[index].1.last_mut().unwrap(); + *term = term.replacen(&cond, "true", 1); + assert!( + verify_front_flow(&wrong).is_err(), + "{target}: hardwired decline" + ); + let mut wrong = blocks.clone(); + for (_, body) in &mut wrong { + for line in body { + if line.contains("getelementptr i8, ptr ") && line.ends_with(", i64 0") { + *line = line.strip_suffix(", i64 0").unwrap().to_string() + ", i64 8"; + } + } + } + assert_ne!(wrong, blocks, "{target}: slot sabotage must alter IR"); + assert!( + verify_front_directory(&wrong).is_err(), + "{target}: wrong agent slot" + ); + let mut wrong = blocks.clone(); + let call = wrong[index] + .1 + .iter_mut() + .find(|l| l.contains("call double @js_object_get_field_ic_front(")) + .unwrap(); + let operand = call + .split_once("(ptr ") + .unwrap() + .1 + .split(',') + .next() + .unwrap() + .to_string(); + *call = call.replacen( + &format!("(ptr {operand},"), + "(ptr @PERRY_EMPTY_SHAPE_DIR,", + 1, + ); + assert_ne!(wrong, blocks, "{target}: operand sabotage must alter IR"); + assert!( + verify_front_directory(&wrong).is_err(), + "{target}: disconnected directory operand" + ); + let mut wrong = blocks.clone(); + let (entry, _) = tower_block(&blocks, "pic.miss.front"); + let (_, _, slow) = tower_cond_br(&blocks[index].1); + let body = &mut wrong.iter_mut().find(|(l, _)| l == entry).unwrap().1; + *body.last_mut().unwrap() = format!("br label %{slow}"); + assert!(verify_front_flow(&wrong).is_err(), "{target}: front bypass"); + } +} diff --git a/crates/perry-codegen/src/expr/property_get/helpers.rs b/crates/perry-codegen/src/expr/property_get/helpers.rs index eee5b2514c..b0638acc95 100644 --- a/crates/perry-codegen/src/expr/property_get/helpers.rs +++ b/crates/perry-codegen/src/expr/property_get/helpers.rs @@ -199,6 +199,17 @@ pub(crate) fn lower_raw_f64_class_field_get_for_number_context( return Ok(None); }; + // A typed class read can enter this helper before property_get::lower. + // Consume the same exact fresh R fact there, rather than emitting an + // ordinary class guard inside F (whose fallback can run JS and retire F). + // Without R, the bare word may contain a box: number context must keep + // the existing guarded/coercing path below. + if crate::stmt::region_loop::is_f64_read(ctx, expr) { + if let Some(value) = crate::stmt::region_loop::try_lower_bare_get(ctx, expr)? { + return Ok(Some(value)); + } + } + // Scalar-replaced objects do not have a valid heap receiver. The general // property-get lowering handles this, but native-f64 numeric contexts query // raw class-field lowering first. Keep allocation-elided objects on their @@ -488,70 +499,6 @@ pub(crate) fn lower_raw_f64_class_field_get_for_number_context( return Ok(None); }; - // #5093 loop versioning: inside the fast clone of a class-field versioned - // loop, a tracked number-context field read on the proven receiver lowers - // to a bare slot load on the preheader-cached object pointer — no shape - // check, no guard call, no fallback (see stmt/loops.rs). Mirrors the hook - // in the generic class-field GET diamond (property_get.rs). - let loop_fact_ptr = match object.as_ref() { - Expr::LocalGet(recv_id) => crate::expr::class_field_loop_fact_lookup( - &ctx.class_field_loop_facts, - *recv_id, - &class_name, - property, - ) - .filter(|(_, loop_idx)| *loop_idx == field_index) - .map(|(fact, _)| fact.obj_ptr.clone()), - _ => None, - }; - if let Some(obj_ptr) = loop_fact_ptr { - let field_idx_str = field_index.to_string(); - let header_skip = - crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); - let blk = ctx.block(); - let fields_base = blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); - let field_ptr = blk.gep(DOUBLE, &fields_base, &[(I64, &field_idx_str)]); - let val = blk.load(DOUBLE, &field_ptr); - let fast = LoweredValue { - semantic: SemanticKind::JsNumber, - rep: NativeRep::F64, - llvm_ty: DOUBLE, - value: val.clone(), - }; - ctx.record_lowered_value_with_access_mode_and_facts( - "ClassFieldGet", - None, - "class_field_get_number.loop_raw_f64_load", - &fast, - Some(BoundsState::Guarded { - guard_id: "class_field_loop_preheader_check".to_string(), - }), - None, - Some(BufferAccessMode::CheckedNative), - None, - None, - None, - vec![raw_f64_layout_fact( - None, - "consumed", - "class_field_loop_preheader_check", - None, - )], - Vec::new(), - false, - false, - vec![ - format!("class={}", class_name), - format!("field={}", property), - format!("field_index={}", field_idx_str), - "receiver_proof=loop_preheader_shape_check".to_string(), - "field_layout=raw_f64_slot_array".to_string(), - "loop_versioning=class_field_fast_clone".to_string(), - ], - ); - return Ok(Some(val)); - } - // Representation-selection Phase 3b: shape-proven Ptr receiver // whose field is numeric-proven (every reachable store is a number) — // bare fixed-offset load, no guard diamond. The numeric proof is what diff --git a/crates/perry-codegen/src/expr/property_get/tests.rs b/crates/perry-codegen/src/expr/property_get/tests.rs index a17cae655e..6f5c9520c6 100644 --- a/crates/perry-codegen/src/expr/property_get/tests.rs +++ b/crates/perry-codegen/src/expr/property_get/tests.rs @@ -14,6 +14,10 @@ use crate::{compile_module, AppMetadata, CompileOptions}; use perry_hir::{Expr, Module, ModuleInitKind, Stmt}; +#[path = "front_contract_tests.rs"] +mod front_contract; +use front_contract::{front_call_block, verify_front_directory}; + fn ir_opts(debug_locations: bool, module_source: Option<&str>) -> CompileOptions { CompileOptions { static_shape_ids: Vec::new(), @@ -21,6 +25,7 @@ fn ir_opts(debug_locations: bool, module_source: Option<&str>) -> CompileOptions target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), @@ -570,7 +575,7 @@ fn generic_property_get_tries_ways_before_calling_the_miss_handler() { on_miss.starts_with("pic.miss.front"), "the compare's miss edge must reach the front (the ways) first: {token:?}" ); - let (front_label, front) = tower_block(&blocks, "pic.miss.front"); + let (front_label, front) = front_call_block(&blocks); assert!( front .iter() @@ -707,7 +712,7 @@ fn a_spill_entry_is_served_by_the_leaf_front_before_the_slow_call() { use crate::expr::property_get::generic_dispatch::PACKED_SPILL_FLIP; let ir = emit(false, None); let blocks = tower_blocks(&ir); - let (front_label, front) = tower_block(&blocks, "pic.miss.front"); + let (front_label, front) = front_call_block(&blocks); let call = front .iter() .find(|l| l.contains("@js_object_get_field_ic_front(")) @@ -1176,11 +1181,9 @@ fn generic_property_get_slot_load_is_reached_only_through_every_guard() { !blocks.iter().any(|(l, _)| l.starts_with("pic.way")), "no way block may be expanded per site:\n{func}" ); - let front_body = blocks - .iter() - .find(|(l, _)| l.starts_with("pic.miss.front")) - .map(|(_, body)| body.join("\n")) - .expect("the miss front block"); + let front_blocks = tower_blocks(&ir); + let (_, front) = front_call_block(&front_blocks); + let front_body = front.join("\n"); let term = front_body .lines() .rev() @@ -1505,6 +1508,9 @@ fn the_front_reads_its_directory_without_a_call_where_the_target_allows() { .split("\ndefine ") .find(|f| f.contains("\npic.miss.front")) .unwrap_or_else(|| panic!("{target}: no function contains the front:\n{ir}")); + let blocks = tower_blocks(&ir); + front_call_block(&blocks); + verify_front_directory(&blocks).unwrap_or_else(|e| panic!("{target}: {e}\n{func}")); let dir_call = func.contains("call ptr @perry_shape_dir_cell("); match inline_form { Some(form) => { @@ -1637,18 +1643,15 @@ fn the_generic_tower_is_one_leaf_call_two_exits_and_a_bounded_number_of_blocks() fronts[0].contains(" = call double "), "the front is nounwind, a plain call:\n{func}" ); - // A non-`length` site confirms from this agent's own directory: the dir - // operand is slot 0 of `PERRY_AGENT_PTRS` (one initial-exec load in this - // ELF executable), never the empty directory a `length` site passes. + // A non-`length` site confirms from this agent's own directory: slot 0 + // of the target's per-agent block, or the empty directory when Apple's + // direct TLS lookup is unavailable. Follow the actual call operand. assert!( !fronts[0].contains("@PERRY_EMPTY_SHAPE_DIR"), "only a `length` site passes the empty directory:\n{}", fronts[0] ); - assert!( - func.contains("getelementptr i8, ptr @PERRY_AGENT_PTRS, i64 0"), - "the dir operand is PERRY_AGENT_PTRS slot 0:\n{func}" - ); + verify_front_directory(&tower_blocks(&ir)).unwrap_or_else(|e| panic!("{e}\n{func}")); let blocks: Vec<&str> = func .lines() @@ -1769,7 +1772,7 @@ fn the_generic_slow_read_is_called_only_after_the_front_declines() { slot and the packed word:\n{slow_line}" ); // 3. - let (_, front) = tower_block(&blocks, "pic.miss.front"); + let (_, front) = front_call_block(&blocks); let (cond, served, declined) = tower_cond_br(front); assert!( front diff --git a/crates/perry-codegen/src/expr/property_set.rs b/crates/perry-codegen/src/expr/property_set.rs index 4afc4ea904..aa8f7a455f 100644 --- a/crates/perry-codegen/src/expr/property_set.rs +++ b/crates/perry-codegen/src/expr/property_set.rs @@ -986,126 +986,6 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, assignment_strict: bool) - field_index, ); let requires_raw_f64_str = if requires_raw_f64 { "1" } else { "0" }; - // #5093 loop versioning: inside the fast clone of a - // class-field versioned loop, a tracked raw-f64 field - // store on the proven receiver lowers to an inline - // plain-finite value check + bare slot store on the - // preheader-cached object pointer. A value that is - // not a plain finite double (±Inf/NaN, or any NaN-box - // tag — including INT32-boxed integers) side-exits to - // the slow clone's preheader BEFORE the store, so the - // slow clone re-executes the whole iteration and - // routes the value through the runtime guard exactly - // as today (downgrade semantics preserved). - if requires_raw_f64 { - let loop_fact = - match object.as_ref() { - Expr::LocalGet(recv_id) => { - crate::expr::class_field_loop_fact_lookup( - &ctx.class_field_loop_facts, - *recv_id, - &class_name, - property, - ) - .filter(|(_, loop_idx)| *loop_idx == field_index) - .map(|(fact, _)| { - ( - fact.obj_ptr.clone(), - fact.side_exit_label.clone(), - ) - }) - } - _ => None, - }; - if let Some((obj_ptr, side_exit_label)) = loop_fact { - let field_idx_str = field_index.to_string(); - let store_idx = - ctx.new_block("class_field_loop_store.fast"); - let store_label = ctx.block_label(store_idx); - { - let blk = ctx.block(); - let val_bits = blk.bitcast_double_to_i64(&val_double); - let finite = crate::expr::class_field_inline_guard:: - emit_plain_finite_number_check(blk, &val_bits); - blk.cond_br(&finite, &store_label, &side_exit_label); - } - ctx.current_block = store_idx; - super::store_census::bump( - ctx, - super::store_census::CFIELD_LOOP_RAW, - ); - { - let header_skip = - crate::target_layout::object_header_size_bytes( - ctx.target_triple, - ) - .to_string(); - let blk = ctx.block(); - let fields_base = - blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); - let field_ptr = blk.gep( - DOUBLE, - &fields_base, - &[(I64, &field_idx_str)], - ); - // No raw-f64 canonicalization call is needed: - // INT32-boxed and NaN values — the only - // inputs `js_array_numeric_value_to_raw_f64` - // rewrites — cannot pass the finite check. - // - // GC_STORE_AUDIT(POINTER_FREE): the inline - // finite check proved `val_double` is a - // genuine (unboxed, finite) double, never a - // heap pointer — no edge, no write barrier. - blk.store(DOUBLE, &val_double, &field_ptr); - } - let stored = LoweredValue { - semantic: SemanticKind::JsNumber, - rep: NativeRep::F64, - llvm_ty: DOUBLE, - value: val_double.clone(), - }; - ctx.record_lowered_value_with_access_mode_and_facts( - "ClassFieldSet", - None, - "class_field_set.loop_raw_f64_store", - &stored, - Some(BoundsState::Guarded { - guard_id: "class_field_loop_preheader_check" - .to_string(), - }), - None, - Some(BufferAccessMode::CheckedNative), - None, - None, - None, - vec![raw_f64_layout_fact( - None, - "consumed", - "class_field_loop_preheader_check", - None, - )], - Vec::new(), - false, - false, - vec![ - format!("class={}", class_name), - format!("field={}", property), - format!("field_index={}", field_idx_str), - "receiver_proof=loop_preheader_shape_check" - .to_string(), - "field_layout=raw_f64_slot_array".to_string(), - "loop_versioning=class_field_fast_clone" - .to_string(), - "rhs_numeric_guard=inline_plain_finite_check" - .to_string(), - "store_guard_failure=side_exit_slow_restart" - .to_string(), - ], - ); - return Ok(val_double); - } - } // Representation-selection Phase 3b: shape-proven // Ptr receiver (collectors/ptr_shape.rs) — no // guard call, no shape diamond. Raw-f64 slots keep the @@ -1127,6 +1007,15 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, assignment_strict: bool) - .ptr_shape_receiver_fact(object.as_ref()) .map(|fact| fact.class_name == class_name) .unwrap_or(false); + // A contained offset proof may carry completed + // ConstFn facts. Writing that boxed slot must + // deprecate/restamp through the checked funnel. + let ptr_shape_proven = ptr_shape_proven + && (requires_raw_f64 + || !crate::codegen::slot_may_be_constfn( + &keys_global_name, + field_index, + )); if ptr_shape_proven { ctx.note_ptr_shape_consumed(object.as_ref(), "ptr_shape_set"); super::store_census::bump( @@ -1332,18 +1221,15 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, assignment_strict: bool) - ); return Ok(val_double); } - // #5093: build the guard operands once, up front, so both - // the inline shape pre-check and the guard-call fallback - // can reference them. - let (obj_bits, obj_handle, key_raw, val_bits) = { + // #5093: build only the noncollecting precheck operands here. + // The guard and fallback materialize fresh key handles + // and consume rooted receiver/value snapshots below. + let (obj_bits, obj_handle, val_bits) = { let blk = ctx.block(); let obj_bits = blk.bitcast_double_to_i64(&recv_box); let obj_handle = blk.and(I64, &obj_bits, POINTER_MASK_I64); - let key_box = blk.load(DOUBLE, &key_handle_global); - let key_bits = blk.bitcast_double_to_i64(&key_box); - let key_raw = blk.and(I64, &key_bits, POINTER_MASK_I64); let val_bits = blk.bitcast_double_to_i64(&val_double); - (obj_bits, obj_handle, key_raw, val_bits) + (obj_bits, obj_handle, val_bits) }; let fast_idx = ctx.new_block("class_field_set.fast"); let fallback_idx = ctx.new_block("class_field_set.fallback"); @@ -1354,7 +1240,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, assignment_strict: bool) - // #5093: inline shape pre-check. On a hit this branches // straight to the store, skipping the call; on a miss the - // guard-call path below runs unchanged. + // guard-call path below adopts the evaluated operands into roots. // // #7854: this used to be gated on `requires_raw_f64`, // leaving every BOXED declared field (`string`, a class @@ -1370,7 +1256,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, assignment_strict: bool) - // taken the boxed inline precheck since #7288: the write // barrier, layout note and string demote come from // `emit_jsvalue_slot_store_pointer_tested` (which the - // shared `fast_label` block below calls, with the very + // common store emitter below calls, with the very // same value-side predicates), NOT from the guard; and a // setter in the chain is already refused upstream by // `class_field_global_index`'s `accessor_in_chain`. @@ -1416,154 +1302,144 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, assignment_strict: bool) - &keys_global_name, field_index, ); - super::store_census::bump(ctx, super::store_census::CFIELD_IC_CALL); - let guard_ok = ctx.block().call( - I32, - "js_typed_feedback_class_field_set_guard", - &[ - (I64, &site_id), - (DOUBLE, &recv_box), - (I32, &expected_class_id_str), - (I32, &expected_shape_id), - (I64, &key_raw), - (I32, &field_idx_str), - (DOUBLE, &val_double), - (I32, requires_raw_f64_str), - ], - ); - let guard_pass = ctx.block().icmp_ne(I32, &guard_ok, "0"); - ctx.block() - .cond_br(&guard_pass, &fast_label, &fallback_label); - - ctx.current_block = fast_idx; - super::store_census::bump( - ctx, - super::store_census::CFIELD_GUARD_STORE, - ); - // #5334 lever D: a value that is a non-pointer by - // construction (number / bool / undefined / null / - // comparison / arithmetic) creates no parent→child heap - // reference, so the generational write barrier is a - // semantic no-op and can be skipped. Computed before the - // block builder is borrowed below. The LAYOUT NOTE is - // kept regardless: it records the slot's pointer-ness for - // minor-scan skipping, and a non-pointer write into a - // slot that previously held a pointer is a real - // transition the GC must observe. Same soundness standard - // as the array-store barrier elision. - let field_set_barrier_needed = - !expr_produces_non_pointer_bits_by_construction(ctx, value); - // #7469: value-side elision of the addref and layout - // note on the guarded arm — computed here because the - // predicates take `&FnCtx` and the block builder is - // borrowed below. - let guarded_addref_needed = - class_field_store_needs_string_addref(ctx, value); - let raw_stored_value = { - // arm64_32 watchOS: the object fields region begins at - // `size_of::()` past the user pointer — 16 on - // both LP64 and ILP32 since #8047. A hardcoded offset writes - // class fields to the wrong word when the header changes; the paired inline read - // (`property_get`) and the runtime setter must agree, so - // derive it from the target triple (no-op on 64-bit; see - // `target_layout`). - let header_skip = - crate::target_layout::object_header_size_bytes( - ctx.target_triple, - ) - .to_string(); - let field_ptr = { - let blk = ctx.block(); - let obj_ptr = blk.inttoptr(I64, &obj_handle); - let fields_base = - blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); - blk.gep(DOUBLE, &fields_base, &[(I64, &field_idx_str)]) - }; - let raw_stored_value = if requires_raw_f64 { - // Guarded raw-f64 slots are pointer-free by typed - // shape descriptor; non-number writes miss the - // guard and use the boxed setter fallback. - // #10907: canonicalize only off the - // plain-finite path. - // - // GC_STORE_AUDIT(POINTER_FREE): typed raw-f64 class - // slots contain numbers only. - emit_raw_f64_class_field_slot_store( - ctx, - value, - &val_double, - &field_ptr, - ); - Some(val_double.clone()) - } else { - // #5334 lever D: skip the barrier when the value - // is a non-pointer by construction. #7469 extends - // the same value-expression gating to the addref - // and layout note — the Phase 4b.1 predicates are - // value-side-only proofs (see their docs: safe in - // every layout state the receiver can be in), so - // they apply on this guarded arm exactly as on - // the ptr-shape-proven arm above. The guard - // passing does not change what the VALUE can be; - // `requires_raw_f64` is false here, which is the - // precondition `class_field_store_needs_layout_note` - // documents. - // - // #7511: this is the arm the shared - // `_constructor` symbol lands on, where the - // value is an opaque function parameter and lever D - // can never fire. Whatever survives it is decided by - // ONE live test of the stored bits instead of three - // cross-crate calls that each re-ask the same - // question — see - // `emit_jsvalue_slot_store_pointer_tested`. - let field_addr = ctx.block().ptrtoint(&field_ptr, I64); - emit_jsvalue_slot_store_pointer_tested( + let guardcall_idx = ctx.current_block; + // Keep the inline hit free of root traffic. The collecting + // guard has a separate store diamond using refreshed operands. + let emit_guarded_store = + |ctx: &mut FnCtx<'_>, + obj_bits: &str, + obj_handle: &str, + val_double: &str| { + super::store_census::bump( ctx, - &field_ptr, - &val_double, - &obj_handle, - guarded_addref_needed, - &obj_bits, - &field_addr, - field_set_barrier_needed, - "class_field_set", + super::store_census::CFIELD_GUARD_STORE, ); - None - }; - ctx.block().br(&merge_label); - raw_stored_value - }; - if let Some(numeric_value) = raw_stored_value { - let stored = LoweredValue { - semantic: SemanticKind::JsNumber, - rep: NativeRep::F64, - llvm_ty: DOUBLE, - value: numeric_value.clone(), - }; - ctx.record_lowered_value_with_access_mode_and_facts( - "ClassFieldSet", - None, - "class_field_set.raw_f64_store", - &stored, - Some(BoundsState::Guarded { - guard_id: "class_field_set_guard".to_string(), - }), - None, - Some(BufferAccessMode::CheckedNative), - None, - None, - None, - vec![raw_f64_layout_fact( - None, - "consumed", - "class_field_set_guard", - None, - )], - Vec::new(), - false, - false, - vec![ + // #5334 lever D: a value that is a non-pointer by + // construction (number / bool / undefined / null / + // comparison / arithmetic) creates no parent→child heap + // reference, so the generational write barrier is a + // semantic no-op and can be skipped. Computed before the + // block builder is borrowed below. The LAYOUT NOTE is + // kept regardless: it records the slot's pointer-ness for + // minor-scan skipping, and a non-pointer write into a + // slot that previously held a pointer is a real + // transition the GC must observe. Same soundness standard + // as the array-store barrier elision. + let field_set_barrier_needed = + !expr_produces_non_pointer_bits_by_construction( + ctx, value, + ); + // #7469: value-side elision of the addref and layout + // note on the guarded arm — computed here because the + // predicates take `&FnCtx` and the block builder is + // borrowed below. + let guarded_addref_needed = + class_field_store_needs_string_addref(ctx, value); + let raw_stored_value = { + // arm64_32 watchOS: the object fields region begins at + // `size_of::()` past the user pointer — 16 on + // both LP64 and ILP32 since #8047. A hardcoded offset writes + // class fields to the wrong word when the header changes; the paired inline read + // (`property_get`) and the runtime setter must agree, so + // derive it from the target triple (no-op on 64-bit; see + // `target_layout`). + let header_skip = + crate::target_layout::object_header_size_bytes( + ctx.target_triple, + ) + .to_string(); + let field_ptr = { + let blk = ctx.block(); + let obj_ptr = blk.inttoptr(I64, obj_handle); + let fields_base = + blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); + blk.gep( + DOUBLE, + &fields_base, + &[(I64, &field_idx_str)], + ) + }; + if requires_raw_f64 { + // Guarded raw-f64 slots are pointer-free by typed + // shape descriptor; non-number writes miss the + // guard and use the boxed setter fallback. + // #10907: canonicalize only off the + // plain-finite path. + // + // GC_STORE_AUDIT(POINTER_FREE): typed raw-f64 class + // slots contain numbers only. + emit_raw_f64_class_field_slot_store( + ctx, value, val_double, &field_ptr, + ); + Some(val_double.to_string()) + } else { + // #5334 lever D: skip the barrier when the value + // is a non-pointer by construction. #7469 extends + // the same value-expression gating to the addref + // and layout note — the Phase 4b.1 predicates are + // value-side-only proofs (see their docs: safe in + // every layout state the receiver can be in), so + // they apply on this guarded arm exactly as on + // the ptr-shape-proven arm above. The guard + // passing does not change what the VALUE can be; + // `requires_raw_f64` is false here, which is the + // precondition `class_field_store_needs_layout_note` + // documents. + // + // #7511: this is the arm the shared + // `_constructor` symbol lands on, where the + // value is an opaque function parameter and lever D + // can never fire. Whatever survives it is decided by + // ONE live test of the stored bits instead of three + // cross-crate calls that each re-ask the same + // question — see + // `emit_jsvalue_slot_store_pointer_tested`. + let field_addr = + ctx.block().ptrtoint(&field_ptr, I64); + emit_jsvalue_slot_store_pointer_tested( + ctx, + &field_ptr, + val_double, + obj_handle, + guarded_addref_needed, + obj_bits, + &field_addr, + field_set_barrier_needed, + "class_field_set", + ); + None + } + }; + if let Some(numeric_value) = raw_stored_value { + let stored = LoweredValue { + semantic: SemanticKind::JsNumber, + rep: NativeRep::F64, + llvm_ty: DOUBLE, + value: numeric_value.clone(), + }; + ctx.record_lowered_value_with_access_mode_and_facts( + "ClassFieldSet", + None, + "class_field_set.raw_f64_store", + &stored, + Some(BoundsState::Guarded { + guard_id: "class_field_set_guard".to_string(), + }), + None, + Some(BufferAccessMode::CheckedNative), + None, + None, + None, + vec![raw_f64_layout_fact( + None, + "consumed", + "class_field_set_guard", + None, + )], + Vec::new(), + false, + false, + vec![ format!("class={}", class_name), format!("class_id={}", expected_class_id_str), format!("field={}", property), @@ -1573,19 +1449,19 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, assignment_strict: bool) - "field_layout=raw_f64_slot_array".to_string(), "pointer_bitmap=non_pointer".to_string(), ], - ); - ctx.record_lowered_value_with_access_mode( - "WriteBarrierElided", - None, - "write_barrier.elided_raw_f64_class_field", - &stored, - None, - None, - None, - None, - false, - false, - vec![ + ); + ctx.record_lowered_value_with_access_mode( + "WriteBarrierElided", + None, + "write_barrier.elided_raw_f64_class_field", + &stored, + None, + None, + None, + None, + false, + false, + vec![ "reason=raw_f64_class_field_pointer_free".to_string(), format!("class={}", class_name), format!("class_id={}", expected_class_id_str), @@ -1596,79 +1472,164 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, assignment_strict: bool) - "field_layout=raw_f64_slot_array".to_string(), "pointer_bitmap=non_pointer".to_string(), ], - ); - } + ); + } + }; - ctx.current_block = fallback_idx; - super::store_census::bump( - ctx, - super::store_census::CFIELD_GUARD_FALLBACK, - ); - let blk = ctx.block(); - // #5334 lever A: the guard already ran and FAILED in the - // entry block, so this cold arm is a pure guard-miss - // fallback. Outline the two operations it used to emit - // inline (record_fallback + by-name set) into ONE - // `js_class_field_set_fallback` call. Semantics are - // byte-identical; only the emitted IR shrinks (cold path - // → zero hot-loop cost). `obj_bits` keeps the full - // NaN-box tag; `key_raw` is POINTER_MASK-stripped — the - // same operands the two calls received. - blk.call_void( - "js_class_field_set_fallback", - &[ - (I64, &site_id), - (I64, &obj_bits), - (I64, &key_raw), - (DOUBLE, &val_double), - ], - ); - blk.br(&merge_label); - if requires_raw_f64 { - let fallback = LoweredValue { - semantic: SemanticKind::JsValue, - rep: NativeRep::JsValue, - llvm_ty: DOUBLE, - value: val_double.clone(), + ctx.current_block = fast_idx; + emit_guarded_store(ctx, &obj_bits, &obj_handle, &val_double); + let fast_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = guardcall_idx; + let cold_val = rooting::with_rooted_group(ctx, 2, |ctx, group| { + let receiver = group.adopt_emitted( + ctx, + rooting::Repr::Boxed, + &recv_box, + true, + ); + let rhs = group.adopt_emitted( + ctx, + rooting::Repr::Boxed, + &val_double, + true, + ); + let cold_fast_idx = ctx.new_block("class_field_set.cold_fast"); + let cold_merge_idx = + ctx.new_block("class_field_set.cold_merge"); + let cold_fast_label = ctx.block_label(cold_fast_idx); + let cold_merge_label = ctx.block_label(cold_merge_idx); + let guard_receiver = group.reread_emitted(ctx, receiver); + let guard_rhs = group.reread_emitted(ctx, rhs); + let key_raw = { + let blk = ctx.block(); + let key_box = blk.load(DOUBLE, &key_handle_global); + let key_bits = blk.bitcast_double_to_i64(&key_box); + blk.and(I64, &key_bits, POINTER_MASK_I64) }; - ctx.record_lowered_value_with_access_mode_and_facts( - "ClassFieldSet", - None, - "js_object_set_field_by_name", - &fallback, - Some(BoundsState::Unknown), - None, - Some(BufferAccessMode::DynamicFallback), - Some(MaterializationReason::RuntimeApi), - None, - None, - Vec::new(), - vec![ - raw_f64_layout_fact( - None, - "rejected", - "class_field_set_guard", - Some(MaterializationReason::RuntimeApi), - ), - raw_f64_layout_fact( - None, - "invalidated", - "runtime_api", - Some(MaterializationReason::RuntimeApi), - ), + super::store_census::bump( + ctx, + super::store_census::CFIELD_IC_CALL, + ); + let guard_ok = ctx.block().call( + I32, + "js_typed_feedback_class_field_set_guard", + &[ + (I64, &site_id), + (DOUBLE, &guard_receiver), + (I32, &expected_class_id_str), + (I32, &expected_shape_id), + (I64, &key_raw), + (I32, &field_idx_str), + (DOUBLE, &guard_rhs), + (I32, requires_raw_f64_str), ], - false, - false, - vec![ - format!("class={}", class_name), - format!("field={}", property), - format!("field_index={}", field_idx_str), + ); + let guard_pass = ctx.block().icmp_ne(I32, &guard_ok, "0"); + ctx.block().cond_br( + &guard_pass, + &cold_fast_label, + &fallback_label, + ); + + ctx.current_block = cold_fast_idx; + let cold_receiver = group.reread_emitted(ctx, receiver); + let cold_rhs = group.reread_emitted(ctx, rhs); + let cold_bits = + ctx.block().bitcast_double_to_i64(&cold_receiver); + let cold_handle = + ctx.block().and(I64, &cold_bits, POINTER_MASK_I64); + emit_guarded_store(ctx, &cold_bits, &cold_handle, &cold_rhs); + ctx.block().br(&cold_merge_label); + + ctx.current_block = fallback_idx; + super::store_census::bump( + ctx, + super::store_census::CFIELD_GUARD_FALLBACK, + ); + let fallback_receiver = group.reread_emitted(ctx, receiver); + let fallback_rhs = group.reread_emitted(ctx, rhs); + let blk = ctx.block(); + let fallback_bits = + blk.bitcast_double_to_i64(&fallback_receiver); + let key_box = blk.load(DOUBLE, &key_handle_global); + let key_bits = blk.bitcast_double_to_i64(&key_box); + let fallback_key = blk.and(I64, &key_bits, POINTER_MASK_I64); + // #5334 lever A: the guard already ran and FAILED in the + // entry block, so this cold arm is a pure guard-miss + // fallback. Outline the two operations it used to emit + // inline (record_fallback + by-name set) into ONE + // `js_class_field_set_fallback` call. Semantics are + // byte-identical; only the emitted IR shrinks (cold path + // → zero hot-loop cost). The refreshed receiver retains its + // NaN-box tag and the freshly-loaded key is mask-stripped. + blk.call_void( + "js_class_field_set_fallback", + &[ + (I64, &site_id), + (I64, &fallback_bits), + (I64, &fallback_key), + (DOUBLE, &fallback_rhs), ], ); - } + blk.br(&cold_merge_label); + if requires_raw_f64 { + let fallback = LoweredValue { + semantic: SemanticKind::JsValue, + rep: NativeRep::JsValue, + llvm_ty: DOUBLE, + value: fallback_rhs.clone(), + }; + ctx.record_lowered_value_with_access_mode_and_facts( + "ClassFieldSet", + None, + "js_object_set_field_by_name", + &fallback, + Some(BoundsState::Unknown), + None, + Some(BufferAccessMode::DynamicFallback), + Some(MaterializationReason::RuntimeApi), + None, + None, + Vec::new(), + vec![ + raw_f64_layout_fact( + None, + "rejected", + "class_field_set_guard", + Some(MaterializationReason::RuntimeApi), + ), + raw_f64_layout_fact( + None, + "invalidated", + "runtime_api", + Some(MaterializationReason::RuntimeApi), + ), + ], + false, + false, + vec![ + format!("class={}", class_name), + format!("field={}", property), + format!("field_index={}", field_idx_str), + ], + ); + } + + ctx.current_block = cold_merge_idx; + // A fallback setter can collect again. The assignment returns + // the saved RHS, even if user code overwrote its source binding. + Ok(group.reread_emitted(ctx, rhs)) + })?; + let cold_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); ctx.current_block = merge_idx; - Ok(val_double) + Ok(ctx.block().phi( + DOUBLE, + &[(&val_double, &fast_end), (&cold_val, &cold_end)], + )) }, ); } @@ -1701,3 +1662,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, assignment_strict: bool) - _ => unreachable!("expr/mod.rs dispatched a variant not handled by this submodule"), } } + +#[cfg(test)] +#[path = "collecting_root_tests.rs"] +mod collecting_root_tests; diff --git a/crates/perry-codegen/src/expr/property_set/sloppy_class_field.rs b/crates/perry-codegen/src/expr/property_set/sloppy_class_field.rs index eeb9f5bdac..b5d53b2a58 100644 --- a/crates/perry-codegen/src/expr/property_set/sloppy_class_field.rs +++ b/crates/perry-codegen/src/expr/property_set/sloppy_class_field.rs @@ -98,64 +98,6 @@ pub(crate) fn try_lower_sloppy_class_field_store( // through the unchanged direct path. Its own collection predicate keeps // a compound receiver with an inert RHS byte-identical too. with_class_store_operands(ctx, object, value, |ctx, recv_box, val_double| { - // #7287: inside the fast clone of a #5093 class-field versioned loop, this - // store is covered by the preheader's hoisted shape check — emit the same - // inline plain-finite check + bare slot store the STRICT arm emits (see - // `lower`'s class-field arm), instead of the per-access diamond. - // - // Sound in sloppy mode for the same reason #7423 made the fast arm - // mode-independent: the preheader proved not-frozen, no per-receiver - // descriptors, matching class id and keys token, and an intact typed - // layout, and the loop's body is call-free so none of that can change while - // the clone runs. A store that reaches the raw slot could not have been - // *rejected* in either mode, so there is no sloppy/strict divergence to - // preserve. Everything else — a non-finite or NaN-boxed value — side-exits - // to the slow clone BEFORE storing, and the slow clone re-executes the whole - // iteration through this unchanged sloppy lowering. - if let Expr::LocalGet(recv_id) = object { - if let Some((fact, _)) = crate::expr::class_field_loop_fact_lookup( - &ctx.class_field_loop_facts, - *recv_id, - &class_name, - property, - ) - .filter(|(_, loop_idx)| *loop_idx == field_index) - { - let obj_ptr = fact.obj_ptr.clone(); - let side_exit_label = fact.side_exit_label.clone(); - let store_idx = ctx.new_block("class_field_loop_store.sloppy_fast"); - let store_label = ctx.block_label(store_idx); - { - let blk = ctx.block(); - let val_bits = blk.bitcast_double_to_i64(&val_double); - let finite = - crate::expr::class_field_inline_guard::emit_plain_finite_number_check( - blk, &val_bits, - ); - blk.cond_br(&finite, &store_label, &side_exit_label); - } - ctx.current_block = store_idx; - { - let header_skip = - crate::target_layout::object_header_size_bytes(ctx.target_triple) - .to_string(); - let blk = ctx.block(); - let fields_base = blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); - let field_ptr = - blk.gep(DOUBLE, &fields_base, &[(I64, &field_index.to_string())]); - // No `js_array_numeric_value_to_raw_f64` canonicalization is - // needed: INT32-boxed and NaN values — the only inputs it - // rewrites — cannot pass the finite check above. - // - // GC_STORE_AUDIT(POINTER_FREE): the finite check proved - // `val_double` is a genuine unboxed double, never a heap - // pointer — no edge, no write barrier. - blk.store(DOUBLE, &val_double, &field_ptr); - } - return Ok(Some(val_double)); - } - } - let key_idx = ctx.strings.intern(property); let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); let field_idx_str = field_index.to_string(); diff --git a/crates/perry-codegen/src/expr/receiver_range.rs b/crates/perry-codegen/src/expr/receiver_range.rs index 9e58ab1ae7..36db635a79 100644 --- a/crates/perry-codegen/src/expr/receiver_range.rs +++ b/crates/perry-codegen/src/expr/receiver_range.rs @@ -130,6 +130,9 @@ pub(crate) enum Route { /// A region guard's STATIC supplier matched (DESIGN §4.1): the receiver /// carries the driver's static id, so the region ran with no word. RloopStatic = 28, + /// One F-body iteration whose exact chosen supplier guarantees at least + /// one F64 region key (P7 acceptance census). + RloopFRep = 34, } /// `PERRY_RECV_ROUTE_COUNT=1` at COMPILE time: emit one diff --git a/crates/perry-codegen/src/expr/region_loop_tests.rs b/crates/perry-codegen/src/expr/region_loop_tests.rs index 8793dba439..8048ecb59e 100644 --- a/crates/perry-codegen/src/expr/region_loop_tests.rs +++ b/crates/perry-codegen/src/expr/region_loop_tests.rs @@ -64,7 +64,11 @@ fn put(key: &str, value: Expr) -> Stmt { } /// `function probe(o, v, n) { let h = 0; for (let i = 0; i < n; i++) { body } return h; }` -fn loop_ir(name: &str, body: Vec) -> String { +fn loop_ir_with_return(name: &str, body: Vec, result: Expr) -> String { + loop_ir_with_bound(name, body, result, Expr::LocalGet(N)) +} + +fn loop_ir_with_bound(name: &str, body: Vec, result: Expr, bound: Expr) -> String { let mut m = Module::new(name); m.functions = vec![Function { id: 1, @@ -91,7 +95,7 @@ fn loop_ir(name: &str, body: Vec) -> String { condition: Some(Expr::Compare { op: CompareOp::Lt, left: Box::new(Expr::LocalGet(I)), - right: Box::new(Expr::LocalGet(N)), + right: Box::new(bound), }), update: Some(Expr::Update { id: I, @@ -100,7 +104,7 @@ fn loop_ir(name: &str, body: Vec) -> String { }), body, }, - Stmt::Return(Some(Expr::LocalGet(H))), + Stmt::Return(Some(result)), ], is_async: false, is_generator: false, @@ -115,6 +119,10 @@ fn loop_ir(name: &str, body: Vec) -> String { String::from_utf8(compile_module(&m, opts()).expect("module compiles")).expect("UTF-8 IR") } +fn loop_ir(name: &str, body: Vec) -> String { + loop_ir_with_return(name, body, Expr::LocalGet(H)) +} + /// The blocks of the probe function, label -> (instructions, successors). fn blocks(ir: &str) -> HashMap, Vec)> { let mut out = HashMap::new(); @@ -342,15 +350,15 @@ fn a_region_that_stores_every_key_it_names_has_no_spill_copy() { ); } -/// The prime call's last argument: the boxed-store mask (charter step 5). +/// The prime call's penultimate argument: the boxed-store mask (charter step 5). fn prime_boxed_masks(ir: &str) -> Vec { ir.lines() .filter(|l| l.contains("@js_region_loop_prime(")) .filter_map(|l| { - // The call can carry trailing attributes after its closing parenthesis. - let call = l.split_once(')')?.0; - let last_arg = call.rsplit_once("i32 ")?.1; - last_arg.trim().parse().ok() + // The R mask follows the boxed-store mask; the call may carry + // trailing LLVM attributes after its closing parenthesis. + let (before_r, _) = l.rsplit_once(", i32 ")?; + before_r.rsplit_once("i32 ")?.1.trim().parse().ok() }) .collect() } @@ -389,3 +397,420 @@ fn a_bare_store_of_a_value_not_proven_a_double_names_its_key_to_the_prime() { "a literal double is a valid value of every lane: {masks:?}" ); } + +/// Last prime argument, before LLVM call attributes, is the requested region R mask. +fn prime_rep_masks(ir: &str) -> Vec { + ir.lines() + .filter(|line| line.contains("@js_region_loop_prime(")) + .filter_map(|line| { + line.rsplit_once("i32 ")? + .1 + .split_once(')')? + .0 + .trim() + .parse() + .ok() + }) + .collect() +} + +/// P8: the generic region must carry the class-field increment shape after +/// its older numeric loop tier is retired. The store is bare only when its +/// own exact read is protected by R; a string store cannot clear the boxed +/// mask even when a later read requests R. +#[test] +fn a_region_r_proven_increment_store_clears_only_its_number_boxed_bit() { + let increment = Expr::Binary { + op: BinaryOp::Add, + left: Box::new(get("x")), + right: Box::new(Expr::Integer(1)), + }; + let numeric = loop_ir("region_store_r_number", vec![put("x", increment)]); + let numeric_masks = prime_boxed_masks(&numeric); + assert!( + numeric.contains("rloop.fast"), + "numeric region did not form:\n{numeric}" + ); + assert!( + !numeric_masks.is_empty() && numeric_masks.iter().all(|&m| m == 0), + "R-proven Number store must clear the boxed bit: {numeric_masks:?}\n{numeric}" + ); + let numeric_r = prime_rep_masks(&numeric); + assert!( + !numeric_r.is_empty() && numeric_r.iter().all(|&m| m == 1), + "increment must actually request F64 for its exact read: {numeric_r:?}\n{numeric}" + ); + + let non_number = loop_ir( + "region_store_r_string", + vec![ + put("x", Expr::String("bad".into())), + Stmt::Expr(Expr::LocalSet( + H, + Box::new(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(get("x")), + right: Box::new(Expr::Integer(1)), + }), + )), + ], + ); + let non_number_masks = prime_boxed_masks(&non_number); + assert!( + !non_number_masks.is_empty() && non_number_masks.iter().all(|&m| m == 1), + "string store must retain the boxed bit: {non_number_masks:?}\n{non_number}" + ); +} + +/// A fresh bare read used by a Number-consuming add requests an F64 lane. +/// The prime must refuse an Any receiver, so this is an actual R-bearing +/// region rather than a vacuous mask argument. +#[test] +fn a_number_consuming_bare_read_sets_the_prime_rep_mask() { + let ir = loop_ir( + "region_loop_rep", + vec![Stmt::Expr(Expr::LocalSet( + H, + Box::new(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(H)), + right: Box::new(get("x")), + }), + ))], + ); + assert!(ir.contains("rloop.fast"), "region did not form:\n{ir}"); + let masks = prime_rep_masks(&ir); + assert!(!masks.is_empty(), "no learned prime in\n{ir}"); + assert!( + masks.iter().all(|&m| m == 1), + "fresh x read must request key 0: {masks:?}" + ); +} + +/// The F-local fixed point follows the fresh F64 read through a temporary and +/// a loop-carried accumulator. Entry is strict; G and post-loop code retain +/// the ordinary dynamic add. Removing the scoped materialization or the +/// entry check makes this test fail. +#[test] +fn a_region_number_local_is_admitted_only_in_f() { + const TEMP: u32 = 6; + let body = vec![ + Stmt::Let { + id: TEMP, + name: "temp".to_string(), + ty: Type::Any, + mutable: false, + init: Some(get("x")), + }, + Stmt::Expr(Expr::LocalSet( + H, + Box::new(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(H)), + right: Box::new(Expr::LocalGet(TEMP)), + }), + )), + ]; + let ir = loop_ir_with_return( + "region_number_scope", + body, + Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(H)), + right: Box::new(Expr::Integer(1)), + }, + ); + assert!(ir.contains("rloop.fast"), "region did not form:\n{ir}"); + let masks = prime_rep_masks(&ir); + assert!( + !masks.is_empty() && masks.iter().all(|&m| m == 1), + "the temp's source must request R=1: {masks:?}\n{ir}" + ); + assert!( + ir.contains("rloop.fast") && ir.contains("fadd double"), + "F must use numeric add:\n{ir}" + ); + assert!( + ir.contains("rloop.guard") && ir.contains("icmp ult i64"), + "A_F must strictly test the loop-carried accumulator:\n{ir}" + ); + assert!( + ir.lines() + .filter(|line| { + line.contains("call ") && line.contains("@js_dynamic_string_or_number_add(") + }) + .count() + >= 2, + "G and post-loop adds must remain dynamic (no scope leak):\n{ir}" + ); +} + +/// Literal-bound flow through temp must receive the same R/5L proof as direct reads. +#[test] +fn flow_derived_number_local_constant_bound_avoids_recheck() { + const TEMP: u32 = 6; + let body = vec![ + Stmt::Let { + id: TEMP, + name: "temp".to_string(), + ty: Type::Any, + mutable: false, + init: Some(get("x")), + }, + Stmt::Expr(Expr::LocalSet( + H, + Box::new(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(H)), + right: Box::new(Expr::LocalGet(TEMP)), + }), + )), + ]; + let ir = loop_ir_with_bound( + "region_number_scope_constant_bound", + body, + Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(H)), + right: Box::new(Expr::Integer(1)), + }, + Expr::Integer(200), + ); + assert!(ir.contains("rloop.fast"), "region did not form:\n{ir}"); + assert!( + !ir.contains("br i1 true, label %rloop.recheck"), + "constant-bound flow-derived R/5L must avoid an unconditional recheck:\n{ir}" + ); + let bl = blocks(&ir); + let f = f_body_blocks(&bl); + assert!(!f.is_empty(), "F body must be present:\n{ir}"); + for label in f { + let instructions = bl[&label].0.join("\n"); + assert!( + !instructions.contains("@js_object_get_field"), + "{label} must retain the bare R-proven load:\n{instructions}\n{ir}" + ); + } + let masks = prime_rep_masks(&ir); + assert!( + !masks.is_empty() && masks.iter().all(|&m| m == 1), + "the temp's source must request R=1: {masks:?}\n{ir}" + ); + assert!( + ir.contains("rloop.fast") && ir.contains("fadd double"), + "F must use numeric add:\n{ir}" + ); + assert!( + ir.contains("rloop.guard") && ir.contains("icmp ult i64"), + "A_F must strictly test the loop-carried accumulator:\n{ir}" + ); + assert!( + ir.lines() + .filter(|line| { + line.contains("call ") && line.contains("@js_dynamic_string_or_number_add(") + }) + .count() + >= 2, + "G and post-loop adds must remain dynamic (no scope leak):\n{ir}" + ); +} + +/// An unrestricted bound comparison may invoke user code and revoke freshness. +#[test] +fn any_bound_numeric_region_retains_collecting_comparison_recheck() { + const TEMP: u32 = 6; + let body = vec![ + Stmt::Let { + id: TEMP, + name: "temp".to_string(), + ty: Type::Any, + mutable: false, + init: Some(get("x")), + }, + Stmt::Expr(Expr::LocalSet( + H, + Box::new(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(H)), + right: Box::new(Expr::LocalGet(TEMP)), + }), + )), + ]; + let ir = loop_ir_with_return( + "region_number_scope_any_bound", + body, + Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(H)), + right: Box::new(Expr::Integer(1)), + }, + ); + assert!(ir.contains("rloop.fast"), "region did not form:\n{ir}"); + assert!( + ir.contains("@js_rel_lt("), + "Any bound must retain its collecting comparison:\n{ir}" + ); + assert!( + ir.contains("br i1 true, label %rloop.recheck"), + "Any-bound user-code comparison must retain the conservative recheck:\n{ir}" + ); + let masks = prime_rep_masks(&ir); + assert!( + !masks.is_empty() && masks.iter().all(|&m| m == 1), + "the temp's source must request R=1: {masks:?}\n{ir}" + ); + assert!( + ir.contains("rloop.fast") && ir.contains("fadd double"), + "F must use numeric add:\n{ir}" + ); + assert!( + ir.contains("rloop.guard") && ir.contains("icmp ult i64"), + "A_F must strictly test the loop-carried accumulator:\n{ir}" + ); + assert!( + ir.lines() + .filter(|line| { + line.contains("call ") && line.contains("@js_dynamic_string_or_number_add(") + }) + .count() + >= 2, + "G and post-loop adds must remain dynamic (no scope leak):\n{ir}" + ); +} + +fn times(e: Expr, factor: i64) -> Expr { + Expr::Binary { + op: BinaryOp::Mul, + left: Box::new(e), + right: Box::new(Expr::Integer(factor)), + } +} + +fn accumulated(reads: &[&str], factor: i64) -> Stmt { + let mut sum = Expr::LocalGet(H); + for key in reads { + sum = Expr::Binary { + op: BinaryOp::Add, + left: Box::new(sum), + right: Box::new(times(get(key), factor)), + }; + } + Stmt::Expr(Expr::LocalSet(H, Box::new(sum))) +} + +/// An arithmetic wrapper must consume the exact R and 5L facts, regardless +/// of its spelling. Sabotaging either proof restores the unconditional +/// recheck and (for four reads) generic reads after the first one. +#[test] +fn numeric_arithmetic_twins_keep_all_reads_bare_without_a_recheck() { + for keys in [&["a"][..], &["a", "b", "c", "e"][..]] { + for factor in [1, 2] { + let ir = loop_ir_with_bound( + "region_arith_twin", + vec![accumulated(keys, factor)], + Expr::LocalGet(H), + Expr::Integer(200), + ); + let masks = prime_rep_masks(&ir); + let expected = (1u32 << keys.len()) - 1; + assert!( + !masks.is_empty() && masks.iter().all(|m| *m == expected), + "every exact arithmetic read must be R-proven: {masks:?}\n{ir}" + ); + assert!(ir.contains("rloop.fast"), "F did not form:\n{ir}"); + assert!( + !ir.contains("rloop.recheck"), + "numeric arithmetic must not recheck every iteration:\n{ir}" + ); + let bl = blocks(&ir); + let f = f_body_blocks(&bl); + for label in f { + let instructions = bl[&label].0.join("\n"); + assert!( + !instructions.contains("@js_object_get_field"), + "{label} must not use a generic read:\n{instructions}\n{ir}" + ); + } + } + } +} + +/// A property read from another object may run a getter. It must kill the +/// receiver fact even when it is nested under native arithmetic, and a +/// later read of the region receiver must not inherit the earlier proof. +#[test] +fn arithmetic_getter_operand_requires_generic_recheck() { + let getter = Expr::PropertyGet { + object: Box::new(Expr::Call { + callee: Box::new(Expr::LocalGet(V)), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + }), + property: "x".to_string(), + byte_offset: 0, + }; + let body = vec![ + Stmt::Expr(Expr::LocalSet( + H, + Box::new(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(H)), + right: Box::new(Expr::Binary { + op: BinaryOp::Mul, + left: Box::new(get("a")), + right: Box::new(getter), + }), + }), + )), + Stmt::Expr(get("b")), + ]; + let ir = loop_ir_with_bound( + "region_getter_kill", + body, + Expr::LocalGet(H), + Expr::Integer(200), + ); + assert!( + ir.contains("rloop.fast"), + "fixture must admit the first read:\n{ir}" + ); + assert!( + ir.contains("rloop.recheck") && ir.contains("br i1 true, label %rloop.recheck"), + "getter operand must force the back-edge recheck:\n{ir}" + ); + assert!( + prime_rep_masks(&ir).iter().all(|m| m & 0b10 == 0), + "the later b read must not borrow the stale fact:\n{ir}" + ); +} + +/// A call after the final bare read may mutate the receiver or its +/// prototype. The whole F path, not just prefixes of bare reads, is part of +/// the next iteration's freshness proof. +#[test] +fn post_read_mutation_call_forces_next_iteration_recheck() { + let body = vec![ + accumulated(&["a"], 1), + Stmt::Expr(Expr::Call { + callee: Box::new(Expr::LocalGet(V)), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + }), + ]; + let ir = loop_ir_with_bound( + "region_post_read_mutation", + body, + Expr::LocalGet(H), + Expr::Integer(200), + ); + assert!( + ir.contains("rloop.fast"), + "fixture must admit the read:\n{ir}" + ); + assert!( + ir.contains("rloop.recheck") && ir.contains("br i1 true, label %rloop.recheck"), + "a post-read JS call must recheck before the next iteration:\n{ir}" + ); +} diff --git a/crates/perry-codegen/src/expr/store_census.rs b/crates/perry-codegen/src/expr/store_census.rs index 15ac110100..b15e2c5703 100644 --- a/crates/perry-codegen/src/expr/store_census.rs +++ b/crates/perry-codegen/src/expr/store_census.rs @@ -29,8 +29,8 @@ pub(crate) const CFIELD_GUARD_STORE: usize = 5; pub(crate) const CFIELD_GUARD_FALLBACK: usize = 6; /// Class-field store: a `js_class_field_set_ic` call. pub(crate) const CFIELD_IC_CALL: usize = 7; -/// Class-field store: the loop-versioned raw store. -pub(crate) const CFIELD_LOOP_RAW: usize = 8; +// Index 8 remains a zero tombstone for the removed class-loop raw store. +// The runtime's diagnostic array keeps its indices stable across the deletion. /// Class setter dispatch (`__set_`). pub(crate) const CFIELD_SETTER: usize = 9; /// Sloppy-mode class-field store. @@ -61,6 +61,9 @@ pub(crate) const ELEM_STORE_APPEND: usize = 38; pub(crate) const ELEM_STORE_GUARD_MISS: usize = 39; /// Array element store: a runtime set / extend call. pub(crate) const ELEM_STORE_FALLBACK: usize = 40; +/// Array element read through a versioned-indexed loop fact specifically. +/// Unlike OTHER_TIER, no region or trusted-parameter read increments this word. +pub(crate) const ELEM_READ_VERSIONED_INDEXED: usize = 41; /// Array element store into an F64 array of a NaN-boxed value: the cold arm /// that clears the kind (header first) or converts an INT32 box. pub(crate) const ELEM_STORE_F64_COLD: usize = 42; diff --git a/crates/perry-codegen/src/fn_info.rs b/crates/perry-codegen/src/fn_info.rs index fe022e3d78..8d4d70cb38 100644 --- a/crates/perry-codegen/src/fn_info.rs +++ b/crates/perry-codegen/src/fn_info.rs @@ -26,7 +26,8 @@ use std::collections::{BTreeMap, BTreeSet}; use crate::runtime_abi::{ FN_ARROW, FN_ASYNC, FN_ASYNC_GENERATOR, FN_GENERATOR, FN_HAS_DECLARED, FN_HAS_LENGTH, - FN_REST_SYNTHETIC_ARGUMENTS, FN_REST_USER, FN_REST_USER_AND_ARGUMENTS, FN_STRICT, + FN_PERMANENT_IMAGE, FN_REST_SYNTHETIC_ARGUMENTS, FN_REST_USER, FN_REST_USER_AND_ARGUMENTS, + FN_STRICT, }; /// The LLVM type of a `JsFunctionInfo`, field for field (perry-abi's @@ -40,6 +41,11 @@ pub(crate) fn info_symbol(body: &str) -> String { format!("{body}$info") } +/// Shared by closure lowering and the static final-shape pre-pass. +pub(crate) fn closure_body_symbol(module_prefix: &str, func_id: u32) -> String { + format!("perry_closure_{module_prefix}__{func_id}") +} + /// A compiler-private direct-call clone of a body. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct CloneTarget { @@ -133,6 +139,8 @@ pub(crate) struct DefinedBody { pub(crate) struct FnInfoState { requested: BTreeSet, facts: BTreeMap, + /// Bodies whose info address a separate static-seed object will name. + static_seed_bodies: BTreeSet, } impl FnInfoState { @@ -145,6 +153,15 @@ impl FnInfoState { format!("@{}", info_symbol(body)) } + /// Reserve a stable body-info symbol for a future static seed unit. + /// This also requests the info definition. The body remains local; only + /// its info becomes linkable when the definer renders this module. + /// No birth collector calls this until the seed ABI has module-init parity. + pub(crate) fn request_static_seed_body(&mut self, body: &str) -> String { + self.static_seed_bodies.insert(body.to_string()); + self.request(body) + } + /// The facts of a body this module defines. pub(crate) fn facts_mut(&mut self, body: &str) -> &mut FnInfoFacts { self.facts.entry(body.to_string()).or_default() @@ -159,6 +176,7 @@ impl FnInfoState { &self, defined: impl Fn(&str) -> Option, exported: impl IntoIterator, + permanent_image: bool, ) -> Vec { let mut bodies: BTreeSet<&str> = self.requested.iter().map(String::as_str).collect(); bodies.extend(self.facts.keys().map(String::as_str)); @@ -171,6 +189,8 @@ impl FnInfoState { body, &def, self.facts.get(body).cloned().unwrap_or_default(), + permanent_image, + self.static_seed_bodies.contains(body), )), None if self.requested.contains(body) => out.push(format!( "@{} = external constant {}", @@ -186,10 +206,20 @@ impl FnInfoState { } } -fn render_definition(body: &str, def: &DefinedBody, facts: FnInfoFacts) -> String { - let linkage = match def.linkage.as_str() { - "" => String::new(), - other => format!("{other} "), +fn render_definition( + body: &str, + def: &DefinedBody, + facts: FnInfoFacts, + permanent_image: bool, + static_seed: bool, +) -> String { + let linkage = if static_seed { + "hidden ".to_string() + } else { + match def.linkage.as_str() { + "" => String::new(), + other => format!("{other} "), + } }; let clone = |target: &Option| match target { Some(t) => (format!("@{}", t.symbol), t.captures, t.boxed_mask), @@ -205,7 +235,12 @@ fn render_definition(body: &str, def: &DefinedBody, facts: FnInfoFacts) -> Strin ty = INFO_TYPE, params = saturate_u16(def.params as u64), rest = facts.rest_fixed, - flags = facts.flags, + flags = facts.flags + | if permanent_image { + FN_PERMANENT_IMAGE + } else { + 0 + }, length = facts.length, tcap = trusted_captures, tcode = trusted_code, @@ -246,6 +281,7 @@ mod tests { .flatten() }, [], + false, ); assert_eq!( lines, @@ -257,11 +293,58 @@ mod tests { ); } + #[test] + fn only_a_permanent_image_marks_defined_body_infos() { + let mut state = FnInfoState::default(); + state.request("perry_closure_m__3"); + let transient = state.render_globals(|_| defined(0, "internal"), [], false); + let permanent = state.render_globals(|_| defined(0, "internal"), [], true); + assert!(transient[0].contains("i32 0, i32 0")); + assert!(permanent[0].contains(&format!("i32 {FN_PERMANENT_IMAGE}, i32 0"))); + } + + #[test] + fn seed_info_has_linkable_stable_symbol_but_body_keeps_local_linkage() { + let mut state = FnInfoState::default(); + let body = "perry_closure_m__3"; + assert_eq!( + state.request_static_seed_body(body), + format!("@{}", info_symbol(body)) + ); + assert_eq!( + state.request_static_seed_body(body), + format!("@{}", info_symbol(body)) + ); + let lines = state.render_globals(|_| defined(0, "internal"), [], true); + assert_eq!( + lines.len(), + 1, + "one body has one info despite fresh closures" + ); + assert!(lines[0].starts_with(&format!("@{} = hidden constant", info_symbol(body)))); + assert!(lines[0].contains(&format!("ptr @{body}"))); + assert!(lines[0].contains(&format!("i32 {FN_PERMANENT_IMAGE}"))); + } + + #[test] + fn foreign_seed_info_is_only_declared_by_importer() { + let mut state = FnInfoState::default(); + let body = "perry_closure_other__3"; + state.request_static_seed_body(body); + assert_eq!( + state.render_globals(|_| None, [], true), + vec![format!( + "@{} = external constant {INFO_TYPE}", + info_symbol(body) + )] + ); + } + #[test] fn a_foreign_body_is_declared_never_copied() { let mut state = FnInfoState::default(); state.request("__perry_wrap_perry_fn_other__f"); - let lines = state.render_globals(|_| None, []); + let lines = state.render_globals(|_| None, [], false); assert_eq!( lines, vec![format!( @@ -280,6 +363,7 @@ mod tests { .flatten() }, ["__perry_wrap_perry_fn_m__g".to_string()], + false, ); assert_eq!(lines.len(), 1); assert!(lines[0].starts_with(&format!( @@ -296,7 +380,7 @@ mod tests { captures: 2, boxed_mask: 0b10, }); - let lines = state.render_globals(|_| defined(0, "internal"), []); + let lines = state.render_globals(|_| defined(0, "internal"), [], false); assert!( lines[0].contains("i32 2, ptr @perry_closure_m__9$trusted_boxes, i64 2, ptr null"), "{}", diff --git a/crates/perry-codegen/src/gc_call_effects.rs b/crates/perry-codegen/src/gc_call_effects.rs index cc33d01f4c..c7c5234bb8 100644 --- a/crates/perry-codegen/src/gc_call_effects.rs +++ b/crates/perry-codegen/src/gc_call_effects.rs @@ -415,6 +415,24 @@ pub(crate) fn transitive_leaf_functions(functions: &[&LlFunction]) -> HashSet CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/lower_call/method_override.rs b/crates/perry-codegen/src/lower_call/method_override.rs index c3d7d9c68c..ab5d665343 100644 --- a/crates/perry-codegen/src/lower_call/method_override.rs +++ b/crates/perry-codegen/src/lower_call/method_override.rs @@ -305,7 +305,14 @@ pub(crate) fn emit_inline_direct_method_shape_guard( let expected_shape_i64 = blk.zext(I32, expected_shape_id, I64); let expected_shape_high = blk.shl(I64, &expected_shape_i64, "32"); let expected_class_shape = blk.or(I64, &expected_shape_high, expected_class_id); - let class_shape_ok = blk.icmp_eq(I64, &class_shape, &expected_class_shape); + let class_shape_ok = crate::typed_shape::emit_compatible_class_shape_eq( + blk, + &class_shape, + expected_class_id, + expected_shape_id, + &expected_class_shape, + &[], + ); // `is_shape_id` is `[0x8000_0000, 0xC000_0000)`. Subtract the base // modulo i32 and compare with the range length, matching the runtime @@ -477,7 +484,14 @@ fn emit_inline_exact_argument_shape_guard( let expected_shape_high = blk.shl(I64, &expected_shape_i64, "32"); let expected_class_shape = blk.or(I64, &expected_shape_high, &expected_class_id.to_string()); - let class_shape_ok = blk.icmp_eq(I64, &class_shape, &expected_class_shape); + let class_shape_ok = crate::typed_shape::emit_compatible_class_shape_eq( + blk, + &class_shape, + &expected_class_id.to_string(), + expected_shape_id, + &expected_class_shape, + &[], + ); let shape_id_rel = blk.add(I32, expected_shape_id, SHAPE_ID_BASE_NEG_I32); let shape_valid = blk.icmp_ult(I32, &shape_id_rel, SHAPE_ID_RANGE_LEN); let pass = blk.and(I1, &gc_header_ok, &class_shape_ok); @@ -1063,7 +1077,12 @@ pub(super) fn emit_guarded_direct_method_call( let next = sub_test_labels[0].clone(); let blk = ctx.block(); let cid_ok = blk.icmp_eq(I32, &cid, &expected_class_id_str); - let shape_ok = blk.icmp_eq(I32, &shape_id, &expected_shape_id); + let shape_ok = crate::typed_shape::emit_compatible_shape_eq( + blk, + &shape_id, + &expected_shape_id, + &[], + ); let pass = blk.and(I1, &cid_ok, &shape_ok); blk.cond_br(&pass, &fast_label, &next); } @@ -1078,7 +1097,8 @@ pub(super) fn emit_guarded_direct_method_call( let arm_shape_id = subclass_shape_ids[i].clone(); let blk = ctx.block(); let cid_ok = blk.icmp_eq(I32, &cid, &class_id_str); - let shape_ok = blk.icmp_eq(I32, &shape_id, &arm_shape_id); + let shape_ok = + crate::typed_shape::emit_compatible_shape_eq(blk, &shape_id, &arm_shape_id, &[]); let pass = blk.and(I1, &cid_ok, &shape_ok); blk.cond_br(&pass, &case_label, &next); } diff --git a/crates/perry-codegen/src/lower_call/native_module_dispatch.rs b/crates/perry-codegen/src/lower_call/native_module_dispatch.rs index 6a9413c9d0..51bb8f46ea 100644 --- a/crates/perry-codegen/src/lower_call/native_module_dispatch.rs +++ b/crates/perry-codegen/src/lower_call/native_module_dispatch.rs @@ -182,6 +182,25 @@ pub fn lower_native_module_dispatch( arg_types.push(DOUBLE); } + let thread_launch = matches!( + sig.runtime, + "js_thread_spawn" | "js_thread_parallel_map" | "js_thread_parallel_filter" + ); + let runtime = if thread_launch { + let prepare = format!( + "__perry_prepare_thread_strings_{}", + ctx.strings.thread_literal_callback_prefix() + ); + ctx.pending_declares + .push((prepare.clone(), crate::types::VOID, vec![])); + llvm_args.push((I64, format!("ptrtoint (ptr @{} to i64)", prepare))); + arg_types.push(I64); + format!("{}_with_literals", sig.runtime) + } else { + sig.runtime.to_string() + }; + let runtime = runtime.as_str(); + // Determine return type for the declare let ret_type = match sig.ret { NativeRetKind::GcPtr @@ -200,7 +219,7 @@ pub fn lower_native_module_dispatch( }; ctx.pending_declares - .push((sig.runtime.to_string(), ret_type, arg_types)); + .push((runtime.to_string(), ret_type, arg_types)); let arg_slices: Vec<(crate::types::LlvmType, &str)> = llvm_args.iter().map(|(t, s)| (*t, s.as_str())).collect(); @@ -295,7 +314,7 @@ pub fn lower_native_module_dispatch( let raw = blk.call(I64, sig.runtime, &arg_slices); Ok(nanbox_bigint_inline(blk, &raw)) } - NativeRetKind::F64 => Ok(ctx.block().call(DOUBLE, sig.runtime, &arg_slices)), + NativeRetKind::F64 => Ok(ctx.block().call(DOUBLE, runtime, &arg_slices)), NativeRetKind::BoolI1 | NativeRetKind::BoolI32 => { let blk = ctx.block(); let raw = blk.call(ret_type, sig.runtime, &arg_slices); diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index edaead4ff4..cd947c2421 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -235,6 +235,30 @@ fn lower_new_impl( // `new` site that roots anything. let mut group = open_rooted_group(args.len() + 1); let result = lower_new_impl_inner(ctx, class_name, args, cap_args_appended, &mut group); + let result = result.map(|boxed| { + if ctx.block().is_terminated() || !crate::codegen::static_constfn::has_final_shapes() { + return boxed; + } + let candidate = ctx + .classes + .get(class_name) + .and_then(|class| crate::codegen::static_constfn::anon_props(class, args)); + if let Some(props) = candidate { + if let Some(rep) = ctx + .class_keys_globals + .get(class_name) + .and_then(|keys| ctx.class_birth_reps.get(keys)) + .copied() + { + let bits = ctx.block().bitcast_double_to_i64(&boxed); + let handle = ctx.block().and(I64, &bits, crate::nanbox::POINTER_MASK_I64); + let handle = + crate::codegen::static_constfn::finalize_literal(ctx, &props, rep, &handle); + return nanbox_pointer_inline(ctx.block(), &handle); + } + } + crate::codegen::static_constfn::finalize_class(ctx, class_name, &boxed) + }); group.release(ctx); result } @@ -1622,16 +1646,10 @@ fn lower_new_impl_inner<'a>( // Field initializers / an inlined constructor body were lowered // between the instance allocation and here, so refresh again. lowered_args = refresh_rooted_args(ctx, group)?; - let marshalled = marshal_imported_ctor_args(ctx, &ctor, &lowered_args); - let mut ctor_args: Vec<(crate::types::LlvmType, &str)> = - Vec::with_capacity(1 + marshalled.len()); - ctor_args.push((DOUBLE, &obj_box)); + let marshalled = marshal_imported_ctor_args(ctx, &ctor, &lowered_args, group); let ctor_param_types: Vec = std::iter::once(DOUBLE) .chain(marshalled.iter().map(|_| DOUBLE)) .collect(); - for la in &marshalled { - ctor_args.push((DOUBLE, la.as_str())); - } // Walked to an ANCESTOR ctor: its return-override does not replace // the leaf instance, so discard the return value. Declared DOUBLE // to match the symbol's real signature (see codegen/mod.rs). @@ -1653,6 +1671,15 @@ fn lower_new_impl_inner<'a>( None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), }; let nt_save = crate::rooting::new_target_save(ctx, &nt_ref); + // Initializers, argument packing and class-value lookup may + // collect. The rooted this-slot also owns any replacement this. + let ctor_this = ctx.block().load(DOUBLE, &this_slot); + let marshalled: Vec<_> = marshalled + .iter() + .map(|arg| arg.reread(ctx, group)) + .collect(); + let mut ctor_args = vec![(DOUBLE, ctor_this.as_str())]; + ctor_args.extend(marshalled.iter().map(|arg| (DOUBLE, arg.as_str()))); let _ = ctx.block().call(DOUBLE, &ctor.symbol, &ctor_args); crate::rooting::new_target_restore(ctx, &nt_save); } else if let Some(ctor) = ctx.imported_class_ctors.get(class_name).cloned() { @@ -1662,17 +1689,10 @@ fn lower_new_impl_inner<'a>( // Field initializers / an inlined constructor body were lowered // between the instance allocation and here, so refresh again. lowered_args = refresh_rooted_args(ctx, group)?; - let marshalled = marshal_imported_ctor_args(ctx, &ctor, &lowered_args); - // Pass `this` as NaN-boxed double (same as compile_method's this_arg). - let mut ctor_args: Vec<(crate::types::LlvmType, &str)> = - Vec::with_capacity(1 + marshalled.len()); - ctor_args.push((DOUBLE, &obj_box)); + let marshalled = marshal_imported_ctor_args(ctx, &ctor, &lowered_args, group); let ctor_param_types: Vec = std::iter::once(DOUBLE) .chain(marshalled.iter().map(|_| DOUBLE)) .collect(); - for la in &marshalled { - ctor_args.push((DOUBLE, la.as_str())); - } // The standalone `_constructor` symbol returns DOUBLE: the // value an explicit `return ` produced (ECMAScript ctor // return-override) or `undefined` for an ordinary ctor. Capture it @@ -1691,6 +1711,15 @@ fn lower_new_impl_inner<'a>( None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), }; let nt_save = crate::rooting::new_target_save(ctx, &nt_ref); + // Read after every collecting preparation step, immediately + // before dispatch; obj_box still holds the allocation address. + let ctor_this = ctx.block().load(DOUBLE, &this_slot); + let marshalled: Vec<_> = marshalled + .iter() + .map(|arg| arg.reread(ctx, group)) + .collect(); + let mut ctor_args = vec![(DOUBLE, ctor_this.as_str())]; + ctor_args.extend(marshalled.iter().map(|arg| (DOUBLE, arg.as_str()))); let ctor_ret = ctx.block().call(DOUBLE, &ctor.symbol, &ctor_args); crate::rooting::new_target_restore(ctx, &nt_save); ctx.block().store(DOUBLE, &ctor_ret, &ctor_result_slot); diff --git a/crates/perry-codegen/src/lower_call/new_ctor_args.rs b/crates/perry-codegen/src/lower_call/new_ctor_args.rs index 81ff124c37..5c66ba89f8 100644 --- a/crates/perry-codegen/src/lower_call/new_ctor_args.rs +++ b/crates/perry-codegen/src/lower_call/new_ctor_args.rs @@ -18,6 +18,7 @@ use perry_hir::{Expr, Param}; use super::new_helpers::effective_constructor_param_count; use crate::expr::{lower_expr, nanbox_pointer_inline, FnCtx}; use crate::nanbox::double_literal; +use crate::rooting::{AccArray, RootedGroup}; use crate::types::{DOUBLE, I32, I64}; pub(crate) struct InlineConstructorScope { @@ -303,6 +304,36 @@ pub(super) fn lower_constructor_arg(ctx: &mut FnCtx<'_>, arg: &Expr) -> Result, group: &RootedGroup<'_>) -> String { + match self { + Self::Value(value) => value.clone(), + Self::Array(acc) => { + let handle = group.read_array(ctx, *acc); + nanbox_pointer_inline(ctx.block(), &handle) + } + } + } +} + +fn pack_imported_args_array( + ctx: &mut FnCtx<'_>, + group: &mut RootedGroup<'_>, + args: &[String], +) -> AccArray { + let cap = args.len().to_string(); + let acc = group.begin_array(ctx, &cap); + for value in args { + group.push_array(ctx, acc, value); + } + acc +} + /// Marshal the lowered `new`-site args into the value list a cross-module /// imported constructor symbol expects. The source module compiled the /// standalone `_constructor(this, p0, …)` with `ctor.param_count` @@ -312,40 +343,52 @@ pub(super) fn lower_constructor_arg(ctx: &mut FnCtx<'_>, arg: &Expr) -> Result, ctor: &crate::codegen::ImportedCtor, lowered_args: &[String], -) -> Vec { + group: &mut RootedGroup<'_>, +) -> Vec { let undef = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); let param_count = ctor.param_count; let trailing = usize::from(ctor.has_rest) + usize::from(ctor.has_synthetic_arguments); if trailing > 0 && param_count >= trailing { let n_positional = param_count - trailing; - let mut out: Vec = Vec::with_capacity(param_count); + let mut out = Vec::with_capacity(param_count); for i in 0..n_positional { - out.push( + out.push(ImportedCtorArg::Value( lowered_args .get(i) .cloned() .unwrap_or_else(|| undef.clone()), - ); + )); } if ctor.has_rest { let tail: Vec = lowered_args.iter().skip(n_positional).cloned().collect(); - out.push(pack_lowered_args_array(ctx, &tail)); + out.push(ImportedCtorArg::Array(pack_imported_args_array( + ctx, group, &tail, + ))); } if ctor.has_synthetic_arguments { - out.push(pack_lowered_args_array(ctx, lowered_args)); + out.push(ImportedCtorArg::Array(pack_imported_args_array( + ctx, + group, + lowered_args, + ))); } out } else { // No rest: positional, padded to `param_count` with `undefined`. - let mut out: Vec = lowered_args.to_vec(); + let mut out: Vec<_> = lowered_args + .iter() + .cloned() + .map(ImportedCtorArg::Value) + .collect(); while out.len() < param_count { - out.push(undef.clone()); + out.push(ImportedCtorArg::Value(undef.clone())); } // #6537 review: `param_count.max(out.len())` made this a no-op, so a // call site passing MORE args than the imported ctor's fixed arity diff --git a/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs b/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs index 38467b8aaf..5c47220f40 100644 --- a/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs +++ b/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs @@ -454,7 +454,12 @@ pub(crate) fn try_lower_instance_method_call( .unwrap_or_else(|| ctx.block_label(own_idx)); let blk = ctx.block(); let cid_ok = blk.icmp_eq(I32, &cid, &class_id.to_string()); - let shape_ok = blk.icmp_eq(I32, &shape_id, expected_shape); + let shape_ok = crate::typed_shape::emit_compatible_shape_eq( + blk, + &shape_id, + expected_shape, + &[], + ); let exact = blk.and(I1, &cid_ok, &shape_ok); blk.cond_br(&exact, &probe_dispatch_label, &miss_label); } diff --git a/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs b/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs index 40f4fb41e4..2eca37b443 100644 --- a/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs +++ b/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs @@ -32,6 +32,7 @@ fn ir_opts() -> CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/module.rs b/crates/perry-codegen/src/module.rs index 6b9476d5b1..8c103ee367 100644 --- a/crates/perry-codegen/src/module.rs +++ b/crates/perry-codegen/src/module.rs @@ -323,12 +323,16 @@ impl LlModule { note(self.fn_infos.borrow_mut().facts_mut(body)); } + pub(crate) fn request_static_seed_body(&mut self, body: &str) { + self.fn_infos.borrow_mut().request_static_seed_body(body); + } + /// Emit the module's `JsFunctionInfo` globals (`crate::fn_info`): one /// definition per body this module defines that is allocated here, has /// recorded facts, or is an external-linkage value wrapper another module /// may allocate; an `external` declaration for every allocated body /// another module defines. Runs once, after every function exists. - pub(crate) fn emit_fn_infos(&mut self) { + pub(crate) fn emit_fn_infos(&mut self, permanent_image: bool) { let lines = { let functions = &self.functions; let by_name: std::collections::HashMap<&str, &LlFunction> = @@ -348,6 +352,7 @@ impl LlModule { }) }, exported, + permanent_image, ) }; self.globals.extend(lines); diff --git a/crates/perry-codegen/src/native_root_coverage/mod.rs b/crates/perry-codegen/src/native_root_coverage/mod.rs index 66249d3ae8..fc18b2bc1f 100644 --- a/crates/perry-codegen/src/native_root_coverage/mod.rs +++ b/crates/perry-codegen/src/native_root_coverage/mod.rs @@ -121,6 +121,7 @@ pub(crate) fn ir_opts(target: &str, is_entry: bool) -> CompileOptions { target: Some(target.to_string()), is_entry_module: is_entry, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs index 9087e5c226..1ad1becf06 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs @@ -48,6 +48,17 @@ pub(crate) fn declare_third_party(module: &mut LlModule) { module.declare_function("js_thread_parallel_map", DOUBLE, &[DOUBLE, DOUBLE]); module.declare_function("js_thread_parallel_filter", DOUBLE, &[DOUBLE, DOUBLE]); module.declare_function("js_thread_spawn", DOUBLE, &[DOUBLE]); + module.declare_function( + "js_thread_parallel_map_with_literals", + DOUBLE, + &[DOUBLE, DOUBLE, I64], + ); + module.declare_function( + "js_thread_parallel_filter_with_literals", + DOUBLE, + &[DOUBLE, DOUBLE, I64], + ); + module.declare_function("js_thread_spawn_with_literals", DOUBLE, &[DOUBLE, I64]); // Immutable module-global leaves (codegen/global_transfer.rs): publication // cell, current-agent cache and canonical slot addresses. module.declare_function("js_thread_global_publish", VOID, &[I64, I64, I64]); diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index e483564534..99a45004a6 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -1136,7 +1136,7 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { module.declare_function( "js_region_loop_prime", I64, - &[PTR, I32, I32, I64, I64, I64, I64, I64, I32, I32, I32], + &[PTR, I32, I32, I64, I64, I64, I64, I64, I32, I32, I32, I32], ); // Design step 4: the per-class mint with the driver's static id, and the // literal-shape seed. @@ -1145,6 +1145,16 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { I32, &[I64, I32, I32, I32, I32, I64], ); + module.declare_function( + "js_object_final_shape_id_for_class_keys_static_constfn", + I32, + &[I64, I32, I32, I32, I32, I64, PTR, I32], + ); + module.declare_function( + "js_object_finalize_constfn_static", + I64, + &[I64, I32, PTR, I32, I32, I32, I32, I64, PTR, I32], + ); module.declare_function("js_shape_seed_plain", I32, &[I32, PTR, I32, I32, I32, I64]); module.declare_function("js_shape_register_static_seed", VOID, &[PTR]); module.declare_function("js_shape_run_static_seed", VOID, &[]); diff --git a/crates/perry-codegen/src/stmt/boxed_slot_no_root_tests.rs b/crates/perry-codegen/src/stmt/boxed_slot_no_root_tests.rs index b6c4d04c79..2e77a2ab1d 100644 --- a/crates/perry-codegen/src/stmt/boxed_slot_no_root_tests.rs +++ b/crates/perry-codegen/src/stmt/boxed_slot_no_root_tests.rs @@ -11,6 +11,7 @@ fn ir_opts() -> CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/stmt/class_field_loop_tests.rs b/crates/perry-codegen/src/stmt/class_field_loop_tests.rs index 683839206f..8b4483cfcc 100644 --- a/crates/perry-codegen/src/stmt/class_field_loop_tests.rs +++ b/crates/perry-codegen/src/stmt/class_field_loop_tests.rs @@ -1,29 +1,6 @@ -//! #7287: the #5093 class-field versioned loop must actually be REACHED. -//! -//! `lower_class_field_versioned_for` (`stmt/loops.rs`) hoists a monomorphic -//! `this.field` shape check into a loop preheader and runs a guard-free, -//! call-free fast clone. It was written for `benchmarks/suite/09_method_calls.ts` -//! and it is worth ~9× on it. It also matched **nothing** for months, in either -//! configuration, and nothing noticed: -//! -//! * with representation-selection Phase 1 on (the default), a proven-integer -//! loop counter's *only* storage is its canonical i32 slot — it has no -//! `ctx.locals` entry — and the matcher gated its counter and its bound on -//! `ctx.locals.contains_key(..)`; -//! * with Phase 1 off, the counter regains its `ctx.locals` entry but a bare -//! `i++` counter never earns an i32 *shadow*, which the lowering separately -//! requires. -//! -//! Every existing signal scored it as working. The lowering compiles, the -//! matcher is exercised by no test, `09_method_calls` still printed the right -//! answer, and the emitted object still differed from an unoptimised build (by -//! the *other* class-field lowerings). Only asserting that the versioned blocks -//! appear in the emitted IR distinguishes "implemented" from "reached" — see -//! CLAUDE.md, "a gate must assert its subject was live". -//! -//! So these tests assert on emitted block labels, and every one of them -//! requires the fast clone AND its guard-free store together: a preheader that -//! is emitted but branched around would still print `class_field.loop.*`. +//! P8 replacement IR checks for the original literal/module-bound and strict +//! class increment shapes. Runtime, hostile-value, and cost acceptance remains +//! separate and must use the unchanged original TypeScript fixtures. use crate::{compile_module, AppMetadata, CompileOptions}; use perry_hir::types::Type; @@ -38,6 +15,7 @@ fn ir_opts() -> CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), @@ -248,81 +226,61 @@ fn emit(m: &Module) -> String { String::from_utf8(compile_module(m, ir_opts()).unwrap()).expect("LLVM IR should be UTF-8") } -/// Both halves of the transform, asserted together. -/// -/// `class_field.loop.fast.preheader` alone would pass on a lowering that emits -/// the versioned skeleton and then unconditionally branches to the slow clone -/// (which is exactly what `lower_class_field_versioned_for` does when the fast -/// clone turns out not to be call-free). The guard-free store block is the part -/// that only exists when the fast clone was really entered, and the hoisted -/// preheader check is what makes it sound — so require all three. +/// P8: a numeric class-field module loop must use the generic guarded F/G body. +/// Runtime route/shape/store attribution is checked separately by the isolated +/// executable. These IR assertions never certify retained-tier cost parity. fn assert_versioned_loop_lowered(ir: &str, what: &str) { for label in [ - "class_field.loop.fast.preheader", - "class_field_loop.preheader.deref", - "class_field_loop_store.sloppy_fast", + "rloop.guard.", + "rloop.fast", + "rloop.join", + "rloop.version.plain", ] { assert!( ir.contains(label), - "{what}: expected the #5093 class-field versioned loop to be lowered, \ - but `{label}` is absent from the emitted IR. The matcher in \ - stmt/loops.rs declined — check that the loop counter and bound are \ - still admitted through `local_has_readable_slot` (repsel Phase 1 \ - stores a proven-integer local ONLY in its canonical i32 slot, with \ - no `ctx.locals` entry). See #7287." + "{what}: missing generic replacement `{label}`" ); } - // The slow clone must survive as the cold arm: it is what a receiver that - // fails the preheader check (frozen, descriptor-bearing, wrong class) and - // every mid-loop store side exit falls into. - assert!( - ir.contains("for.class_field_slow.cond"), - "{what}: the versioned loop's SLOW clone is missing — a hoisted guard \ - with no fallback arm is worse than no hoist at all" - ); - // #7480 step 4: the clone must be ENTERED, not merely emitted. The lowering - // builds the fast clone first and proves it call-free second; on a failed - // proof it terminates the guard with an UNCONDITIONAL branch to the slow - // clone and leaves the fast blocks as unreachable code — a state in which - // every label assertion above still passes. The twin assertion on the - // element-shape clone caught exactly that: #7690's back-edge polls put a - // `js_gc_loop_safepoint()` inside the clone and silently deleted it. + for label in [ + "class_field.loop.", + "class_field_loop.", + "class_field_loop_store.", + "for.class_field_fast", + "for.class_field_slow", + ] { + assert!(!ir.contains(label), "{what}: legacy tier remains `{label}`"); + } + let fast = ir + .lines() + .skip_while(|line| !line.starts_with("rloop.fast")) + .take_while(|line| !line.starts_with("rloop.join")) + .collect::>() + .join("\n"); assert!( - ir.contains("label %for.class_field_fast.cond") - || ir.contains("label %class_field.loop.fast.preheader"), - "{what}: the guard must branch INTO the fast clone. If it ends in an \ - unconditional branch to the slow clone, the call-free proof failed and \ - the clone is dead code that every label assertion above still accepts" + fast.contains("load double") + && fast.contains("fadd double") + && fast.contains("store double") + && ir + .lines() + .any(|line| line.contains("br i1 ") && line.contains("label %rloop.version.split")), + "{what}: reachable generic F must read, add and commit the increment" ); - // The fast clone must be free of the per-access diamond it exists to - // replace: no volatile gate load between the fast preheader and the store. - let fast = fast_clone_slice(ir); assert!( - !fast.contains("@PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED"), - "{what}: the fast clone still reads the per-access inline-guard gate; \ - the whole point of the preheader check is that it does not" + !fast.contains("@js_class_field_") + && !fast.contains("@js_object_get_field") + && !fast.contains("@js_number_coerce") + && !fast.contains("@js_dynamic_string_or_number_add") + && !fast.contains("@js_put_value"), + "{what}: typed class read must consume exact R, not its ordinary guard" ); assert!( - !fast.contains("js_typed_feedback_class_field"), - "{what}: the fast clone still calls the class-field guard; it must be \ - call-free (call-free ⇒ allocation-free ⇒ no GC ⇒ the preheader's \ - cached object pointer stays valid)" + ir.lines() + .any(|line| line.contains("call i64 @js_region_loop_prime(") + && line.contains("i32 1, i32 0, i32 1)")), + "{what}: increment must request stored=1, boxed=0 and R=1" ); } -/// The emitted text from the fast clone's condition block up to the slow -/// clone's, i.e. exactly the blocks the fast copy owns. -fn fast_clone_slice(ir: &str) -> &str { - let start = ir - .find("for.class_field_fast.cond") - .expect("fast clone cond block"); - let end = ir[start..] - .find("for.class_field_slow.cond") - .map(|off| start + off) - .unwrap_or(ir.len()); - &ir[start..end] -} - /// The exact `09_method_calls` shape: an integer-literal bound. #[test] fn class_field_versioned_loop_fires_for_literal_bound() { @@ -355,11 +313,9 @@ fn class_field_versioned_loop_fires_for_module_scope_counter() { assert_versioned_loop_lowered(&ir, "module-scope const bound"); } -/// STRICT module scope takes a different store lowering -/// (`put_value_static_property_fast_path` → `property_set::lower`), which has -/// carried its own loop-fact branch since #5093. Both arms must reach the fast -/// clone, or an ESM/CJS difference silently changes which one a file gets — -/// the same class of path-dependence #7288 was. +/// STRICT module scope takes a different ordinary store lowering +/// (`put_value_static_property_fast_path` → `property_set::lower`). Both modes +/// must consume the same guarded region store proof in F. #[test] fn class_field_versioned_loop_fires_in_strict_mode() { let ir = emit(&method_calls_module( @@ -367,18 +323,69 @@ fn class_field_versioned_loop_fires_in_strict_mode() { Vec::new(), true, )); - for label in [ - "class_field.loop.fast.preheader", - "class_field_loop.preheader.deref", - "class_field_loop_store.fast", - ] { - assert!( - ir.contains(label), - "strict mode: expected `{label}` in the emitted IR (#7287)" - ); - } + assert_versioned_loop_lowered(&ir, "strict mode"); +} + +/// Replacement's scoped suppression must not erase the pre-existing receiver +/// proof after F/G joins. The subsequent read still uses a direct Ptr +/// load, but keeps the ordinary boxed-value/coercion check: R must not escape. +#[test] +fn class_loop_replacement_restores_straight_line_receiver_proof() { + let mut module = method_calls_module(Expr::Integer(200), Vec::new(), false); + module.init.push(Stmt::Expr(Expr::Binary { + op: BinaryOp::Mul, + left: Box::new(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(1)), + property: "value".to_string(), + byte_offset: 0, + }), + right: Box::new(Expr::Integer(2)), + })); + let ir = emit(&module); + assert_versioned_loop_lowered(&ir, "subsequent read"); + let post = ir + .lines() + .skip_while(|line| !line.starts_with("rloop.version.merge")) + .skip(1) + .take_while(|line| !line.is_empty()) + .collect::>() + .join("\n"); assert!( - !fast_clone_slice(&ir).contains("@PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED"), - "strict mode: the fast clone still reads the per-access gate" + post.contains("load double") + && post.contains("label %ptr_shape_get_number.coerce") + && !post.contains("@js_class_field_") + && !post.contains("class_field_inline"), + "post-loop shape proof must return without leaking Number R:\n{post}" + ); +} + +/// The removed twin admitted only a single expression. Region admission must +/// come from its own effect and representation proof, without keeping that +/// old syntactic matcher as a second authority. +#[test] +fn numeric_class_region_accepts_multiple_commits() { + let mut module = method_calls_module(Expr::Integer(200), Vec::new(), false); + let body = module + .init + .iter_mut() + .find_map(|stmt| match stmt { + Stmt::For { body, .. } => Some(body), + _ => None, + }) + .expect("fixture must contain the original increment loop"); + let increment = body[0].clone(); + body.push(increment); + let ir = emit(&module); + assert_versioned_loop_lowered(&ir, "multiple numeric commits"); + let fast = ir + .lines() + .skip_while(|line| !line.starts_with("rloop.fast")) + .take_while(|line| !line.starts_with("rloop.join")) + .collect::>() + .join("\n"); + assert_eq!( + fast.matches("fadd double").count(), + 2, + "both increment expressions must consume the same guarded numeric lane" ); } diff --git a/crates/perry-codegen/src/stmt/element_shape_loop.rs b/crates/perry-codegen/src/stmt/element_shape_loop.rs index 65f252ef4a..b497315746 100644 --- a/crates/perry-codegen/src/stmt/element_shape_loop.rs +++ b/crates/perry-codegen/src/stmt/element_shape_loop.rs @@ -148,8 +148,7 @@ use perry_hir::Stmt; use super::loops::{ emit_js_value_is_number, local_bound_is_loop_invariant, local_has_readable_slot, loop_counter_bounds_are_safe, loop_counter_entry_i32_range_is_safe, lower_for_after_init, - lower_for_after_init_with_i32_bound, CLASS_FIELD_LOOP_CLASS_DENYLIST, - CLASS_FIELD_LOOP_PROP_DENYLIST, + lower_for_after_init_with_i32_bound, ELEMENT_SHAPE_CLASS_DENYLIST, ELEMENT_SHAPE_PROP_DENYLIST, }; use crate::expr::{lower_expr, FnCtx}; use crate::types::{DOUBLE, I1, I32, I64}; @@ -1264,7 +1263,7 @@ fn match_element_shape_versioned_loop( // the spec, wherever those two differ. const SHAPE_PROP_DENYLIST: &[&str] = &["__proto__"]; let denylist = match identity { - ElementShapeIdentity::Class { .. } => CLASS_FIELD_LOOP_PROP_DENYLIST, + ElementShapeIdentity::Class { .. } => ELEMENT_SHAPE_PROP_DENYLIST, ElementShapeIdentity::Shape => SHAPE_PROP_DENYLIST, }; for prop in &facts.props { @@ -1348,7 +1347,7 @@ fn match_class_identity( class_name: &str, props: &std::collections::BTreeSet, ) -> Option { - if CLASS_FIELD_LOOP_CLASS_DENYLIST.contains(&class_name) { + if ELEMENT_SHAPE_CLASS_DENYLIST.contains(&class_name) { return None; } let class = ctx.classes.get(class_name)?; @@ -1456,7 +1455,7 @@ fn materialize_loop_i32( } /// Lower the matched loop as a guarded fast clone plus the unchanged generic -/// body, modeled on `lower_class_field_versioned_for`. +/// body, with the guard and call-free clone sharing one dynamic extent. /// /// SAFETY (miscompile class — see the module docs): between the preheader's /// post-guard re-derivation of the elements base pointer and the end of the diff --git a/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs b/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs index e8e815782a..d1b10db7c6 100644 --- a/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs +++ b/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs @@ -28,6 +28,7 @@ fn ir_opts() -> CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index f10e8c6000..87e8fe6994 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -3923,12 +3923,12 @@ fn lower_packed_f64_range_versioned_for( Ok(true) } -/// #5093: property names with dedicated branches in the property-get/set +/// Element-shape field access restrictions: property names with dedicated branches in the property-get/set /// lowering dispatch ahead of the class-field diamond (`length` header loads, /// `errors` runtime call, accessor-ish names, …). A tracked field must not /// collide or the fast clone's access would lower through a different — /// possibly calling — path, breaking the call-free guarantee. -pub(super) const CLASS_FIELD_LOOP_PROP_DENYLIST: &[&str] = &[ +pub(super) const ELEMENT_SHAPE_PROP_DENYLIST: &[&str] = &[ "length", "errors", "size", @@ -3944,10 +3944,10 @@ pub(super) const CLASS_FIELD_LOOP_PROP_DENYLIST: &[&str] = &[ "valueOf", ]; -/// #5093: class names with dedicated (builtin-flavored) branches in the +/// Element-shape field access restrictions: class names with dedicated (builtin-flavored) branches in the /// property lowering dispatch; a user class sharing one of these names could /// be intercepted before the class-field diamond. -pub(super) const CLASS_FIELD_LOOP_CLASS_DENYLIST: &[&str] = &[ +pub(super) const ELEMENT_SHAPE_CLASS_DENYLIST: &[&str] = &[ "Headers", "URLPattern", "ClientRequest", @@ -5438,511 +5438,6 @@ fn lower_object_array_write_versioned_for( Ok(true) } -#[derive(Clone, Copy)] -enum ClassFieldLoopBound { - /// `i < `. - Constant(i64), - /// `i < b` where `b` is a loop-invariant plain local or module global. - Local(u32), -} - -struct ClassFieldVersionedLoop { - counter_id: u32, - bound: ClassFieldLoopBound, - recv_id: u32, - class_name: String, - expected_class_id: u32, - keys_global_name: String, - /// property -> (packed slot index, written). All raw-f64 candidates. - fields: std::collections::BTreeMap, -} - -/// #5093: effect-free expression walk for the class-field versioned loop. -/// Tracked `recv.prop` reads, numeric locals, numeric literals and pure -/// arithmetic/Math only — the same shapes `packed_f64_range_loop_pure_expr_ -/// collect` admits, minus array accesses, plus class-field reads. Everything -/// here must lower without emitting a call that can allocate (libm intrinsic -/// calls are fine: they cannot trigger a GC). -fn class_field_loop_pure_expr_collect( - ctx: &FnCtx<'_>, - expr: &perry_hir::Expr, - counter_id: u32, - recv: &mut Option, - props: &mut std::collections::BTreeMap, -) -> bool { - use perry_hir::Expr; - match expr { - Expr::PropertyGet { - object, property, .. - } => { - let Expr::LocalGet(obj_id) = object.as_ref() else { - return false; - }; - if *obj_id == counter_id { - return false; - } - match recv { - Some(r) if *r == *obj_id => {} - Some(_) => return false, // single receiver per loop - None => *recv = Some(*obj_id), - } - props.entry(property.clone()).or_insert(false); - true - } - // Reading the receiver as a VALUE (outside a tracked field access) - // could flow it into arbitrary lowering; only allow scalar reads the - // type analysis proves numeric. - Expr::LocalGet(id) => { - recv.map_or(true, |r| r != *id) && crate::type_analysis::is_numeric_expr(ctx, expr) - } - Expr::Number(_) | Expr::Integer(_) => true, - Expr::Binary { left, right, .. } => { - crate::type_analysis::is_numeric_expr(ctx, expr) - && class_field_loop_pure_expr_collect(ctx, left, counter_id, recv, props) - && class_field_loop_pure_expr_collect(ctx, right, counter_id, recv, props) - } - Expr::NumberCoerce(operand) => { - class_field_loop_pure_expr_collect(ctx, operand, counter_id, recv, props) - } - Expr::MathImul(left, right) | Expr::MathPow(left, right) => { - class_field_loop_pure_expr_collect(ctx, left, counter_id, recv, props) - && class_field_loop_pure_expr_collect(ctx, right, counter_id, recv, props) - } - Expr::MathMin(values) | Expr::MathMax(values) => values - .iter() - .all(|expr| class_field_loop_pure_expr_collect(ctx, expr, counter_id, recv, props)), - Expr::MathAbs(value) - | Expr::MathSqrt(value) - | Expr::MathFloor(value) - | Expr::MathCeil(value) - | Expr::MathRound(value) - | Expr::MathTrunc(value) - | Expr::MathSign(value) - | Expr::MathF16round(value) => { - class_field_loop_pure_expr_collect(ctx, value, counter_id, recv, props) - } - _ => false, - } -} - -/// #5093: class-field versioned loop — the "collapse" this issue tracks. -/// -/// Matches `for (let i = k0; i < B; i++) ` where `B` is an -/// integer literal or a loop-invariant local/module-global and the statement's -/// only side effect is a raw-f64 class-field store on a loop-invariant -/// receiver of statically known class (or a scalar `LocalSet` accumulator), -/// with every other subexpression pure per the walker above. -/// -/// The single-statement / effect-last restriction is the side-exit protocol -/// (same as the #6011 range loop): the fast clone's only mid-loop bail is the -/// store's inline plain-finite value check, which fires BEFORE the store — so -/// jumping to the slow clone's preheader re-executes the current iteration -/// without duplicating any effect. -fn match_class_field_versioned_loop( - ctx: &FnCtx<'_>, - init: Option<&Stmt>, - condition: Option<&perry_hir::Expr>, - update: Option<&perry_hir::Expr>, - body: &[Stmt], -) -> Option { - use perry_hir::{CompareOp, Expr, UpdateOp}; - // Oversized modules full-outline the class-field diamonds for code size; - // keep the versioned clone (which would re-inline them) off there. - if crate::codegen::full_outline_ic_enabled() { - return None; - } - if !ctx.pending_labels.is_empty() { - return None; - } - let (counter_id, start) = match init? { - Stmt::Let { - id, - init: Some(init_expr), - .. - } => { - let start = match init_expr { - Expr::Integer(n) => *n, - Expr::Number(n) if n.is_finite() && n.fract() == 0.0 => *n as i64, - _ => return None, - }; - (*id, start) - } - _ => return None, - }; - if !(0..=i64::from(i32::MAX)).contains(&start) { - return None; - } - let (op, left, right) = match condition? { - Expr::Compare { op, left, right } => (*op, left.as_ref(), right.as_ref()), - _ => return None, - }; - if !matches!(op, CompareOp::Lt) || !matches!(left, Expr::LocalGet(id) if *id == counter_id) { - return None; - } - let bound = match right { - Expr::Integer(k) if (0..=i64::from(i32::MAX)).contains(k) => { - ClassFieldLoopBound::Constant(*k) - } - Expr::LocalGet(bound_id) if *bound_id != counter_id => { - if ctx.boxed_vars.contains(bound_id) { - return None; - } - if !local_has_readable_slot(ctx, *bound_id) - && !ctx.module_globals.contains_key(bound_id) - { - return None; - } - if !local_bound_is_loop_invariant(condition?, update, body, *bound_id) { - return None; - } - ClassFieldLoopBound::Local(*bound_id) - } - _ => return None, - }; - if !matches!( - update?, - Expr::Update { - id, - op: UpdateOp::Increment, - .. - } if *id == counter_id - ) { - return None; - } - if !local_has_readable_slot(ctx, counter_id) - || ctx.boxed_vars.contains(&counter_id) - || !ctx.integer_locals.contains(&counter_id) - || !loop_counter_bounds_are_safe(ctx, counter_id, update, body) - || !loop_counter_entry_i32_range_is_safe(init, counter_id) - { - return None; - } - - // Single-statement body whose only side effect commits after every - // potential side exit. - let [Stmt::Expr(effect)] = body else { - return None; - }; - let mut recv: Option = None; - let mut props: std::collections::BTreeMap = std::collections::BTreeMap::new(); - match effect { - // `recv.prop = ` — the benchmark shape. Lowering - // rewrites the static-key PutValueSet through the PropertySet - // class-field diamond (`put_value_static_property_fast_path`). - Expr::PutValueSet { - target, - key, - value, - receiver, - .. - } => { - let (Expr::LocalGet(t), Expr::LocalGet(r)) = (target.as_ref(), receiver.as_ref()) - else { - return None; - }; - if t != r { - return None; - } - // Keep this class-field clone's existing string-only contract; - // integer keys are handled by the general object-write matcher. - let prop = crate::expr::proxy_reflect::static_string_write_key(ctx, key.as_ref())?; - recv = Some(*t); - if !class_field_loop_pure_expr_collect(ctx, value, counter_id, &mut recv, &mut props) { - return None; - } - props - .entry(prop) - .and_modify(|written| *written = true) - .or_insert(true); - } - Expr::PropertySet { - object, - property, - value, - } => { - let Expr::LocalGet(obj_id) = object.as_ref() else { - return None; - }; - recv = Some(*obj_id); - if !class_field_loop_pure_expr_collect(ctx, value, counter_id, &mut recv, &mut props) { - return None; - } - props - .entry(property.clone()) - .and_modify(|written| *written = true) - .or_insert(true); - } - // Scalar accumulator: `acc = `. No - // store side exit exists, so re-execution can never happen; the - // LocalSet itself must still target a plain numeric non-shadow local. - Expr::LocalSet(id, value) => { - if *id == counter_id - || !ctx.locals.contains_key(id) - || ctx.boxed_vars.contains(id) - || ctx.module_globals.contains_key(id) - || ctx.shadow_slot_map.contains_key(id) - || !crate::type_analysis::is_numeric_expr(ctx, &Expr::LocalGet(*id)) - { - return None; - } - if !class_field_loop_pure_expr_collect(ctx, value, counter_id, &mut recv, &mut props) { - return None; - } - if recv == Some(*id) { - return None; - } - if let ClassFieldLoopBound::Local(bound_id) = bound { - if bound_id == *id { - return None; - } - } - } - _ => return None, - } - let recv_id = recv?; - if props.is_empty() || recv_id == counter_id { - return None; - } - if let ClassFieldLoopBound::Local(bound_id) = bound { - if bound_id == recv_id { - return None; - } - } - - // Receiver: loop-invariant, directly addressable, not aliased by another - // representation (POD / scalar replacement take different lowering paths). - if ctx.boxed_vars.contains(&recv_id) - || ctx.pod_records.contains_key(&recv_id) - || ctx.scalar_replaced.contains_key(&recv_id) - { - return None; - } - if !ctx.locals.contains_key(&recv_id) && !ctx.module_globals.contains_key(&recv_id) { - return None; - } - if !local_bound_is_loop_invariant(condition?, update, body, recv_id) { - return None; - } - let class_name = - crate::type_analysis::receiver_class_name(ctx, &perry_hir::Expr::LocalGet(recv_id))?; - if CLASS_FIELD_LOOP_CLASS_DENYLIST.contains(&class_name.as_str()) { - return None; - } - let class = ctx.classes.get(&class_name)?; - if !class.computed_members.is_empty() { - return None; - } - let expected_class_id = *ctx.class_ids.get(&class_name)?; - let keys_global_name = ctx.class_keys_globals.get(&class_name)?.clone(); - - let mut fields = std::collections::BTreeMap::new(); - for (prop, written) in props { - if CLASS_FIELD_LOOP_PROP_DENYLIST.contains(&prop.as_str()) { - return None; - } - // Accessors route through synthesized __get_/__set_ methods before - // the class-field diamond; `class_field_global_index` also rejects - // accessor-shadowed names, but mirror the dispatch gate exactly. - if ctx - .methods - .contains_key(&(class_name.clone(), format!("__get_{prop}"))) - || ctx - .methods - .contains_key(&(class_name.clone(), format!("__set_{prop}"))) - { - return None; - } - let field_index = crate::type_analysis::class_field_global_index(ctx, &class_name, &prop)?; - let raw_f64 = crate::expr::class_field_inline_guard::class_field_site_raw_f64( - ctx, - &class_name, - &prop, - field_index, - ); - if !raw_f64 { - return None; - } - fields.insert(prop, (field_index, written)); - } - - Some(ClassFieldVersionedLoop { - counter_id, - bound, - recv_id, - class_name, - expected_class_id, - keys_global_name, - fields, - }) -} - -/// #5093: lowering for [`match_class_field_versioned_loop`], modeled on -/// [`lower_packed_f64_range_versioned_for`]. The bound is materialized to i32 -/// once (with a finite-integral check for local/global bounds), the inline -/// class-field shape check runs once in the preheader, and the fast clone -/// lowers with a scoped [`crate::expr::ClassFieldLoopFact`] so every tracked -/// field access is a bare GEP load/store on the preheader-cached object -/// pointer. Store side exits resume at the current `i` in the slow clone. -/// -/// SAFETY (memory-corruption class — see #5093): between the preheader's -/// receiver load and the end of the fast clone, NO call may be emitted. The -/// matcher enforces this by shape (single pure-arithmetic statement, all -/// field accesses tracked, counter/bound machinery call-free); the preheader -/// itself emits only bit ops, loads, and the finite-integral bound checks. -/// Call-free ⇒ allocation-free ⇒ no GC ⇒ the object cannot move and none of -/// the checked shape facts can change while the fast clone runs. -fn lower_class_field_versioned_for( - ctx: &mut FnCtx<'_>, - init: Option<&Stmt>, - condition: Option<&perry_hir::Expr>, - update: Option<&perry_hir::Expr>, - body: &[Stmt], -) -> Result { - let Some(matched) = match_class_field_versioned_loop(ctx, init, condition, update, body) else { - return Ok(false); - }; - // The fast clone's cond reads the counter through its i32 slot; without - // one the versioned copy would win nothing. - if !ctx.i32_counter_slots.contains_key(&matched.counter_id) { - return Ok(false); - } - - let fast_pre_idx = ctx.new_block("class_field.loop.fast.preheader"); - let slow_pre_idx = ctx.new_block("class_field.loop.slow.preheader"); - let merge_idx = ctx.new_block("class_field.loop.merge"); - let fast_pre_label = ctx.block_label(fast_pre_idx); - let slow_pre_label = ctx.block_label(slow_pre_idx); - let merge_label = ctx.block_label(merge_idx); - - // One-time i32 materialization of the bound (mirrors the #6011 range - // loop): non-number / NaN / fractional / out-of-range bounds keep full JS - // trip-count semantics in the slow clone. - let bound_i32: String = match matched.bound { - ClassFieldLoopBound::Constant(k) => k.to_string(), - ClassFieldLoopBound::Local(bound_id) => { - let bound_d = lower_expr(ctx, &perry_hir::Expr::LocalGet(bound_id))?; - let is_number = emit_js_value_is_number(ctx, &bound_d); - let range_idx = ctx.new_block("class_field.loop.bound.range"); - let convert_idx = ctx.new_block("class_field.loop.bound.convert"); - let check_idx = ctx.new_block("class_field.loop.shape_check"); - let range_label = ctx.block_label(range_idx); - let convert_label = ctx.block_label(convert_idx); - let check_label = ctx.block_label(check_idx); - ctx.block() - .cond_br(&is_number, &range_label, &slow_pre_label); - - ctx.current_block = range_idx; - let ge_zero = ctx.block().fcmp("oge", &bound_d, "0.0"); - let le_max = { - let max_literal = format!("{:.1}", i32::MAX as f64); - ctx.block().fcmp("ole", &bound_d, &max_literal) - }; - let in_range = ctx.block().and(I1, &ge_zero, &le_max); - ctx.block() - .cond_br(&in_range, &convert_label, &slow_pre_label); - - ctx.current_block = convert_idx; - let bound_i32 = ctx.block().fptosi(DOUBLE, &bound_d, I32); - let roundtrip = ctx.block().sitofp(I32, &bound_i32, DOUBLE); - let is_integral = ctx.block().fcmp("oeq", &roundtrip, &bound_d); - ctx.block() - .cond_br(&is_integral, &check_label, &slow_pre_label); - - ctx.current_block = check_idx; - bound_i32 - } - }; - - // Receiver load + hoisted shape check. From here to loop entry the - // emitted IR is call-free, so the pointer the check validates is the - // pointer the fast clone uses. - let recv_box = lower_expr(ctx, &perry_hir::Expr::LocalGet(matched.recv_id))?; - let expected_shape_id = crate::typed_shape::class_shape_id_operand( - ctx, - &matched.class_name, - &matched.keys_global_name, - ); - let (obj_bits, obj_handle) = { - let blk = ctx.block(); - let obj_bits = blk.bitcast_double_to_i64(&recv_box); - let obj_handle = blk.and(I64, &obj_bits, crate::nanbox::POINTER_MASK_I64); - (obj_bits, obj_handle) - }; - let has_store = matched.fields.values().any(|(_, written)| *written); - let expected_class_id_str = matched.expected_class_id.to_string(); - let (obj_ptr, shape_ok) = - crate::expr::class_field_inline_guard::emit_class_field_loop_preheader_check( - ctx, - &obj_bits, - &obj_handle, - &expected_class_id_str, - &expected_shape_id, - has_store, - &slow_pre_label, - ); - // The deref block is left unterminated on purpose: it branches into the - // fast clone only after the clone is PROVEN call-free below. - let deref_idx = ctx.current_block; - - let scope_id = ctx.next_loop_proof_scope_id(); - let fast_scan_start = ctx.func.num_blocks(); - ctx.current_block = fast_pre_idx; - ctx.class_field_loop_facts - .push(crate::expr::ClassFieldLoopFact { - recv_local_id: matched.recv_id, - scope_id, - class_name: matched.class_name.clone(), - obj_ptr, - side_exit_label: slow_pre_label.clone(), - fields: matched - .fields - .iter() - .map(|(prop, (field_index, _))| (prop.clone(), *field_index)) - .collect(), - }); - lower_for_after_init_with_i32_bound( - ctx, - init, - condition, - update, - body, - "for.class_field_fast", - Some((matched.counter_id, bound_i32)), - )?; - ctx.class_field_loop_facts - .retain(|fact| fact.scope_id != scope_id); - if !ctx.block().is_terminated() { - ctx.block().br(&merge_label); - } - let fast_scan_end = ctx.func.num_blocks(); - - // Compile-time verification of the safety invariant: the fast clone must - // be call-free (no runtime call ⇒ no allocation ⇒ no GC ⇒ the cached - // `obj_ptr` cannot move and the hoisted shape check stays true). The - // matcher makes this true by construction; if some unpredicted lowering - // path emitted a call anyway, never enter the fast clone — run the slow - // clone unconditionally and leave the fast blocks as unreachable code. - let fast_clone_call_free = !ctx.func.blocks()[fast_pre_idx].contains_gc_unsafe_call() - && (fast_scan_start..fast_scan_end) - .all(|idx| !ctx.func.blocks()[idx].contains_gc_unsafe_call()); - ctx.current_block = deref_idx; - if fast_clone_call_free { - ctx.block() - .cond_br(&shape_ok, &fast_pre_label, &slow_pre_label); - } else { - ctx.block().br(&slow_pre_label); - } - - ctx.current_block = slow_pre_idx; - lower_for_after_init(ctx, init, condition, update, body, "for.class_field_slow")?; - if !ctx.block().is_terminated() { - ctx.block().br(&merge_label); - } - - ctx.current_block = merge_idx; - Ok(true) -} - fn record_packed_f64_loop_guard_artifacts( ctx: &mut FnCtx<'_>, arr_id: u32, @@ -7246,13 +6741,6 @@ pub(crate) fn lower_for( return Ok(()); } - // #5093: monomorphic class-field hot loops (`counter.value = counter.value - // + 1` after method inlining). Shape check hoisted to a preheader; fast - // clone is call-free raw slot access. - if lower_class_field_versioned_for(ctx, init, condition, update, body)? { - return Ok(()); - } - // repsel #7480 / #5093: `sum += arr[i].field` over an array carrying the // homogeneous element-shape invariant. Tried last, so every array-shaped // matcher above keeps precedence on the loops it already owns. @@ -7263,7 +6751,7 @@ pub(crate) fn lower_for( } // #8690 owns only loops left over after the established packed-number, - // indexed-method, class-field, and homogeneous element-shape clones have + // indexed-method, and homogeneous element-shape clones have // had first refusal. Its runtime admission is deliberately broader, so // trying it earlier would steal those specialized access shapes. if super::stable_packed_loop::lower(ctx, init, condition, update, body)? { @@ -7273,15 +6761,31 @@ pub(crate) fn lower_for( // Step 4b (#10884): every specialised tier above declined; a loop (or // body) region guards its receivers once here, in the preheader, and // splits the body when the tier below lowers it (`stmt::region_loop`). - let region = super::region_loop::begin(ctx, condition, body, update)?; - let lowered = super::region_loop::lower_loop(ctx, region, &mut |ctx| { - if i32_counter::lower(ctx, init, condition, update, body)? { - Ok(()) - } else { - lower_for_after_init(ctx, init, condition, update, body, "for") - } - }); - super::region_loop::end(ctx, region); + // Named class-field loops use the same fresh shape/representation proof + // as other receiver loops. Straight-line Ptr facts stay recorded, + // but must not bypass the region's exact R/store admission. All specialized + // array/storage tiers above retain first refusal. Restore the previous + // context both when planning declines and when lowering fails. + let saved_ptr_shape_context = ctx.repsel_context_allows_ptr_shape; + ctx.repsel_context_allows_ptr_shape = false; + let lowered = (|| -> Result<()> { + let region = super::region_loop::begin(ctx, condition, body, update)?; + // With no region there is no F/G extent: ordinary loop lowering can + // consume its pre-existing straight-line receiver facts as before. + if region.is_none() { + ctx.repsel_context_allows_ptr_shape = saved_ptr_shape_context; + } + let lowered = super::region_loop::lower_loop(ctx, region, &mut |ctx| { + if i32_counter::lower(ctx, init, condition, update, body)? { + Ok(()) + } else { + lower_for_after_init(ctx, init, condition, update, body, "for") + } + }); + super::region_loop::end(ctx, region); + lowered + })(); + ctx.repsel_context_allows_ptr_shape = saved_ptr_shape_context; lowered } @@ -7402,7 +6906,7 @@ fn lower_for_after_init_impl( // repsel Phase 1 having done so). Only the inserter removes at loop exit. let mut hoist_counter_i32_was_fresh = false; // #7480 step 4: inside a call-free-by-construction fast clone - // (`lower_element_shape_versioned_for`, `lower_class_field_versioned_for`) + // (`lower_element_shape_versioned_for`) // the caller has ALREADY materialized the trip count and passed it in // `precomputed_i32_bound`, so the cond block never reads this slot. The // hoist would emit a `js_value_length_f64` call whose result nothing @@ -7418,7 +6922,6 @@ fn lower_for_after_init_impl( // clone's other lowering may depend on them; suppressing those too would // trade one silent loss for another. let in_call_free_clone = !ctx.element_shape_loop_facts.is_empty() - || !ctx.class_field_loop_facts.is_empty() || !ctx.stable_packed_loop_facts.is_empty() || precomputed_i32_bound.is_some(); let hoisted_length_slot: Option = if let Some(hoist) = hoist_classification { @@ -8028,7 +7531,7 @@ pub(crate) fn emit_gc_loop_safepoint( } // #7480 step 4: never inside a call-free-by-construction fast clone. // - // `lower_class_field_versioned_for`, `lower_element_shape_versioned_for`, + // `lower_element_shape_versioned_for` // and the stable-packed loop tier hoist a guard into a preheader and clone // the body against it. All rest on the SAME safety argument: the clone makes no call, therefore // allocates nothing, therefore cannot collect, therefore the pointer the @@ -8047,14 +7550,6 @@ pub(crate) fn emit_gc_loop_safepoint( // `stmt/element_shape_loop.rs`'s module docs predicted in as many words, // and `assert_fast_clone_is_entered` is the assertion that now catches it. // - // The class-field clone is NOT affected today, and that was checked rather - // than assumed: removing this suppression leaves its three IR tests green, - // because `loop_may_allocate` already proves an `obj.field`-only body inert - // and emits no poll for it. It is covered here anyway — the two clones rest - // on the identical argument, and the next body shape admitted to the - // class-field matcher that is not provably inert would delete that clone - // the same way. Its tests gained the same liveness assertion. - // // Skipping the poll here is not a new licence — it is the rule the line // below already applies. A poll exists so that an ALLOCATING loop can defer // a collection to a safe point; a body that cannot allocate does not need @@ -8065,7 +7560,6 @@ pub(crate) fn emit_gc_loop_safepoint( // call-free or it is not entered, and the slow clone — lowered after the // scope is popped — keeps its poll either way. if !ctx.element_shape_loop_facts.is_empty() - || !ctx.class_field_loop_facts.is_empty() || !ctx.stable_packed_loop_facts.is_empty() // #9379: the packed-f64 loop clone is the next body the paragraph above // predicted — "the next body shape admitted to the matcher that is not diff --git a/crates/perry-codegen/src/stmt/prealloc_module_global_tests.rs b/crates/perry-codegen/src/stmt/prealloc_module_global_tests.rs index 2a281ee91c..e6ce415fc7 100644 --- a/crates/perry-codegen/src/stmt/prealloc_module_global_tests.rs +++ b/crates/perry-codegen/src/stmt/prealloc_module_global_tests.rs @@ -46,6 +46,7 @@ pub(super) fn ir_opts() -> CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/stmt/region_loop/arrays.rs b/crates/perry-codegen/src/stmt/region_loop/arrays.rs index 7d3bb73c2a..4a29d8b5ac 100644 --- a/crates/perry-codegen/src/stmt/region_loop/arrays.rs +++ b/crates/perry-codegen/src/stmt/region_loop/arrays.rs @@ -166,7 +166,10 @@ pub(super) fn candidates( extra.extend(cond); extra.extend(update); let written = assigned(body, &extra); - let keyed: HashSet = accesses(body).into_iter().map(|(r, _, _)| r).collect(); + let keyed: HashSet = accesses(body) + .into_iter() + .map(|(r, _, _, _, _)| r) + .collect(); for (id, c) in reads { let r = Recv::Local(id); if written.contains(&id) || keyed.contains(&r) || !receiver_eligible(ctx, r) { diff --git a/crates/perry-codegen/src/stmt/region_loop/guard.rs b/crates/perry-codegen/src/stmt/region_loop/guard.rs index a90702caa4..7e71fd88c4 100644 --- a/crates/perry-codegen/src/stmt/region_loop/guard.rs +++ b/crates/perry-codegen/src/stmt/region_loop/guard.rs @@ -92,6 +92,7 @@ pub(super) fn emit_prime_call( (I32, &last), (I32, &rv.stored_mask.to_string()), (I32, &rv.boxed_mask.to_string()), + (I32, &rv.r_mask.to_string()), ], ) } @@ -117,6 +118,7 @@ pub(super) fn emit_body_guard_direct( rv: &Receiver, sites: &Sites, word: &str, + entry_tests: &[u32], inline_l: &str, spill_l: &str, fail_l: &str, @@ -145,7 +147,11 @@ pub(super) fn emit_body_guard_direct( let handle = crate::expr::receiver_range::emit_handle(ctx.block(), &test.biased); let expected = ctx.block().trunc(I64, word, I32); let sid = field_i32(ctx, &handle, 4); - let eq = ctx.block().icmp_eq(I32, &sid, &expected); + let mut eq = ctx.block().icmp_eq(I32, &sid, &expected); + if !entry_tests.is_empty() { + let number_ok = emit_number_entry_tests(ctx, entry_tests)?; + eq = ctx.block().and(I1, &eq, &number_ok); + } let admit = if rv.has_store { Some(store_admission(ctx, &handle, true)) } else { @@ -193,7 +199,7 @@ pub(super) fn emit_body_guard_direct( /// ShapeId against the id, so a declared type (a parameter `p: C`, a /// reassigned binding) serves as well as a proven one — a receiver of any /// other shape misses into the learned supplier. -fn static_region_word(ctx: &FnCtx<'_>, rv: &Receiver) -> Option { +fn static_region_word(ctx: &FnCtx<'_>, rv: &Receiver) -> Option<(u64, u32)> { let class_name = crate::type_analysis::receiver_class_name(ctx, &rv.recv.expr()).or_else(|| { match rv.recv { @@ -210,12 +216,13 @@ fn static_region_word(ctx: &FnCtx<'_>, rv: &Receiver) -> Option { } })?; let keys_global = ctx.class_keys_globals.get(&class_name)?; - let (id, slots) = crate::codegen::static_region_slots(keys_global, &rv.keys, rv.boxed_mask)?; + let (id, slots, r_mask) = + crate::codegen::static_region_slots(keys_global, &rv.keys, rv.boxed_mask)?; let mut word = u64::from(id); for (i, slot) in slots.iter().enumerate() { word |= u64::from(*slot) << (32 + SLOT_BITS * i as u32); } - Some(word) + Some((word, r_mask)) } /// A guard whose receiver the compiler names (DESIGN §4.1, static-exclusive): @@ -225,7 +232,7 @@ fn static_region_word(ctx: &FnCtx<'_>, rv: &Receiver) -> Option { /// no word load and no prime. Any miss selects the generic copy. fn emit_static_guard( ctx: &mut FnCtx<'_>, - rv: &Receiver, + rv: &mut Receiver, word: u64, ) -> Result<(String, String, String)> { note(ctx, Route::RloopGuard); @@ -246,7 +253,17 @@ fn emit_static_guard( let handle = crate::expr::receiver_range::emit_handle(ctx.block(), &test.biased); let sid = field_i32(ctx, &handle, 4); let expected = (word as u32).to_string(); - let eq = ctx.block().icmp_eq(I32, &sid, &expected); + // Region slots remain identical. Refuse a raw write to any CF lane; + // other completed shapes retain the same numeric/boxed slot facts. + let written_slots: Vec = if rv.has_store { + (0..rv.keys.len()) + .map(|i| ((word >> (32 + SLOT_BITS * i as u32)) & ((1 << SLOT_BITS) - 1)) as u32) + .collect() + } else { + Vec::new() + }; + let eq = + crate::typed_shape::emit_compatible_shape_eq(ctx.block(), &sid, &expected, &written_slots); let mut miss_edges = vec![entry_l]; if rv.has_store { let admit = store_admission(ctx, &handle, true); @@ -270,6 +287,12 @@ fn emit_static_guard( let mut edges: Vec<(&str, &str)> = miss_edges.iter().map(|l| ("false", l.as_str())).collect(); edges.push(("true", hit_end.as_str())); let pass = ctx.block().phi(I1, &edges); + // Re-entry must compare the accepted header, which may be a compatible + // completed ConstFn shape. Keep the packed slot word constant so field + // displacements still fold; the extra value is a pointer-free ShapeId. + let mut shape_edges: Vec<(&str, &str)> = miss_edges.iter().map(|l| ("0", l.as_str())).collect(); + shape_edges.push((sid.as_str(), hit_end.as_str())); + rv.expected_shape = Some(ctx.block().phi(I32, &shape_edges)); Ok((word.to_string(), pass, "false".to_string())) } @@ -279,7 +302,8 @@ pub(super) fn emit_guard( ) -> Result<(String, String, String)> { // A receiver whose class the compiler names takes its guard's ShapeId // from the driver's static id (DESIGN §4.1): no loaded supplier. - if let Some(w) = static_region_word(ctx, rv) { + if let Some((w, r_mask)) = static_region_word(ctx, rv) { + rv.r_mask = r_mask; return emit_static_guard(ctx, rv, w); } // A retired region (every bounded prime refused) is decided by the word diff --git a/crates/perry-codegen/src/stmt/region_loop/mod.rs b/crates/perry-codegen/src/stmt/region_loop/mod.rs index b601823ef3..63e3a9eac1 100644 --- a/crates/perry-codegen/src/stmt/region_loop/mod.rs +++ b/crates/perry-codegen/src/stmt/region_loop/mod.rs @@ -64,6 +64,7 @@ use crate::types::{DOUBLE, I1, I16, I32, I64, I8}; mod arrays; mod bare; mod guard; +mod numeric_expression; mod plan; mod verify; @@ -77,11 +78,12 @@ use self::guard::{ decode_slots, emit_body_guard_direct, emit_guard, emit_guard_word, field_i32, handle_of, lower_recv, store_admission, }; +pub(crate) use self::numeric_expression::try_lower_numeric_compare; use self::plan::{ accesses, assigned, body_nodes, body_refused, fact_tree_leaves, plan, receiver_eligible, Plan, Recheck, }; -use self::verify::{successors, verify}; +use self::verify::{successors, verify, verify_exit_effects}; const SLOT_BITS: u32 = 6; const PRIME_ATTEMPTS: &str = "8"; @@ -184,16 +186,51 @@ pub(crate) struct Receiver { /// Bit `i`: a bare store may write `keys[i]` a value not proven a /// canonical double (the word must then give it an `Any` lane). boxed_mask: u32, + /// Bit `i`: a read assumes the key has an identity F64 lane. + r_mask: u32, /// `i1`: the guard matched this receiver's SPILL word (flipped id). spill: String, sites: Option<(String, String)>, /// The region word, an SSA value of the preheader (loop regions) or of /// the tail's guard block (body regions). word: String, + /// Actual accepted header for a static guard; slot bits remain constant. + expected_shape: Option, /// Each key's slot (`i64`), decoded once from `word`. slots: Vec, } +/// Whether this exact fresh bare read is protected by an R bit in an +/// active F clone. The published word (or exact static birth id) guarantees +/// the slot holds a canonical raw JS Number; G and post-loop have no Active +/// fact and therefore never answer true. +pub(crate) fn is_f64_read(ctx: &FnCtx<'_>, e: &Expr) -> bool { + let Expr::PropertyGet { + object, property, .. + } = e + else { + return false; + }; + let Some(recv) = Recv::of(object) else { + return false; + }; + let ptr = e as *const Expr as usize; + // bare::try_lower_bare_get consults only the innermost Active fact. + // Looking through the stack would claim Number for a read the inner + // clone actually lowers through the generic path. + ctx.region_loop_facts.last().is_some_and(|facts| { + facts.bare.contains(&ptr) + && facts.receivers.iter().any(|rv| { + rv.recv == recv + && rv + .keys + .iter() + .position(|key| key == property) + .is_some_and(|i| rv.r_mask & (1 << i) != 0) + }) + }) +} + /// A region whose body has not been lowered yet: `lower_stmts` recognises the /// body slice by address and splits it. #[derive(Clone)] @@ -209,6 +246,11 @@ pub(crate) struct Pending { recheck: Recheck, receivers: Vec, bare: HashSet, + bare_reads: Vec<(usize, Recv, String)>, + number_local_uses: HashSet, + declared_locals: HashSet, + number_locals: Vec, + entry_tests: Vec, trees: HashSet, token: u64, /// Loop regions: which split copy [`lower_loop`] is lowering — the one @@ -311,7 +353,7 @@ fn candidates_for_loop( let written = assigned(body, &extra); accesses(body) .into_iter() - .map(|(r, _, _)| r) + .map(|(r, _, _, _, _)| r) .filter(|r| match r { Recv::Local(id) => !written.contains(id), Recv::This => true, @@ -419,15 +461,17 @@ fn begin_with( let mut receivers: Vec = p .receivers .iter() - .map(|(r, k, st, sm, bm)| Receiver { + .map(|(r, k, st, sm, bm, rm)| Receiver { recv: *r, keys: k.clone(), has_store: *st, stored_mask: effective_stored_mask(*sm, k.len()), boxed_mask: *bm, + r_mask: *rm, spill: "false".to_string(), sites: None, word: String::new(), + expected_shape: None, slots: Vec::new(), }) .collect(); @@ -449,6 +493,18 @@ fn begin_with( all = ctx.block().and(I1, &all, &pass); arrs.push(a); } + let (number_locals, entry_tests) = number_facts( + ctx, + body, + &receivers, + &p.bare_reads, + &p.number_local_uses, + &p.declared_locals, + ); + if !entry_tests.is_empty() { + let number_ok = emit_number_entry_tests(ctx, &entry_tests)?; + all = ctx.block().and(I1, &all, &number_ok); + } ctx.block().store(I1, &all, &valid_slot); let dirty_slot = ctx.func.alloca_entry(I1); ctx.block().store(I1, "false", &dirty_slot); @@ -468,6 +524,11 @@ fn begin_with( recheck: p.recheck, receivers, bare: p.bare, + bare_reads: p.bare_reads, + number_local_uses: p.number_local_uses, + declared_locals: p.declared_locals, + number_locals, + entry_tests, trees: p.trees, token, spill_mode: false, @@ -530,6 +591,101 @@ fn body_region_plan(ctx: &FnCtx<'_>, body: &[Stmt]) -> Option<(usize, Plan)> { None } +/// Resolve the planner's exact fresh reads against the R guaranteed by +/// each receiver's chosen supplier, then run the existing Number-local +/// greatest fixed point with those reads as additional leaves. +fn number_facts( + ctx: &FnCtx<'_>, + tail: &[Stmt], + receivers: &[Receiver], + bare_reads: &[(usize, Recv, String)], + number_local_uses: &HashSet, + declared_locals: &HashSet, +) -> (Vec, Vec) { + let f64_reads: HashSet = bare_reads + .iter() + .filter_map(|(ptr, recv, key)| { + receivers.iter().find(|rv| rv.recv == *recv).and_then(|rv| { + rv.keys + .iter() + .position(|k| k == key) + .filter(|i| rv.r_mask & (1 << i) != 0) + .map(|_| *ptr) + }) + }) + .collect(); + number_facts_from_reads(ctx, tail, &f64_reads, number_local_uses, declared_locals) +} + +/// The same 5L fixed point is used while planning and while lowering. The +/// planner supplies only exact fresh bare reads protected by its proposed R. +fn number_facts_from_reads( + ctx: &FnCtx<'_>, + tail: &[Stmt], + f64_reads: &HashSet, + number_local_uses: &HashSet, + declared_locals: &HashSet, +) -> (Vec, Vec) { + if f64_reads.is_empty() { + return (Vec::new(), Vec::new()); + } + let entry_candidates: HashSet = number_local_uses + .iter() + .copied() + .filter(|id| { + ctx.locals.contains_key(id) + && !declared_locals.contains(id) + && !ctx.boxed_vars.contains(id) + && !ctx.module_globals.contains_key(id) + && !ctx.number_by_construction_locals.contains(id) + }) + .collect(); + let empty_inits = HashMap::new(); + let empty_ids = HashSet::new(); + let empty_fields = HashSet::new(); + let assumptions = crate::collectors::RegionNumberAssumptions { + entry_candidates: &entry_candidates, + static_numbers: ctx.number_by_construction_locals, + f64_reads, + }; + let numeric = crate::collectors::collect_numeric_by_construction_locals_in_region( + tail, + &ctx.boxed_vars, + ctx.module_globals, + ctx.not_bigint_locals, + &empty_inits, + &empty_ids, + &empty_ids, + &empty_fields, + Some(&assumptions), + ); + let mut locals: Vec = numeric + .iter() + .copied() + .filter(|id| !ctx.number_by_construction_locals.contains(id)) + .collect(); + locals.sort_unstable(); + let mut tests: Vec = locals + .iter() + .copied() + .filter(|id| entry_candidates.contains(id)) + .collect(); + tests.sort_unstable(); + (locals, tests) +} + +/// Strict Number entry condition for each loop-carried local that the F +/// clone assumes. The same check is repeated when G can re-enter F. +fn emit_number_entry_tests(ctx: &mut FnCtx<'_>, ids: &[u32]) -> Result { + let mut ok = "true".to_string(); + for &id in ids { + let value = lower_expr(ctx, &Expr::LocalGet(id))?; + let number = crate::stmt::loops::emit_js_value_is_number(ctx, &value); + ok = ctx.block().and(I1, &ok, &number); + } + Ok(ok) +} + /// A body region's pending split at `split_at`. fn body_pending(p: Plan, body: &[Stmt], split_at: usize) -> Pending { let token = NEXT_TOKEN.with(|t| { @@ -540,15 +696,17 @@ fn body_pending(p: Plan, body: &[Stmt], split_at: usize) -> Pending { let receivers: Vec = p .receivers .iter() - .map(|(r, k, st, sm, bm)| Receiver { + .map(|(r, k, st, sm, bm, rm)| Receiver { recv: *r, keys: k.clone(), has_store: *st, stored_mask: effective_stored_mask(*sm, k.len()), boxed_mask: *bm, + r_mask: *rm, spill: "false".to_string(), sites: None, word: String::new(), + expected_shape: None, slots: Vec::new(), }) .collect(); @@ -561,6 +719,11 @@ fn body_pending(p: Plan, body: &[Stmt], split_at: usize) -> Pending { recheck: Recheck::None, receivers, bare: p.bare, + bare_reads: p.bare_reads, + number_local_uses: p.number_local_uses, + declared_locals: p.declared_locals, + number_locals: Vec::new(), + entry_tests: Vec::new(), trees: p.trees, token, spill_mode: false, @@ -691,9 +854,7 @@ pub(crate) fn end(ctx: &mut FnCtx<'_>, token: Option) { /// not split: G-body contains calls, and those tiers discard a clone that /// does. fn in_call_free_clone(ctx: &FnCtx<'_>) -> bool { - !ctx.class_field_loop_facts.is_empty() - || !ctx.element_shape_loop_facts.is_empty() - || !ctx.stable_packed_loop_facts.is_empty() + !ctx.element_shape_loop_facts.is_empty() || !ctx.stable_packed_loop_facts.is_empty() } /// `lower_stmts`' hook: is `stmts` a registered region body? @@ -745,6 +906,11 @@ pub(crate) fn lower_split( }; let retry = ctx.region_loops[idx].retry.clone(); let bare = ctx.region_loops[idx].bare.clone(); + let bare_reads = ctx.region_loops[idx].bare_reads.clone(); + let number_local_uses = ctx.region_loops[idx].number_local_uses.clone(); + let declared_locals = ctx.region_loops[idx].declared_locals.clone(); + let planned_number_locals = ctx.region_loops[idx].number_locals.clone(); + let planned_entry_tests = ctx.region_loops[idx].entry_tests.clone(); let trees = ctx.region_loops[idx].trees.clone(); let dirty_slot = ctx.region_loops[idx].dirty_slot.clone(); let arrs = ctx.region_loops[idx].arrays.clone(); @@ -772,7 +938,7 @@ pub(crate) fn lower_split( // Where the entry decision is emitted; its terminator is written LAST, // once `verify` has judged F-body. - let decide; + let mut decide; let mut decide_top: Option<(usize, String, String)> = None; // With a nested body region the top tests the dirty flag first: G-body // sets it to come back (`Pending::retry`); `decide_top` then carries @@ -814,7 +980,11 @@ pub(crate) fn lower_split( let recv_box = lower_recv(ctx, rv.recv)?; let h = handle_of(ctx, &recv_box); let sid = field_i32(ctx, &h, 4); - let exp = ctx.block().trunc(I64, &rv.word, I32); + let exp = if let Some(expected) = &rv.expected_shape { + expected.clone() + } else { + ctx.block().trunc(I64, &rv.word, I32) + }; // The spill copy runs only on flipped (spill) words. let exp = if modes[0] { ctx.block().xor(I32, &exp, FLIP_I32) @@ -834,6 +1004,10 @@ pub(crate) fn lower_split( let pass = arrays::emit_guard(ctx, a)?; ok = ctx.block().and(I1, &ok, &pass); } + if !planned_entry_tests.is_empty() { + let number_ok = emit_number_entry_tests(ctx, &planned_entry_tests)?; + ok = ctx.block().and(I1, &ok, &number_ok); + } ctx.block().store(I1, &ok, slot); ctx.block().store(I1, "false", &d_slot); let rc_idx = ctx.current_block; @@ -865,6 +1039,23 @@ pub(crate) fn lower_split( } } + let (number_locals, entry_tests) = if valid_slot.is_some() { + (planned_number_locals, planned_entry_tests) + } else { + number_facts( + ctx, + tail, + &receivers, + &bare_reads, + &number_local_uses, + &declared_locals, + ) + }; + if valid_slot.is_none() && direct.is_none() && !entry_tests.is_empty() { + let number_ok = emit_number_entry_tests(ctx, &entry_tests)?; + decide.1 = ctx.block().and(I1, &decide.1, &number_ok); + } + // F-body, once per layout; each copy is verified on its own IR. let mut copies: Vec<(String, bool)> = Vec::with_capacity(modes.len()); for (ci, &mode) in modes.iter().enumerate() { @@ -876,10 +1067,16 @@ pub(crate) fn lower_split( let fl = ctx.block_label(fb); ctx.current_block = fb; note(ctx, Route::RloopF); + if receivers.iter().any(|rv| rv.r_mask != 0) { + note(ctx, Route::RloopFRep); + } if let Some(d) = &retry { ctx.block().store(I1, "true", d); } let scan_start = ctx.func.num_blocks(); + let number_scope = ctx.next_loop_proof_scope_id(); + ctx.receiver_descriptors + .materialize_number_locals(number_scope, &number_locals); ctx.region_loop_facts.push(Active { receivers: receivers.clone(), bare: bare.clone(), @@ -902,6 +1099,7 @@ pub(crate) fn lower_split( None => lower_list(ctx, tail), }; let active = ctx.region_loop_facts.pop().expect("pushed above"); + ctx.receiver_descriptors.dematerialize_scope(number_scope); r?; if !ctx.block().is_terminated() { ctx.block().br(&join_l); @@ -916,6 +1114,15 @@ pub(crate) fn lower_split( .copied() .collect(); let ok = verify(ctx, fb, scan_start, scan_end, &all_emitted) + && verify_exit_effects( + ctx, + fb, + scan_start, + scan_end, + recheck, + dirty_slot.as_deref(), + valid_slot.as_deref(), + ) && arrays::verify_arrays( ctx, fb, @@ -998,7 +1205,16 @@ pub(crate) fn lower_split( slow_l.clone() }; let rv = receivers[0].clone(); - emit_body_guard_direct(ctx, &rv, sites, word, &inline_t, &spill_t, &slow_l)?; + emit_body_guard_direct( + ctx, + &rv, + sites, + word, + &entry_tests, + &inline_t, + &spill_t, + &slow_l, + )?; } else if copies.len() == 2 { // Body region: the guard passed -> pick the copy for the word's layout. let target = |c: &(String, bool)| if c.1 { c.0.clone() } else { slow_l.clone() }; diff --git a/crates/perry-codegen/src/stmt/region_loop/numeric_expression.rs b/crates/perry-codegen/src/stmt/region_loop/numeric_expression.rs new file mode 100644 index 0000000000..72d784ebd9 --- /dev/null +++ b/crates/perry-codegen/src/stmt/region_loop/numeric_expression.rs @@ -0,0 +1,242 @@ +//! A single numeric comparison within the existing loop and exception scope. +//! +//! The statement/handler tree is lowered once. Only the named read and its +//! comparison are versioned, after every earlier evaluation effect. No fact +//! escapes the expression, and the generic arm sees the original operands. + +use super::*; + +#[cfg(test)] +mod tests; + +#[cfg(test)] +thread_local! { + static TEST_REENTER_BEFORE_READ: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +fn named_read<'a>(ctx: &FnCtx<'_>, e: &'a Expr) -> Option<(Recv, &'a str)> { + let Expr::PropertyGet { + object, property, .. + } = e + else { + return None; + }; + let recv = Recv::of(object)?; + if !receiver_eligible(ctx, recv) + || matches!(recv, Recv::Local(id) if ctx.closure_captures.contains_key(&id)) + { + return None; + } + Some((recv, property)) +} + +fn number_literal(e: &Expr) -> bool { + match e { + Expr::Integer(_) => true, + // Mirror the strict entry predicate, including negative NaN payloads. + Expr::Number(n) => n.to_bits() & 0x7fff_ffff_ffff_ffff < 0x7ff9_0000_0000_0000, + _ => false, + } +} + +fn active(receivers: Vec, read: Option<&Expr>) -> Active { + Active { + receivers, + bare: read + .into_iter() + .map(|e| e as *const Expr as usize) + .collect(), + trees: HashSet::new(), + dirty_slot: None, + emitted: Vec::new(), + handles: Vec::new(), + spill: false, + arrays: Vec::new(), + emitted_arr: Vec::new(), + } +} + +pub(crate) fn try_lower_numeric_compare( + ctx: &mut FnCtx<'_>, + expr: &Expr, + generic: fn(&mut FnCtx<'_>, &Expr) -> Result, +) -> Result> { + if disabled() + || ctx.loop_targets.is_empty() + || !ctx.region_loop_facts.is_empty() + || !ctx.label_targets.is_empty() + || !ctx.pending_labels.is_empty() + { + return Ok(None); + } + let Expr::Compare { op, left, right } = expr else { + return Ok(None); + }; + if !matches!( + op, + CompareOp::Lt | CompareOp::Le | CompareOp::Gt | CompareOp::Ge + ) { + return Ok(None); + } + if expr_refused(left) || expr_refused(right) { + return Ok(None); + } + // A right read admits an arbitrary, already-evaluated left value. A left + // read admits only an inert Number literal on the right: no later getter + // or callback may run between the guard and that bare read. + let (read, recv, key, read_left) = if let Some((recv, key)) = named_read(ctx, right) { + (right.as_ref(), recv, key, false) + } else if number_literal(right) { + let Some((recv, key)) = named_read(ctx, left) else { + return Ok(None); + }; + (left.as_ref(), recv, key, true) + } else { + return Ok(None); + }; + // Only the read and comparison are copied, regardless of how expensive + // evaluating the saved left operand is. Retain the region cost gate. + if !pays(ctx, "numeric-expression", 4, 1) { + return Ok(None); + } + if read_left { + return emit(ctx, expr, *op, left, right, read, recv, key, true, generic).map(Some); + } + // This root owns the LEFT GetValue, not the right receiver. The exact-node + // materialization hook makes both arms reuse it, even if a guard helper + // collects. It also preserves one evaluation when the left getter mutates + // the right receiver's shape, descriptor, prototype or representation. + let saved = lower_expr(ctx, left)?; + crate::rooting::with_materialized_receiver( + ctx, + left.as_ref() as *const Expr as usize, + &saved, + |ctx| emit(ctx, expr, *op, left, right, read, recv, key, false, generic), + ) + .map(Some) +} + +#[allow(clippy::too_many_arguments)] +fn emit( + ctx: &mut FnCtx<'_>, + expr: &Expr, + op: CompareOp, + left: &Expr, + right: &Expr, + read: &Expr, + recv: Recv, + key: &str, + read_left: bool, + generic: fn(&mut FnCtx<'_>, &Expr) -> Result, +) -> Result { + let mut rv = Receiver { + recv, + keys: vec![key.to_string()], + has_store: false, + // This expression never stores. R already requires an inline F64 + // identity lane, so it also refuses spill words without a store bit. + stored_mask: 0, + boxed_mask: 0, + r_mask: 1, + spill: "false".to_string(), + sites: None, + word: String::new(), + expected_shape: None, + slots: Vec::new(), + }; + let (sites, word) = emit_guard_word(ctx, &mut rv); + decode_slots(ctx, &mut rv, &word); + let guard = ctx.current_block; + let number = ctx.new_block("rexpr.number"); + let fast = ctx.new_block("rexpr.fast"); + let slow = ctx.new_block("rexpr.slow"); + let join = ctx.new_block("rexpr.merge"); + let number_l = ctx.block_label(number); + let fast_l = ctx.block_label(fast); + let slow_l = ctx.block_label(slow); + let join_l = ctx.block_label(join); + + ctx.current_block = number; + if read_left { + ctx.block().br(&fast_l); + } else { + let saved = lower_expr(ctx, left)?; + let canonical = crate::stmt::loops::emit_js_value_is_number(ctx, &saved); + ctx.block().cond_br(&canonical, &fast_l, &slow_l); + } + + ctx.current_block = fast; + ctx.region_loop_facts + .push(active(vec![rv.clone()], Some(read))); + let result: Result = (|| { + // Negative control: the emitted verifier must discard a genuinely + // JS-capable invoke inserted before the protected load. Test-only, + // thread-local, and absent from shipping builds and feature graphs. + #[cfg(test)] + if TEST_REENTER_BEFORE_READ.with(|flag| flag.get()) { + ctx.block() + .call(DOUBLE, "js_rel_lt", &[(DOUBLE, "0.0"), (DOUBLE, "0.0")]); + } + // The entry Number proof applies to the saved operand only. Do not + // materialize a Number fact for its source local or any later read. + let l = lower_expr(ctx, left)?; + let r = lower_expr(ctx, right)?; + note(ctx, Route::RloopF); + note(ctx, Route::RloopFRep); + let pred = match op { + CompareOp::Lt => "olt", + CompareOp::Le => "ole", + CompareOp::Gt => "ogt", + CompareOp::Ge => "oge", + _ => unreachable!("relational admission above"), + }; + let bit = ctx.block().fcmp(pred, &l, &r); + let bits = ctx.block().select( + I1, + &bit, + I64, + crate::nanbox::TAG_TRUE_I64, + crate::nanbox::TAG_FALSE_I64, + ); + Ok(ctx.block().bitcast_i64_to_double(&bits)) + })(); + // Restore on compiler error as well as normal paths. No unwind path is + // lowered while these facts are installed: only a bare load and fcmp. + let facts = ctx + .region_loop_facts + .pop() + .expect("expression fact installed"); + let f = result?; + let f_end = ctx.block().label.clone(); + ctx.block().br(&join_l); + ctx.current_block = slow; + note(ctx, Route::RloopG); + // Empty innermost facts suppress overlapping expression selection while + // the same original expression is lowered by the generic comparator. + ctx.region_loop_facts.push(active(Vec::new(), None)); + let result = generic(ctx, expr); + ctx.region_loop_facts + .pop() + .expect("generic suppression installed"); + let g = result?; + let g_end = ctx.block().label.clone(); + ctx.block().br(&join_l); + + // Walk the actual guard-to-read path before committing the entry edge. + // A future JS-capable invoke inserted anywhere in that window discards F. + let check = ctx.new_block("rexpr.guard"); + let check_l = ctx.block_label(check); + ctx.current_block = check; + // Prime is bounded and publishes only for the next visit. Guard after + // left effects with the same full descriptor/prototype/rep admission. + emit_body_guard_direct(ctx, &rv, &sites, &word, &[], &number_l, &slow_l, &slow_l)?; + let verified = facts.emitted.len() == 1 + && verify(ctx, check, number, ctx.func.num_blocks(), &facts.emitted); + if !verified { + stat(4, 1); + } + ctx.current_block = guard; + ctx.block().br(if verified { &check_l } else { &slow_l }); + ctx.current_block = join; + Ok(ctx.block().phi(DOUBLE, &[(&f, &f_end), (&g, &g_end)])) +} diff --git a/crates/perry-codegen/src/stmt/region_loop/numeric_expression/tests.rs b/crates/perry-codegen/src/stmt/region_loop/numeric_expression/tests.rs new file mode 100644 index 0000000000..062ffd9363 --- /dev/null +++ b/crates/perry-codegen/src/stmt/region_loop/numeric_expression/tests.rs @@ -0,0 +1,343 @@ +use super::*; +use perry_hir::{types::Type, CatchClause, Function, Module, Param}; + +fn get(object: Expr, key: &str) -> Expr { + Expr::PropertyGet { + object: Box::new(object), + property: key.into(), + byte_offset: 0, + } +} + +fn compare(op: CompareOp, left: Expr, right: Expr) -> Expr { + Expr::Compare { + op, + left: Box::new(left), + right: Box::new(right), + } +} + +fn ir(body: Vec, in_loop: bool) -> String { + let param = |id| Param { + id, + name: format!("p{id}"), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }; + let protected = Stmt::Try { + body, + catch: Some(CatchClause { + param: None, + body: Vec::new(), + }), + finally: None, + }; + let body = if in_loop { + vec![Stmt::While { + condition: Expr::Bool(true), + body: vec![protected, Stmt::Break], + }] + } else { + vec![protected] + }; + let mut module = Module::new("bounded_numeric"); + module.functions.push(Function { + id: 1, + name: "probe".into(), + type_params: Vec::new(), + params: vec![param(1), param(2)], + return_type: Type::Any, + body, + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }); + let mut opts = crate::temp_root_coverage::entry_opts(); + opts.is_entry_module = false; + String::from_utf8(crate::compile_module(&module, opts).expect("bounded expression compiles")) + .unwrap() +} + +fn function(ir: &str) -> &str { + let start = ir + .find("define double @perry_fn_bounded_numeric__probe") + .expect("probe body"); + let body = &ir[start..]; + &body[..body.find("\n}").expect("end of probe")] +} + +#[test] +fn numeric_expression_all_relations_and_orientations_use_exact_r_guard() { + for (op, pred) in [ + (CompareOp::Lt, "olt"), + (CompareOp::Le, "ole"), + (CompareOp::Gt, "ogt"), + (CompareOp::Ge, "oge"), + ] { + for read_left in [true, false] { + let field = get(Expr::LocalGet(1), "value"); + let expr = if read_left { + compare(op, field, Expr::Integer(166)) + } else { + compare(op, Expr::LocalGet(2), field) + }; + let output = ir(vec![Stmt::Expr(expr)], true); + let body = function(&output); + assert!(body.contains("rexpr.fast"), "{op:?} left={read_left}"); + assert!(body.contains(&format!("fcmp {pred} double"))); + let prime = body + .lines() + .find(|l| l.contains("@js_region_loop_prime(")) + .expect("fresh runtime supplier"); + assert!( + prime.contains("i32 0, i32 0, i32 1)"), + "stored=0, boxed=0, R=1: {prime}" + ); + assert_eq!( + body.matches("landingpad ").count(), + 1, + "single handler tree" + ); + assert!( + !body.contains("rloop.version.split"), + "whole Try body still refused" + ); + } + } +} + +#[test] +fn numeric_expression_saves_left_once_before_guard_and_keeps_eh_target() { + crate::temp_root_coverage::under_both_lowerings(|_| saves_left_once()); +} + +fn saves_left_once() { + let left = get(get(Expr::LocalGet(2), "data"), "length"); + let output = ir( + vec![Stmt::Expr(compare( + CompareOp::Lt, + left, + get(Expr::LocalGet(1), "value"), + ))], + true, + ); + let body = function(&output); + let fast = body.find("rexpr.fast").expect("expression region"); + let prime = body.find("@js_region_loop_prime(").unwrap(); + assert!( + body[..fast].contains("invoke "), + "left getter retains invoke before guard" + ); + assert!( + body.contains("9223372036854775807"), + "saved value gets strict Number entry check" + ); + // The result of the left GetValue is the final property-result phi + // before the guard word load. Assert that exact value is a scoped root, + // and that F rereads that slot below the guard rather than retaining SSA. + let fast_block = body + .split("rexpr.fast.") + .find(|s| s.lines().next().is_some_and(|l| l.ends_with(':'))) + .unwrap(); + let fast_body = fast_block.split("\n\n").next().unwrap(); + let guard_load = body + .lines() + .find(|l| l.contains("load atomic i64") && l.contains("_region ")) + .unwrap(); + let before_guard = &body[..body.find(guard_load).unwrap()]; + let slot = before_guard + .lines() + .filter_map(|line| { + line.contains(" = phi double ") + .then(|| line.trim().split(" =").next().unwrap()) + }) + .find_map(|value| { + crate::testing::temp_slots::temp_root_slot_holding(body, value) + .filter(|slot| fast_body.contains(&format!("ptr {slot}"))) + }) + .expect("saved left GetValue has a managed temporary root reread in F"); + assert!( + fast_body.contains(&format!("ptr {slot}")), + "F rereads saved left root" + ); + assert!( + fast_body.contains("getelementptr double"), + "exact R load is reached in F" + ); + assert!( + !fast_body.contains("@js_object_get_field"), + "F read is bare" + ); + assert!( + body[..prime].contains(&format!("ptr {slot}")), + "root published before prime" + ); + let unwind_targets: HashSet<_> = body + .lines() + .filter_map(|l| l.split("unwind label %").nth(1)) + .collect(); + assert_eq!( + unwind_targets.len(), + 1, + "left reads, fallback getters and coercions retain same active pad" + ); + assert_eq!( + body.lines() + .filter(|l| l.contains("@js_object_get_field_ic_slow(")) + .count(), + 3, + "data, length and G value have exactly one lowering each; F value is bare" + ); +} + +#[test] +fn numeric_expression_js_invoke_before_read_discards_f() { + TEST_REENTER_BEFORE_READ.with(|flag| flag.set(true)); + let output = ir( + vec![Stmt::Expr(compare( + CompareOp::Lt, + get(Expr::LocalGet(1), "value"), + Expr::Integer(166), + ))], + true, + ); + TEST_REENTER_BEFORE_READ.with(|flag| flag.set(false)); + let body = function(&output); + assert!( + body.lines() + .any(|l| l.contains("invoke double @js_rel_lt(double 0.0, double 0.0)")), + "negative control is a JS-capable invoke in the active handler" + ); + let guard_load = body + .lines() + .find(|l| l.contains("load atomic i64") && l.contains("_region ")) + .unwrap(); + let decision = &body[body.find(guard_load).unwrap()..]; + let branch = decision + .lines() + .find(|l| l.trim_start().starts_with("br label %")) + .unwrap(); + assert!( + branch.contains("%rexpr.slow."), + "unverified F cannot execute: {branch}" + ); +} + +#[test] +fn numeric_expression_boxed_receiver_is_refused() { + let closure = Expr::Closure { + func_id: 2, + params: Vec::new(), + return_type: Type::Any, + body: vec![Stmt::Expr(Expr::LocalSet(1, Box::new(Expr::Undefined)))], + captures: vec![1], + mutable_captures: vec![1], + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: true, + is_async: false, + is_generator: false, + is_strict: true, + }; + let output = ir( + vec![ + Stmt::Expr(closure), + Stmt::Expr(compare( + CompareOp::Ge, + get(Expr::LocalGet(1), "value"), + Expr::Integer(166), + )), + ], + true, + ); + assert!(!function(&output).contains("rexpr.fast")); +} + +#[test] +fn numeric_expression_refuses_equality_dynamic_receiver_and_straight_line() { + for expr in [ + compare( + CompareOp::Eq, + get(Expr::LocalGet(1), "value"), + Expr::Integer(1), + ), + compare( + CompareOp::Lt, + get(get(Expr::LocalGet(1), "nested"), "value"), + Expr::Integer(1), + ), + compare( + CompareOp::Lt, + get(Expr::LocalGet(1), "value"), + get(get(Expr::LocalGet(2), "nested"), "other"), + ), + ] { + assert!(!function(&ir(vec![Stmt::Expr(expr)], true)).contains("rexpr.fast")); + } + let expr = compare( + CompareOp::Ge, + get(Expr::LocalGet(1), "value"), + Expr::Integer(166), + ); + assert!(!function(&ir(vec![Stmt::Expr(expr)], false)).contains("rexpr.fast")); + let labeled = Stmt::Labeled { + label: "outer".into(), + body: Box::new(Stmt::Expr(compare( + CompareOp::Ge, + get(Expr::LocalGet(1), "value"), + Expr::Integer(166), + ))), + }; + assert!(!function(&ir(vec![labeled], true)).contains("rexpr.fast")); +} + +#[test] +fn numeric_expression_number_proof_does_not_escape_to_following_reads() { + let before = compare( + CompareOp::Lt, + Expr::LocalGet(2), + get(Expr::LocalGet(1), "value"), + ); + let after = compare(CompareOp::Lt, Expr::LocalGet(2), Expr::LocalGet(1)); + let output = ir(vec![Stmt::Expr(before), Stmt::Expr(after)], true); + let body = function(&output); + let merge = body.find("rexpr.merge.").unwrap(); + assert!( + body[merge..].contains("@js_rel_lt("), + "unproven locals retain generic comparison after F/G" + ); +} + +#[test] +fn numeric_expression_special_numbers_remain_ordered_native_comparisons() { + for n in [f64::NAN, -0.0, f64::INFINITY, f64::NEG_INFINITY] { + let output = ir( + vec![Stmt::Expr(compare( + CompareOp::Le, + get(Expr::LocalGet(1), "value"), + Expr::Number(n), + ))], + true, + ); + assert!(function(&output).contains("fcmp ole double")); + } + let bad = f64::from_bits(0xfffd_0000_0000_0001); + let output = ir( + vec![Stmt::Expr(compare( + CompareOp::Lt, + get(Expr::LocalGet(1), "value"), + Expr::Number(bad), + ))], + true, + ); + assert!(!function(&output).contains("rexpr.fast")); +} diff --git a/crates/perry-codegen/src/stmt/region_loop/plan.rs b/crates/perry-codegen/src/stmt/region_loop/plan.rs index 1990c10876..17bb2850a1 100644 --- a/crates/perry-codegen/src/stmt/region_loop/plan.rs +++ b/crates/perry-codegen/src/stmt/region_loop/plan.rs @@ -88,6 +88,35 @@ pub(super) fn fact_tree_leaves<'e>( Some((recv?, reads)) } +/// Candidate reads inside a Number-consuming expression. The planner's +/// exact bare-access set filters these by freshness after the walk. +fn number_operand_reads(e: &Expr, out: &mut Vec<(usize, Recv, String)>) { + if let Expr::PropertyGet { + object, property, .. + } = e + { + if let Some(r) = Recv::of(object) { + out.push((e as *const Expr as usize, r, property.clone())); + } + } + perry_hir::walker::walk_expr_children(e, &mut |child| number_operand_reads(child, out)); +} + +/// Local value operands whose incoming Number-ness can be discharged by +/// the region's strict entry tests. A receiver beneath PropertyGet is an +/// object, not a candidate Number value. +fn number_operand_locals(e: &Expr, out: &mut HashSet) { + match e { + Expr::LocalGet(id) => { + out.insert(*id); + return; + } + Expr::PropertyGet { .. } => return, + _ => {} + } + perry_hir::walker::walk_expr_children(e, &mut |child| number_operand_locals(child, out)); +} + pub(super) fn kill(st: &mut St) { if let Some(m) = st { m.clear(); @@ -121,12 +150,20 @@ pub(super) struct Planner<'p, 'a> { /// Array receivers (S3) and their static index bound. arrays: &'p HashMap, bare: HashSet, + /// Potential R keys, filtered against exact fresh bare reads at finish. + number_reads: Vec<(usize, Recv, String)>, + number_local_uses: HashSet, + bare_reads: Vec<(usize, Recv, String)>, bare_arrays: HashSet, /// A body region nested in this loop region (array regions): while its /// tail is walked, its bare accesses run no JS and its fact trees only /// set the loop's dirty flag. inner: Option<(&'p HashSet, &'p HashSet)>, in_inner: bool, + /// A store RHS can use R to prove its own numeric reads. Its add must + /// stay an ordinary bare-read add, not the generic fact tree (which + /// dirties freshness before the following store). + in_store_rhs: bool, trees: HashSet, bare_stores: HashSet, /// Per receiver, the keys a planned-bare store may write a value the @@ -135,6 +172,10 @@ pub(super) struct Planner<'p, 'a> { boxed_stores: HashMap>, continues: Vec, record: bool, + /// Exact R reads and scoped 5L locals proved by an earlier planning pass. + /// A read is usable only after this pass has itself made it bare. + proof_reads: &'p HashSet, + proof_locals: &'p HashSet, } impl Planner<'_, '_> { @@ -142,7 +183,16 @@ impl Planner<'_, '_> { /// double (`expr_produces_canonical_raw_f64` is the predicate that already /// licenses an unguarded `fadd`). fn prim(&self, e: &Expr) -> bool { - prim(e) || crate::type_analysis::is_numeric_expr(self.ctx, e) + match e { + Expr::PropertyGet { .. } => { + let ptr = e as *const Expr as usize; + self.bare.contains(&ptr) && self.proof_reads.contains(&ptr) + } + Expr::LocalGet(id) if self.proof_locals.contains(id) => true, + Expr::Binary { left, right, .. } => self.prim(left) && self.prim(right), + Expr::Unary { op, operand } if !matches!(op, UnaryOp::Not) => self.prim(operand), + _ => prim(e) || crate::type_analysis::is_numeric_expr(self.ctx, e), + } } fn covered(&self, r: Recv, key: &str) -> bool { @@ -156,7 +206,11 @@ impl Planner<'_, '_> { let fresh = st.as_ref().is_some_and(|m| m.get(&r) == Some(&FRESH)); if fresh && self.cands.contains(&r) && self.covered(r, key) { if self.record { - self.bare.insert(e as *const Expr as usize); + let ptr = e as *const Expr as usize; + self.bare.insert(ptr); + if !store { + self.bare_reads.push((ptr, r, key.to_string())); + } if store { self.bare_stores.insert(r); if boxed { @@ -218,7 +272,10 @@ impl Planner<'_, '_> { } => { st = self.expr(target, st); st = self.expr(key, st); + let outer_rhs = self.in_store_rhs; + self.in_store_rhs = true; st = self.expr(value, st); + self.in_store_rhs = outer_rhs; match (Recv::of(target), key.as_ref()) { (Some(r), Expr::String(k)) if Recv::of(receiver) == Some(r) => { // The same predicate the bare store lowers with @@ -265,21 +322,38 @@ impl Planner<'_, '_> { st } Expr::Binary { left, right, .. } => { + if self.record { + number_operand_reads(left, &mut self.number_reads); + number_operand_reads(right, &mut self.number_reads); + number_operand_locals(left, &mut self.number_local_uses); + number_operand_locals(right, &mut self.number_local_uses); + } let keys = self.keys; let cands = self.cands; let covered = |r: Recv, k: &str| { cands.contains(&r) && keys.get(&r).is_some_and(|l| l.iter().any(|x| x == k)) }; - if let Some((r, reads)) = fact_tree_leaves(e, &covered) { - if st.as_ref().is_some_and(|m| m.get(&r) == Some(&FRESH)) { - if self.record { - self.trees.insert(e as *const Expr as usize); - for l in reads { - self.bare.insert(l as *const Expr as usize); + if !self.in_store_rhs { + if let Some((r, reads)) = fact_tree_leaves(e, &covered) { + if st.as_ref().is_some_and(|m| m.get(&r) == Some(&FRESH)) { + if self.record { + self.trees.insert(e as *const Expr as usize); + for l in reads { + let ptr = l as *const Expr as usize; + self.bare.insert(ptr); + if let Expr::PropertyGet { + object, property, .. + } = l + { + if let Some(r) = Recv::of(object) { + self.bare_reads.push((ptr, r, property.clone())); + } + } + } } + dirty(&mut st); + return st; } - dirty(&mut st); - return st; } } st = self.expr(left, st); @@ -290,6 +364,10 @@ impl Planner<'_, '_> { st } Expr::Unary { op, operand } => { + if self.record && !matches!(op, UnaryOp::Not) { + number_operand_reads(operand, &mut self.number_reads); + number_operand_locals(operand, &mut self.number_local_uses); + } st = self.expr(operand, st); if !matches!(op, UnaryOp::Not) && !self.prim(operand) { kill(&mut st); @@ -297,6 +375,17 @@ impl Planner<'_, '_> { st } Expr::Compare { op, left, right } => { + if self.record + && matches!( + op, + CompareOp::Lt | CompareOp::Le | CompareOp::Gt | CompareOp::Ge + ) + { + number_operand_reads(left, &mut self.number_reads); + number_operand_reads(right, &mut self.number_reads); + number_operand_locals(left, &mut self.number_local_uses); + number_operand_locals(right, &mut self.number_local_uses); + } st = self.expr(left, st); st = self.expr(right, st); if !matches!(op, CompareOp::Eq | CompareOp::Ne) @@ -665,26 +754,27 @@ pub(super) fn assigned(ss: &[Stmt], extra: &[&Expr]) -> HashSet { } /// Static-key accesses in `ss` per candidate receiver, in first-seen key -/// order: `(receiver, key, is_store)`. -pub(super) fn accesses(ss: &[Stmt]) -> Vec<(Recv, String, bool)> { - fn e_walk(e: &Expr, out: &mut Vec<(Recv, String, bool)>) { +/// order: `(receiver, key, is_store, expression identity, store value)`. +pub(super) fn accesses(ss: &[Stmt]) -> Vec<(Recv, String, bool, usize, Option<&Expr>)> { + fn e_walk<'e>(e: &'e Expr, out: &mut Vec<(Recv, String, bool, usize, Option<&'e Expr>)>) { match e { Expr::PropertyGet { object, property, .. } => { if let Some(r) = Recv::of(object) { - out.push((r, property.clone(), false)); + out.push((r, property.clone(), false, e as *const Expr as usize, None)); } } Expr::PutValueSet { target, key, receiver, + value, .. } => { if let (Some(r), Expr::String(k)) = (Recv::of(target), key.as_ref()) { if Recv::of(receiver) == Some(r) { - out.push((r, k.clone(), true)); + out.push((r, k.clone(), true, e as *const Expr as usize, Some(value))); } } } @@ -704,7 +794,7 @@ pub(super) fn accesses(ss: &[Stmt]) -> Vec<(Recv, String, bool)> { } perry_hir::walker::walk_expr_children(e, &mut |c| e_walk(c, out)); } - fn s_walk(s: &Stmt, out: &mut Vec<(Recv, String, bool)>) { + fn s_walk<'e>(s: &'e Stmt, out: &mut Vec<(Recv, String, bool, usize, Option<&'e Expr>)>) { match s { Stmt::Let { init: Some(e), .. } | Stmt::Expr(e) | Stmt::Throw(e) => e_walk(e, out), Stmt::Return(Some(e)) => e_walk(e, out), @@ -797,9 +887,15 @@ pub(super) fn receiver_eligible(ctx: &FnCtx<'_>, r: Recv) -> bool { } pub(super) struct Plan { - /// `(receiver, keys, has a bare store, stored mask, boxed-store mask)`. - pub(super) receivers: Vec<(Recv, Vec, bool, u32, u32)>, + /// `(receiver, keys, has a bare store, stored mask, boxed-store mask, R mask)`. + pub(super) receivers: Vec<(Recv, Vec, bool, u32, u32, u32)>, pub(super) bare: HashSet, + pub(super) bare_reads: Vec<(usize, Recv, String)>, + pub(super) number_local_uses: HashSet, + /// All covered numeric operand reads, including ones a conservative + /// first walk reached after a freshness kill. + numeric_candidates: Vec<(usize, Recv, String)>, + pub(super) declared_locals: HashSet, pub(super) trees: HashSet, pub(super) recheck: Recheck, /// Array receivers with a bare read, and their static index bound. @@ -821,6 +917,12 @@ pub(crate) enum Recheck { /// the eligible receivers; the facts are FRESH at the tail's top. `loop_ctl` /// carries the loop's condition and update for the re-check decision (loop /// regions only). +/// Resolve exact R and 5L together by a descending fixed point. A first +/// conservative walk discovers covered numeric operand reads even after its +/// initial arithmetic kill. Propose those finite reads as R candidates, then +/// keep only ones the next walk really made bare and R-proven. The 5L local +/// set is recomputed after each reduction. A result is returned only when +/// both proofs agree with the same completed walk. pub(super) fn plan( ctx: &FnCtx<'_>, tail: &[Stmt], @@ -828,13 +930,98 @@ pub(super) fn plan( arrays: HashMap, loop_ctl: Option<(Option<&Expr>, Option<&Expr>)>, inner: Option<(usize, &HashSet, &HashSet)>, +) -> Option { + let empty_reads = HashSet::new(); + let empty_locals = HashSet::new(); + let seed = plan_once( + ctx, + tail, + &cands, + &arrays, + loop_ctl, + inner, + &empty_reads, + &empty_locals, + )?; + let mut proof_reads: HashSet = seed + .numeric_candidates + .iter() + .filter_map(|(ptr, recv, key)| { + seed.receivers + .iter() + .find(|(r, keys, _, _, _, _)| r == recv && keys.contains(key)) + .map(|_| *ptr) + }) + .collect(); + if proof_reads.is_empty() { + return Some(seed); + } + let (locals, _) = number_facts_from_reads( + ctx, + tail, + &proof_reads, + &seed.number_local_uses, + &seed.declared_locals, + ); + let mut proof_locals: HashSet = locals.into_iter().collect(); + let limit = proof_reads.len() + proof_locals.len() + 1; + for _ in 0..=limit { + let p = plan_once( + ctx, + tail, + &cands, + &arrays, + loop_ctl, + inner, + &proof_reads, + &proof_locals, + )?; + let reads: HashSet = p + .bare_reads + .iter() + .filter_map(|(ptr, recv, key)| { + p.receivers + .iter() + .find(|(r, _, _, _, _, _)| r == recv) + .and_then(|(_, keys, _, _, _, mask)| { + keys.iter() + .position(|k| k == key) + .filter(|i| mask & (1 << i) != 0) + .map(|_| *ptr) + }) + }) + .collect(); + let (locals, _) = + number_facts_from_reads(ctx, tail, &reads, &p.number_local_uses, &p.declared_locals); + let locals: HashSet = locals.into_iter().collect(); + if !reads.is_subset(&proof_reads) || !locals.is_subset(&proof_locals) { + return None; + } + if reads == proof_reads && locals == proof_locals { + return Some(p); + } + proof_reads = reads; + proof_locals = locals; + } + None +} + +fn plan_once<'p, 'a>( + ctx: &'p FnCtx<'a>, + tail: &[Stmt], + cands: &'p HashSet, + arrays: &'p HashMap, + loop_ctl: Option<(Option<&Expr>, Option<&Expr>)>, + inner: Option<(usize, &'p HashSet, &'p HashSet)>, + proof_reads: &'p HashSet, + proof_locals: &'p HashSet, ) -> Option { if cands.is_empty() && arrays.is_empty() { return None; } let mut keys: HashMap> = HashMap::new(); let mut overflow: HashSet = HashSet::new(); - for (r, k, _) in accesses(tail) { + for (r, k, _, _, _) in accesses(tail) { if !cands.contains(&r) { continue; } @@ -848,7 +1035,8 @@ pub(super) fn plan( } } let cands: HashSet = cands - .into_iter() + .iter() + .copied() .filter(|r| keys.contains_key(r) && !overflow.contains(r)) .collect(); if cands.is_empty() && arrays.is_empty() { @@ -865,14 +1053,20 @@ pub(super) fn plan( keys: &keys, arrays: &arrays, bare: HashSet::new(), + number_reads: Vec::new(), + number_local_uses: HashSet::new(), + bare_reads: Vec::new(), bare_arrays: HashSet::new(), inner: inner.map(|(_, b, t)| (b, t)), in_inner: false, + in_store_rhs: false, trees: HashSet::new(), bare_stores: HashSet::new(), boxed_stores: HashMap::new(), continues: Vec::new(), record: true, + proof_reads, + proof_locals, }; let end = match inner { // The nested body region's tail: F-tail keeps the loop's facts @@ -927,6 +1121,12 @@ pub(super) fn plan( }; } let bare = std::mem::take(&mut p.bare); + let number_reads = std::mem::take(&mut p.number_reads); + let bare_reads = std::mem::take(&mut p.bare_reads); + let mut number_local_uses = std::mem::take(&mut p.number_local_uses); + let (flow_reads, flow_locals, declared_locals) = + crate::collectors::region_number_flow_reads(tail, &number_local_uses); + number_local_uses.extend(flow_locals); let trees = std::mem::take(&mut p.trees); let bare_stores = std::mem::take(&mut p.bare_stores); let boxed_stores = std::mem::take(&mut p.boxed_stores); @@ -941,7 +1141,7 @@ pub(super) fn plan( // Only receivers with a bare access need a guard. let used: HashSet = { let mut u = HashSet::new(); - for (r, k, _) in accesses(tail) { + for (r, k, _, _, _) in accesses(tail) { if cands.contains(&r) && keys[&r].contains(&k) { u.insert(r); } @@ -952,14 +1152,20 @@ pub(super) fn plan( // runtime serves a spill-located key to reads only, so a stored key must // be inline for the word to be published. let mut stored: HashMap = HashMap::new(); - for (r, k, is_store) in accesses(tail) { + for (r, k, is_store, _, _) in accesses(tail) { if is_store && cands.contains(&r) { if let Some(i) = keys[&r].iter().position(|x| *x == k) { *stored.entry(r).or_default() |= 1 << i; } } } - let mut receivers: Vec<(Recv, Vec, bool, u32, u32)> = used + // A store RHS may use loop-carried locals discharged by the same 5L + // entry/re-entry checks as its reads. Reuse that exact set when judging + // whether its value is compatible with an F64 lane. + let mut numeric_locals: HashSet = + ctx.number_by_construction_locals.iter().copied().collect(); + numeric_locals.extend(proof_locals.iter().copied()); + let mut receivers: Vec<(Recv, Vec, bool, u32, u32, u32)> = used .into_iter() .map(|r| { let boxed = boxed_stores.get(&r).map_or(0, |ks| { @@ -969,19 +1175,87 @@ pub(super) fn plan( .filter(|(_, k)| ks.contains(*k)) .fold(0u32, |m, (i, _)| m | 1 << i) }); + let r_mask = bare_reads.iter().fold(0u32, |mask, (ptr, nr, key)| { + if *nr == r + && (flow_reads.contains(ptr) || number_reads.iter().any(|(p, _, _)| p == ptr)) + { + if let Some(i) = keys[&r].iter().position(|k| k == key) { + return mask | (1 << i); + } + } + mask + }); + // The first planner walk has no R facts, so it conservatively + // marks `o.x = o.x + 1` boxed. Rejudge only its exact bare stores + // against the R that this plan will require at F entry. All + // stores to a key must prove Number before its boxed bit clears. + let f64_reads: HashSet = bare_reads + .iter() + .filter(|(_, nr, key)| { + *nr == r + && keys[&r] + .iter() + .position(|k| k == key) + .is_some_and(|i| r_mask & (1 << i) != 0) + }) + .map(|(ptr, _, _)| *ptr) + .collect(); + let mut boxed = boxed; + for (i, key) in keys[&r].iter().enumerate() { + if boxed & (1 << i) == 0 { + continue; + } + let stores: Vec<&Expr> = accesses(tail) + .into_iter() + .filter(|(sr, sk, is_store, ptr, _)| { + *sr == r && sk == key && *is_store && bare.contains(ptr) + }) + .filter_map(|(_, _, _, _, value)| value) + .collect(); + if !stores.is_empty() + && stores.iter().all(|value| { + crate::type_analysis::expr_produces_canonical_raw_f64(ctx, value) + || crate::collectors::region_store_value_is_number( + value, + &f64_reads, + &numeric_locals, + ctx.not_bigint_locals, + ) + }) + { + boxed &= !(1 << i); + } + } ( r, keys[&r].clone(), bare_stores.contains(&r), stored.get(&r).copied().unwrap_or(0), boxed, + r_mask, ) }) .collect(); - receivers.sort_by_key(|(r, _, _, _, _)| *r); + receivers.sort_by_key(|(r, _, _, _, _, _)| *r); + // R reads reached through local value flow need the same freshness/5L + // rewalk as direct arithmetic leaves. Otherwise lowering sees their R + // mask and emits a numeric F body while planning keeps the seed recheck. + let numeric_candidates = number_reads + .into_iter() + .chain( + bare_reads + .iter() + .filter(|(ptr, _, _)| flow_reads.contains(ptr)) + .cloned(), + ) + .collect(); Some(Plan { receivers, bare, + bare_reads, + number_local_uses, + numeric_candidates, + declared_locals, trees, recheck, arrays: plan_arrays, diff --git a/crates/perry-codegen/src/stmt/region_loop/verify.rs b/crates/perry-codegen/src/stmt/region_loop/verify.rs index ab32c2ee68..5a2afd7f7c 100644 --- a/crates/perry-codegen/src/stmt/region_loop/verify.rs +++ b/crates/perry-codegen/src/stmt/region_loop/verify.rs @@ -136,3 +136,96 @@ pub(super) fn verify( } true } + +/// Check the complete F path, including instructions after its last bare +/// access. A JS-capable operation may reshape a receiver before the next +/// iteration; it therefore needs an unconditional recheck, a dirty-flag +/// store on that path, or an exit from the region. The earlier bare-access +/// check alone cannot establish this back-edge obligation. +pub(super) fn verify_exit_effects( + ctx: &FnCtx<'_>, + entry: usize, + scan_start: usize, + scan_end: usize, + recheck: Recheck, + dirty_slot: Option<&str>, + valid_slot: Option<&str>, +) -> bool { + let Some(valid_slot) = valid_slot else { + return true; // A body region does not carry its facts to an iteration. + }; + if recheck == Recheck::Always { + return true; + } + use crate::inst::LlInst; + const JS: u8 = 1; + const DIRTY: u8 = 2; + const LEFT: u8 = 4; + let blocks = ctx.func.blocks(); + let in_f = |b: usize| b == entry || (scan_start..scan_end).contains(&b); + let mut by_label: HashMap<&str, usize> = HashMap::new(); + for b in (scan_start..scan_end).chain(std::iter::once(entry)) { + by_label.insert(blocks[b].label.as_str(), b); + } + let mut state: HashMap = HashMap::new(); + let mut work = vec![(entry, 1u8)]; + let mut exits = 0u8; + while let Some((b, incoming)) = work.pop() { + let previous = state.get(&b).copied().unwrap_or(0); + if previous | incoming == previous { + continue; + } + let mut mask = previous | incoming; + state.insert(b, mask); + for inst in blocks[b].insts() { + let mut next = 0u8; + for bits in 0..8u8 { + if mask & (1 << bits) == 0 { + continue; + } + let mut bits = bits; + if inst_may_run_js(inst) { + bits |= JS; + } + if let LlInst::Store { val, ptr, .. } = inst { + if dirty_slot == Some(ptr.as_str()) { + if val == "true" { + bits |= DIRTY; + } else if val == "false" { + bits &= !DIRTY; + } else { + // An unknown write cannot prove coverage. + bits &= !DIRTY; + } + } + if ptr == valid_slot { + if val == "false" { + bits |= LEFT; + } else { + // Re-entering the region revokes the exit proof. + bits &= !LEFT; + } + } + } + next |= 1 << bits; + } + mask = next; + } + let successors = successors(&blocks[b]); + if successors.is_empty() { + exits |= mask; + } + for succ in successors { + match by_label.get(succ.as_str()) { + Some(&next) if in_f(next) => work.push((next, mask)), + _ => exits |= mask, + } + } + } + (0..8u8).all(|bits| { + exits & (1 << bits) == 0 + || bits & JS == 0 + || bits & LEFT != 0 + || (recheck == Recheck::Dirty && bits & DIRTY != 0) + }) +} diff --git a/crates/perry-codegen/src/stmt/versioned_indexed_loop.rs b/crates/perry-codegen/src/stmt/versioned_indexed_loop.rs index 31324d8edc..d048cc8219 100644 --- a/crates/perry-codegen/src/stmt/versioned_indexed_loop.rs +++ b/crates/perry-codegen/src/stmt/versioned_indexed_loop.rs @@ -420,9 +420,14 @@ pub(super) fn emit_iteration_guard(ctx: &mut FnCtx<'_>) -> bool { let expected_class_shape = ctx.block() .or(I64, &expected_shape_high, &fact.method.expected_class_id); - let class_shape_ok = ctx - .block() - .icmp_eq(I64, &class_shape, &expected_class_shape); + let class_shape_ok = crate::typed_shape::emit_compatible_class_shape_eq( + ctx.block(), + &class_shape, + &fact.method.expected_class_id, + &fact.method.expected_shape_id, + &expected_class_shape, + &[], + ); pass = ctx.block().and(I1, &pass, &all_methods_ok); pass = ctx.block().and(I1, &pass, &method_ok); pass = ctx.block().and(I1, &pass, &gc_ok); diff --git a/crates/perry-codegen/src/strings.rs b/crates/perry-codegen/src/strings.rs index 532e7b1393..21de07d940 100644 --- a/crates/perry-codegen/src/strings.rs +++ b/crates/perry-codegen/src/strings.rs @@ -76,6 +76,10 @@ pub struct StringPool { /// names match what `emit_string_pool` generates and the codegen /// use sites can reference them directly. module_prefix: String, + /// Immutable graph callback owner, set once before lowering. Launch sites + /// share the symbol even when their local string pool belongs to another + /// module. Defaults to this module for graph-free direct codegen callers. + thread_literal_callback_prefix: String, /// `value → interned index`. Identical literals share an entry. interned: HashMap, /// Ordered list of unique entries; the index in this Vec is the @@ -141,6 +145,7 @@ impl StringPool { /// without colliding on `.str.0.handle` etc. pub fn with_prefix(module_prefix: String) -> Self { Self { + thread_literal_callback_prefix: module_prefix.clone(), module_prefix, interned: HashMap::new(), entries: Vec::new(), @@ -155,6 +160,14 @@ impl StringPool { &self.module_prefix } + pub(crate) fn set_thread_literal_callback_prefix(&mut self, prefix: String) { + self.thread_literal_callback_prefix = prefix; + } + + pub(crate) fn thread_literal_callback_prefix(&self) -> &str { + &self.thread_literal_callback_prefix + } + /// #5247: install the per-module source-location context (file path + /// source text) consulted by the dynamic call-dispatch lowering when the /// `--debug-symbols` flag is on. No-op otherwise (`ctx` is `None`). diff --git a/crates/perry-codegen/src/stubs.rs b/crates/perry-codegen/src/stubs.rs index 0dec8dd3a1..347a200c43 100644 --- a/crates/perry-codegen/src/stubs.rs +++ b/crates/perry-codegen/src/stubs.rs @@ -199,26 +199,98 @@ pub fn generate_stdlib_installer_object( /// through `js_shape_run_static_seed` (`main` after `js_gc_init`, every other /// agent at its start). pub fn static_shape_seed_ll(seeds: &[(u32, crate::BirthShape)]) -> String { + static_shape_seed_ll_impl(seeds, true) +} + +/// The caller supplies only permanent-image final facts or ordinary births. +/// Body-aware seeds create records; they do not stamp incomplete objects. +fn static_shape_seed_ll_impl(seeds: &[(u32, crate::BirthShape)], allow_constfn: bool) -> String { + assert!( + allow_constfn || seeds.iter().all(|(_, shape)| shape.constfn.is_empty()), + "ConstFn static seeds require a body-aware runtime seed ABI" + ); let mut ll = String::new(); ll.push_str("; Perry static shape seeds — generated by perry-codegen::stubs\n\n"); ll.push_str("declare i32 @js_shape_seed_plain(i32, ptr, i32, i32, i32, i64)\n"); + ll.push_str( + "declare i32 @js_shape_seed_plain_constfn(i32, ptr, i32, i32, i32, i64, ptr, i32)\n", + ); ll.push_str("declare void @js_shape_register_static_seed(ptr)\n\n"); + // The seed unit is a separate LLVM module from each defining body. + // Opaque pointers still require every referenced global to be declared + // here; the defining module emits the one hidden, linkable info constant. + let body_symbols = seeds + .iter() + .flat_map(|(_, shape)| shape.constfn.iter().map(|body| body.symbol.as_str())) + .collect::>(); + for symbol in body_symbols { + ll.push_str(&format!( + "@{symbol} = external constant {}\n", + crate::fn_info::INFO_TYPE + )); + } for (i, (_, shape)) in seeds.iter().enumerate() { let bytes: String = shape.keys.iter().map(|b| format!("\\{b:02X}")).collect(); ll.push_str(&format!( "@perry_static_seed_keys_{i} = private unnamed_addr constant [{} x i8] c\"{bytes}\"\n", shape.keys.len() )); + if !shape.constfn.is_empty() { + let mut prior = None; + let mut mask = 0u32; + let entries = shape + .constfn + .iter() + .map(|body| { + assert!( + body.slot < 32 && prior.is_none_or(|slot| body.slot > slot), + "ConstFn seed entries must be sorted and unique" + ); + assert!( + body.symbol + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_.$".contains(&b)), + "invalid ConstFn body info symbol" + ); + prior = Some(body.slot); + mask |= 1 << body.slot; + format!( + "{{ i32, ptr }} {{ i32 {}, ptr @{} }}", + body.slot, body.symbol + ) + }) + .collect::>() + .join(", "); + let special = (0..32) + .filter(|slot| (shape.rep >> (2 * slot)) & 3 == 3) + .fold(0u32, |mask, slot| mask | (1 << slot)); + assert_eq!(mask, special, "ConstFn seed rep/body mismatch"); + ll.push_str(&format!( + "@perry_static_seed_constfn_{i} = private constant [{} x {{ i32, ptr }}] [{entries}]\n", + shape.constfn.len() + )); + } } ll.push_str("\ndefine internal void @perry_static_shape_seed() {\n"); for (i, (id, shape)) in seeds.iter().enumerate() { - ll.push_str(&format!( - " %s{i} = call i32 @js_shape_seed_plain(i32 {id}, ptr @perry_static_seed_keys_{i}, i32 {}, i32 {}, i32 {}, i64 {})\n", - shape.keys.len(), - shape.key_count, - shape.live, - shape.rep - )); + if shape.constfn.is_empty() { + ll.push_str(&format!( + " %s{i} = call i32 @js_shape_seed_plain(i32 {id}, ptr @perry_static_seed_keys_{i}, i32 {}, i32 {}, i32 {}, i64 {})\n", + shape.keys.len(), + shape.key_count, + shape.live, + shape.rep + )); + } else { + ll.push_str(&format!( + " %s{i} = call i32 @js_shape_seed_plain_constfn(i32 {id}, ptr @perry_static_seed_keys_{i}, i32 {}, i32 {}, i32 {}, i64 {}, ptr @perry_static_seed_constfn_{i}, i32 {})\n", + shape.keys.len(), + shape.key_count, + shape.live, + shape.rep, + shape.constfn.len() + )); + } } ll.push_str(" ret void\n}\n\n"); ll.push_str("define internal void @perry_register_static_shape_seed() {\n"); @@ -253,6 +325,7 @@ mod tests { proto: crate::BirthProto::Literal, typed: None, rep: 0, + constfn: Vec::new(), }; let ll = static_shape_seed_ll(&[(0x1000_0042, shape)]); assert!(ll.contains("c\"\\75\\00\\76\\00\""), "{ll}"); @@ -265,6 +338,7 @@ mod tests { proto: crate::BirthProto::Literal, typed: None, rep: 0b0101, + constfn: Vec::new(), }; let ll = static_shape_seed_ll(&[(0x1000_0043, f64_lanes)]); assert!(ll.contains("call i32 @js_shape_seed_plain(i32 268435523, ptr @perry_static_seed_keys_0, i32 4, i32 2, i32 2, i64 5)"), "{ll}"); @@ -279,6 +353,7 @@ mod tests { proto: crate::BirthProto::Literal, typed: None, rep: 0, + constfn: Vec::new(), }, )], None, @@ -287,6 +362,47 @@ mod tests { assert!(bytes.len() > 64); } + #[test] + fn constfn_seed_ir_names_body_info_and_registers_for_every_agent() { + let shape = crate::BirthShape { + keys: b"method\0".to_vec(), + key_count: 1, + live: 1, + proto: crate::BirthProto::Literal, + typed: None, + rep: 0b11, + constfn: vec![crate::ConstFnBirth { + slot: 0, + symbol: "perry_closure_m__method$info".to_string(), + }], + }; + let ll = static_shape_seed_ll_impl(&[(0x1000_0044, shape)], true); + assert!(ll.contains("@perry_static_seed_constfn_0 = private constant [1 x { i32, ptr }] [{ i32, ptr } { i32 0, ptr @perry_closure_m__method$info }]"), "{ll}"); + assert!(ll.contains("@perry_closure_m__method$info = external constant { ptr, i16, i16, i32, i32, i32, ptr, i64, ptr, i32, i16, i16, i64 }"), "{ll}"); + assert!(ll.contains("call i32 @js_shape_seed_plain_constfn(i32 268435524, ptr @perry_static_seed_keys_0, i32 7, i32 1, i32 1, i64 3, ptr @perry_static_seed_constfn_0, i32 1)"), "{ll}"); + assert!(ll.contains("@js_shape_register_static_seed(ptr @perry_static_shape_seed)")); + let object = compile_ll_to_object(&ll, None).expect("body-aware seed LLVM compiles"); + assert!(object.len() > 64); + } + + #[test] + #[should_panic(expected = "ConstFn seed rep/body mismatch")] + fn constfn_seed_rejects_mismatched_rep_before_llvm_emission() { + let shape = crate::BirthShape { + keys: b"method\0".to_vec(), + key_count: 1, + live: 1, + proto: crate::BirthProto::Literal, + typed: None, + rep: 0, + constfn: vec![crate::ConstFnBirth { + slot: 0, + symbol: "perry_closure_m__method$info".to_string(), + }], + }; + let _ = static_shape_seed_ll(&[(0x1000_0044, shape)]); + } + #[test] fn empty_stubs_produce_object() { let bytes = generate_stub_object(&[], &[], &[], None).unwrap(); diff --git a/crates/perry-codegen/src/temp_root_coverage/mod.rs b/crates/perry-codegen/src/temp_root_coverage/mod.rs index 903d05fe6d..7602425808 100644 --- a/crates/perry-codegen/src/temp_root_coverage/mod.rs +++ b/crates/perry-codegen/src/temp_root_coverage/mod.rs @@ -56,6 +56,7 @@ pub(crate) fn entry_opts() -> CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/testing/root_slots.rs b/crates/perry-codegen/src/testing/root_slots.rs index c228640aef..7f32308879 100644 --- a/crates/perry-codegen/src/testing/root_slots.rs +++ b/crates/perry-codegen/src/testing/root_slots.rs @@ -25,6 +25,7 @@ //! |---|---|---| //! | [`SlotKind::Value`] | `alloca double` | a named local, or a scalar-replaced field/element slot (#6968) | //! | [`SlotKind::TempRoot`] | `alloca i64` null-initialised at entry | #7487's `TempRootPool` | +//! | [`SlotKind::ClassKeys`] | `alloca i64` loaded from a registered class-keys global | an immutable function-local copy (#7876) | //! //! [`bound_slots`] **panics on a bound alloca it cannot classify** rather than //! defaulting it into either bucket. That is deliberate and is the property @@ -50,6 +51,8 @@ pub enum SlotKind { /// the entry block, holding an expression temporary rather than any HIR /// local. TempRoot, + /// Immutable copy of a registered class-keys global, bound for rewrites. + ClassKeys, } /// `%reg` -> the text right of `=` on its defining line, within one function. @@ -89,12 +92,26 @@ fn classify(fn_ir: &str, defs: &BTreeMap<&str, &str>, slot: &str) -> SlotKind { match defs.get(slot).copied().and_then(alloca_type) { Some("double") => SlotKind::Value, Some("i64") if fn_ir.contains(&format!("store i64 0, ptr {slot}\n")) => SlotKind::TempRoot, + Some("i64") + if fn_ir.lines().map(str::trim).any(|line| { + line.strip_prefix("store i64 ") + .and_then(|rest| rest.split_once(", ptr ")) + .is_some_and(|(value, target)| { + target.split(',').next().unwrap().trim() == slot + && defs.get(value).is_some_and(|def| { + def.starts_with("load i64, ptr @perry_class_keys_") + }) + }) + }) => + { + SlotKind::ClassKeys + } other => panic!( "root slot {slot} is bound but its alloca ({other:?}) belongs to no \ known slot family. Adding one is fine — classify it HERE, in \ `testing::root_slots`, so every test that measures root traffic \ sees it. Silently folding it into an existing total is how a \ - whole-module bind count stopped measuring its subject (#7504)." + whole-module bind count stopped measuring its subject (#7504).\n{fn_ir}" ), } } diff --git a/crates/perry-codegen/src/testing/temp_slots.rs b/crates/perry-codegen/src/testing/temp_slots.rs index ff8aaf9497..9bbf2d7cb1 100644 --- a/crates/perry-codegen/src/testing/temp_slots.rs +++ b/crates/perry-codegen/src/testing/temp_slots.rs @@ -477,6 +477,10 @@ pub fn temp_root_slots(fn_ir: &str) -> Vec { let defs = defs(fn_ir); let undefined = undefined_literal(); let seeded = zero_seeded_slots(fn_ir); + let entry_end = fn_ir + .lines() + .position(|line| line.trim().starts_with("br ") || line.trim().starts_with("ret ")) + .unwrap_or(usize::MAX); let class_key_loads: Vec<&str> = defs .iter() .filter_map(|(®, def)| { @@ -503,11 +507,14 @@ pub fn temp_root_slots(fn_ir: &str) -> Vec { .filter(|(_, events)| events.iter().any(|e| matches!(e, SlotEvent::Store { .. }))) .filter(|(_, events)| { !events.iter().any(|e| match e { - SlotEvent::Store { value, .. } => { - value == &undefined - || defs.get(value.as_str()).is_some_and(|def| { - def.starts_with("bitcast double ") && def.contains(&undefined) - }) + // A scoped temp may legitimately hold undefined later. + // Only the named slot's hoisted entry seed distinguishes it. + SlotEvent::Store { value, line } => { + *line < entry_end + && (value == &undefined + || defs.get(value.as_str()).is_some_and(|def| { + def.starts_with("bitcast double ") && def.contains(&undefined) + })) } _ => false, }) diff --git a/crates/perry-codegen/src/type_analysis/numeric.rs b/crates/perry-codegen/src/type_analysis/numeric.rs index 190ec6c40e..8098d35368 100644 --- a/crates/perry-codegen/src/type_analysis/numeric.rs +++ b/crates/perry-codegen/src/type_analysis/numeric.rs @@ -351,6 +351,9 @@ pub(crate) fn is_numeric_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { Expr::PropertyGet { object, property, .. } => { + if crate::stmt::region_loop::is_f64_read(ctx, e) { + return true; + } if matches!( crate::lower_call::guarded_path_type(ctx, e), Some(HirType::Number | HirType::Int32) @@ -699,6 +702,9 @@ pub(crate) fn expr_produces_canonical_raw_f64(ctx: &FnCtx<'_>, e: &Expr) -> bool Expr::PropertyGet { object, property, .. } => { + if crate::stmt::region_loop::is_f64_read(ctx, e) { + return true; + } let Some(fact) = ctx.ptr_shape_receiver_fact(object.as_ref()) else { return false; }; diff --git a/crates/perry-codegen/src/type_analysis/numeric/tests.rs b/crates/perry-codegen/src/type_analysis/numeric/tests.rs index 3a70454c07..a3a72c1bc9 100644 --- a/crates/perry-codegen/src/type_analysis/numeric/tests.rs +++ b/crates/perry-codegen/src/type_analysis/numeric/tests.rs @@ -18,6 +18,7 @@ fn ir_opts() -> CompileOptions { target: None, is_entry_module: false, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/type_analysis/pod.rs b/crates/perry-codegen/src/type_analysis/pod.rs index 53a89130ba..4cc391ce22 100644 --- a/crates/perry-codegen/src/type_analysis/pod.rs +++ b/crates/perry-codegen/src/type_analysis/pod.rs @@ -504,6 +504,11 @@ pub(crate) fn numeric_proof_is_declared_only(ctx: &FnCtx<'_>, expr: &Expr) -> bo Expr::PropertyGet { object, property, .. } => { + // R admits this exact fresh bare read only after the shape's F64 + // representation check; there is no boxed fallback in F. + if crate::stmt::region_loop::is_f64_read(ctx, expr) { + return false; + } // `.length` is produced by the runtime, not read out of a // user-writable slot. if property == "length" { @@ -546,18 +551,6 @@ pub(crate) fn numeric_proof_is_declared_only(ctx: &FnCtx<'_>, expr: &Expr) -> bo let Some(class_name) = receiver_class_name(ctx, object) else { return false; }; - if let Expr::LocalGet(recv_id) = object.as_ref() { - if crate::expr::class_field_loop_fact_lookup( - &ctx.class_field_loop_facts, - *recv_id, - &class_name, - property, - ) - .is_some() - { - return false; - } - } let ptr_shape_numeric = ctx .ptr_shape_receiver_fact(object.as_ref()) .map(|fact| fact.class_name == class_name && fact.numeric_fields.contains(property)) diff --git a/crates/perry-codegen/src/typed_shape.rs b/crates/perry-codegen/src/typed_shape.rs index 6d56a4810d..d3c25a3055 100644 --- a/crates/perry-codegen/src/typed_shape.rs +++ b/crates/perry-codegen/src/typed_shape.rs @@ -505,3 +505,45 @@ pub(crate) fn ensure_class_shape_slot( .insert(class_name.to_string(), slot.clone()); slot } + +/// One receiver shape load admits ordinary construction and compatible completed +/// facts. Each extra static final id adds one comparison and OR, no heap load. +pub(crate) fn emit_compatible_shape_eq( + blk: &mut crate::block::LlBlock, + actual: &str, + expected: &str, + written_slots: &[u32], +) -> String { + use crate::types::{I1, I32}; + let mut ok = blk.icmp_eq(I32, actual, expected); + for id in crate::codegen::compatible_final_shape_ids(expected, written_slots) { + let compatible = blk.icmp_eq(I32, actual, &id.to_string()); + ok = blk.or(I1, &ok, &compatible); + } + ok +} + +/// Packed (class, shape) form of the same guard. A final shape preserves the +/// allocation's numeric facts; class identity still licenses raw field access. +pub(crate) fn emit_compatible_class_shape_eq( + blk: &mut crate::block::LlBlock, + actual: &str, + class_id: &str, + expected_shape: &str, + expected_packed: &str, + written_slots: &[u32], +) -> String { + use crate::types::{I1, I32, I64}; + let mut ok = blk.icmp_eq(I64, actual, expected_packed); + for id in crate::codegen::compatible_final_shape_ids(expected_shape, written_slots) { + let packed = if let Ok(cid) = class_id.parse::() { + ((u64::from(id) << 32) | u64::from(cid)).to_string() + } else { + let class_bits = blk.zext(I32, class_id, I64); + blk.or(I64, &class_bits, &(u64::from(id) << 32).to_string()) + }; + let compatible = blk.icmp_eq(I64, actual, &packed); + ok = blk.or(I1, &ok, &compatible); + } + ok +} diff --git a/crates/perry-codegen/src/wasm32/runtime_abi.tsv b/crates/perry-codegen/src/wasm32/runtime_abi.tsv index b932d50c08..18a18b78ab 100644 --- a/crates/perry-codegen/src/wasm32/runtime_abi.tsv +++ b/crates/perry-codegen/src/wasm32/runtime_abi.tsv @@ -2568,6 +2568,8 @@ js_object_delete_field i32s ptr,ptr js_object_delete_field_value i32s f64,ptr js_object_entries ptr ptr js_object_entries_value ptr f64 +js_object_final_shape_id_for_class_keys_static_constfn i32u i64,i32u,i32u,i32u,i32u,i64,ptr,i32u +js_object_finalize_constfn_static i64 i64,i32u,ptr,i32u,i32u,i32u,i32u,i64,ptr,i32u js_object_free void ptr js_object_freeze f64 f64 js_object_from_entries f64 f64 @@ -3190,7 +3192,7 @@ js_regexp_to_string ptr ptr js_region_guard_pack i64 i32u,i32u,i64,i64,i64,i64,i64 js_region_guard_prime i64 ptr,i32u,i32u,i64,i64,i64,i64,i64 js_region_loop_pack i64 i32u,i32u,i64,i64,i64,i64,i64,i32u,i32u -js_region_loop_prime i64 ptr,i32u,i32u,i64,i64,i64,i64,i64,i32u,i32u,i32u +js_region_loop_prime i64 ptr,i32u,i32u,i64,i64,i64,i64,i64,i32u,i32u,i32u,i32u js_register_anon_shape_class_id void i32u js_register_aux_has_active void ptr js_register_aux_pump void ptr @@ -3455,6 +3457,7 @@ js_shape_ordinary_inline_slot_for_key i32s i32u,i64 js_shape_register_static_seed void ptr js_shape_run_static_seed void js_shape_seed_plain i32u i32u,ptr,i32u,i32u,i32u,i64 +js_shape_seed_plain_constfn i32u i32u,ptr,i32u,i32u,i32u,i64,ptr,i32u js_shared_array_buffer_new ptr i32s js_shared_array_buffer_new_value ptr f64 js_sharp_auto_orient i64 i64 @@ -3718,9 +3721,12 @@ js_thread_global_materialize f64 i64,i64,i64 js_thread_global_publish void i64,i64,i64 js_thread_has_pending i32s js_thread_parallel_filter f64 f64,f64 +js_thread_parallel_filter_with_literals f64 f64,f64,i64 js_thread_parallel_map f64 f64,f64 +js_thread_parallel_map_with_literals f64 f64,f64,i64 js_thread_process_pending i32s js_thread_spawn f64 f64 +js_thread_spawn_with_literals f64 f64,i64 js_throw void f64 js_throw_bigint_constructor_type_error f64 js_throw_collection_receiver_type_error void f64,ptr,usize diff --git a/crates/perry-codegen/tests/app_window_config_options.rs b/crates/perry-codegen/tests/app_window_config_options.rs index b05135d17d..12f1277fa4 100644 --- a/crates/perry-codegen/tests/app_window_config_options.rs +++ b/crates/perry-codegen/tests/app_window_config_options.rs @@ -19,6 +19,7 @@ fn empty_opts() -> CompileOptions { target: None, is_entry_module: false, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), import_function_origin_names: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/argless_builtin_extra_args.rs b/crates/perry-codegen/tests/argless_builtin_extra_args.rs index 8926fae27b..8437a8197d 100644 --- a/crates/perry-codegen/tests/argless_builtin_extra_args.rs +++ b/crates/perry-codegen/tests/argless_builtin_extra_args.rs @@ -15,6 +15,7 @@ fn empty_opts() -> CompileOptions { target: None, is_entry_module: false, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/class_field_store_pointer_test.rs b/crates/perry-codegen/tests/class_field_store_pointer_test.rs index 832600113d..668351ac23 100644 --- a/crates/perry-codegen/tests/class_field_store_pointer_test.rs +++ b/crates/perry-codegen/tests/class_field_store_pointer_test.rs @@ -25,6 +25,7 @@ fn empty_opts() -> CompileOptions { target: None, is_entry_module: false, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), import_function_origin_names: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/class_keys_gc_root.rs b/crates/perry-codegen/tests/class_keys_gc_root.rs index f597eefb40..77cd00058c 100644 --- a/crates/perry-codegen/tests/class_keys_gc_root.rs +++ b/crates/perry-codegen/tests/class_keys_gc_root.rs @@ -33,6 +33,7 @@ fn entry_opts() -> CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/concat_site_agent_ownership.rs b/crates/perry-codegen/tests/concat_site_agent_ownership.rs new file mode 100644 index 0000000000..1d8cc21fab --- /dev/null +++ b/crates/perry-codegen/tests/concat_site_agent_ownership.rs @@ -0,0 +1,51 @@ +//! Cache cells containing heap handles must have the same agent ownership +//! as literal pools, even in a helper module that imports no thread API. +use perry_codegen::{ + compile_module, set_program_has_thread_agents, set_program_has_worker, CompileOptions, +}; +use perry_hir::{BinaryOp, Expr, Module, Stmt}; + +#[test] +fn concat_site_cells_and_fill_address_are_agent_local_in_worker_graphs() { + let mut module = Module::new("concat_helper.ts"); + module.init.push(Stmt::Expr(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::String("worker-".into())), + right: Box::new(Expr::Integer(1)), + })); + // This integration target has one test, so its process-wide compilation + // graph flags cannot race with unrelated compilation tests. + for (workers, agents) in [(false, false), (true, false), (false, true)] { + set_program_has_worker(workers); + set_program_has_thread_agents(agents); + let opts = CompileOptions { + emit_ir_only: true, + is_entry_module: false, + ..Default::default() + }; + let ir = String::from_utf8(compile_module(&module, opts).unwrap()).unwrap(); + let table = ir + .lines() + .find(|line| line.starts_with("@perry_concat_site_")) + .expect("fixture must exercise the concat-site cache"); + assert_eq!(table.contains("thread_local global"), workers || agents); + assert!(table.contains("[32 x i64] zeroinitializer"), "{table}"); + let symbol = table.split(" =").next().unwrap(); + assert!( + ir.lines() + .any(|line| line.contains("getelementptr [32 x i64]") && line.contains(symbol)), + "inline probe must address that same table" + ); + assert!( + ir.lines() + .any(|line| line.contains("ptrtoint ptr") && line.contains(symbol)), + "miss helper must receive that same table address" + ); + assert!(ir.contains("call double @js_string_concat_site_value(")); + assert!(!ir + .lines() + .any(|line| line.starts_with(symbol) && line.contains(" constant "))); + } + set_program_has_worker(false); + set_program_has_thread_agents(false); +} diff --git a/crates/perry-codegen/tests/constructor_recursion.rs b/crates/perry-codegen/tests/constructor_recursion.rs index fb56446f07..1f6115cbce 100644 --- a/crates/perry-codegen/tests/constructor_recursion.rs +++ b/crates/perry-codegen/tests/constructor_recursion.rs @@ -9,6 +9,7 @@ fn empty_opts() -> CompileOptions { target: None, is_entry_module: false, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), import_function_origin_names: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/crypto_hash_chain_lowering.rs b/crates/perry-codegen/tests/crypto_hash_chain_lowering.rs index 6befdb2c9f..48e16c60a0 100644 --- a/crates/perry-codegen/tests/crypto_hash_chain_lowering.rs +++ b/crates/perry-codegen/tests/crypto_hash_chain_lowering.rs @@ -24,6 +24,7 @@ fn entry_opts() -> CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/destructure_call_location.rs b/crates/perry-codegen/tests/destructure_call_location.rs index ef4f318459..dee330223e 100644 --- a/crates/perry-codegen/tests/destructure_call_location.rs +++ b/crates/perry-codegen/tests/destructure_call_location.rs @@ -25,6 +25,7 @@ fn base_opts() -> CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/i64_spec_ternary_recursion.rs b/crates/perry-codegen/tests/i64_spec_ternary_recursion.rs index 3b3304d19f..2dfee805e2 100644 --- a/crates/perry-codegen/tests/i64_spec_ternary_recursion.rs +++ b/crates/perry-codegen/tests/i64_spec_ternary_recursion.rs @@ -26,6 +26,7 @@ fn empty_opts() -> CompileOptions { target: None, is_entry_module: false, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), import_function_origin_names: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/ios_platform_api_lowering.rs b/crates/perry-codegen/tests/ios_platform_api_lowering.rs index 234c3a30a3..c1d75fe544 100644 --- a/crates/perry-codegen/tests/ios_platform_api_lowering.rs +++ b/crates/perry-codegen/tests/ios_platform_api_lowering.rs @@ -11,6 +11,7 @@ fn options(target: Option<&str>) -> CompileOptions { target: target.map(str::to_string), is_entry_module: false, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), import_function_prefixes: Default::default(), import_function_ffi_aliases: Default::default(), import_function_origin_names: Default::default(), diff --git a/crates/perry-codegen/tests/large_object_barriers.rs b/crates/perry-codegen/tests/large_object_barriers.rs index 15fd2ee332..788ced5edf 100644 --- a/crates/perry-codegen/tests/large_object_barriers.rs +++ b/crates/perry-codegen/tests/large_object_barriers.rs @@ -9,6 +9,7 @@ fn empty_opts() -> CompileOptions { target: None, is_entry_module: false, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), import_function_origin_names: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/loop_safepoint_purity.rs b/crates/perry-codegen/tests/loop_safepoint_purity.rs index f163c26546..155e24f8cd 100644 --- a/crates/perry-codegen/tests/loop_safepoint_purity.rs +++ b/crates/perry-codegen/tests/loop_safepoint_purity.rs @@ -46,6 +46,7 @@ fn entry_opts() -> CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/macos_bundle_chdir_gate.rs b/crates/perry-codegen/tests/macos_bundle_chdir_gate.rs index 71ae07068b..fff40deeee 100644 --- a/crates/perry-codegen/tests/macos_bundle_chdir_gate.rs +++ b/crates/perry-codegen/tests/macos_bundle_chdir_gate.rs @@ -15,6 +15,7 @@ fn entry_opts(target: Option<&str>) -> CompileOptions { target: target.map(str::to_string), is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), import_function_origin_names: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/native_proof_buffer_views.rs b/crates/perry-codegen/tests/native_proof_buffer_views.rs index 730adb7aef..3a59f7fe5f 100644 --- a/crates/perry-codegen/tests/native_proof_buffer_views.rs +++ b/crates/perry-codegen/tests/native_proof_buffer_views.rs @@ -40,6 +40,7 @@ fn empty_opts() -> CompileOptions { target: None, is_entry_module: false, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), import_function_origin_names: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index a4729d6489..abf737967a 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -47,6 +47,7 @@ fn empty_opts() -> CompileOptions { target: None, is_entry_module: false, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), import_function_origin_names: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/node_test_mock_property_presence.rs b/crates/perry-codegen/tests/node_test_mock_property_presence.rs index ce4b4c6dbc..591f5b7cd6 100644 --- a/crates/perry-codegen/tests/node_test_mock_property_presence.rs +++ b/crates/perry-codegen/tests/node_test_mock_property_presence.rs @@ -11,6 +11,7 @@ fn ir_opts() -> CompileOptions { target: None, is_entry_module: false, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/perry_builtin_name_collision.rs b/crates/perry-codegen/tests/perry_builtin_name_collision.rs index 04fdb5d54c..97d7a3ff6a 100644 --- a/crates/perry-codegen/tests/perry_builtin_name_collision.rs +++ b/crates/perry-codegen/tests/perry_builtin_name_collision.rs @@ -26,6 +26,7 @@ fn base_opts() -> CompileOptions { target: None, is_entry_module: false, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/release_boxes_lowering.rs b/crates/perry-codegen/tests/release_boxes_lowering.rs index 5d06a2e265..5375a4a629 100644 --- a/crates/perry-codegen/tests/release_boxes_lowering.rs +++ b/crates/perry-codegen/tests/release_boxes_lowering.rs @@ -25,6 +25,7 @@ fn entry_opts() -> CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/scalar_replaced_slot_roots.rs b/crates/perry-codegen/tests/scalar_replaced_slot_roots.rs index 487cd21775..4378725e0d 100644 --- a/crates/perry-codegen/tests/scalar_replaced_slot_roots.rs +++ b/crates/perry-codegen/tests/scalar_replaced_slot_roots.rs @@ -65,6 +65,7 @@ fn entry_opts() -> CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/shadow_slot_hygiene.rs b/crates/perry-codegen/tests/shadow_slot_hygiene.rs index 6df8ce16e5..3084bb1fba 100644 --- a/crates/perry-codegen/tests/shadow_slot_hygiene.rs +++ b/crates/perry-codegen/tests/shadow_slot_hygiene.rs @@ -39,6 +39,7 @@ fn empty_opts() -> CompileOptions { target: None, is_entry_module: false, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), import_function_origin_names: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/static_symbol_hygiene.rs b/crates/perry-codegen/tests/static_symbol_hygiene.rs index 068b79e120..96e4b1487f 100644 --- a/crates/perry-codegen/tests/static_symbol_hygiene.rs +++ b/crates/perry-codegen/tests/static_symbol_hygiene.rs @@ -9,6 +9,7 @@ fn empty_opts() -> CompileOptions { target: None, is_entry_module: false, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), import_function_origin_names: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/temp_root_operand_temporaries.rs b/crates/perry-codegen/tests/temp_root_operand_temporaries.rs index 59df57093c..1a529af4a5 100644 --- a/crates/perry-codegen/tests/temp_root_operand_temporaries.rs +++ b/crates/perry-codegen/tests/temp_root_operand_temporaries.rs @@ -66,6 +66,7 @@ fn entry_opts() -> CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/thread_agent_strings.rs b/crates/perry-codegen/tests/thread_agent_strings.rs new file mode 100644 index 0000000000..968cd76dd5 --- /dev/null +++ b/crates/perry-codegen/tests/thread_agent_strings.rs @@ -0,0 +1,198 @@ +use perry_codegen::{compile_module, CompileOptions}; +use perry_hir::types::Type; +use perry_hir::{Expr, Module, ModuleInitKind, Stmt}; + +const LITERAL: &str = + "direct-module-thread-literal-longer-than-sixty-four-bytes-must-belong-to-this-agent"; + +fn literal_ir(name: &str, method: Option<&str>, opts: CompileOptions, deferred: bool) -> String { + let mut module = Module::new(name); + if deferred { + module.init_kind = ModuleInitKind::Deferred; + } + module.init.push(Stmt::Expr(Expr::String(LITERAL.into()))); + if let Some(method) = method { + let closure = Expr::Closure { + func_id: 17, + params: Vec::new(), + return_type: Type::String, + body: vec![Stmt::Return(Some(Expr::String(LITERAL.into())))], + 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 args = if method == "spawn" { + vec![closure] + } else { + vec![ + Expr::Array(vec![Expr::Number(1.0), Expr::Number(2.0)]), + closure, + ] + }; + module.init.push(Stmt::Expr(Expr::NativeMethodCall { + module: "perry/thread".into(), + class_name: None, + object: None, + method: method.into(), + args, + })); + } + String::from_utf8(compile_module(&module, opts).expect("thread fixture compiles")).unwrap() +} + +fn options(graph: &[&str], is_entry: bool) -> CompileOptions { + CompileOptions { + emit_ir_only: true, + is_entry_module: is_entry, + thread_literal_module_prefixes: graph.iter().map(|prefix| (*prefix).into()).collect(), + ..Default::default() + } +} + +fn callback<'a>(ir: &'a str, prefix: &str) -> &'a str { + let anchor = format!("define void @__perry_prepare_thread_strings_{prefix}("); + ir.split(anchor.as_str()) + .nth(1) + .expect("owner callback must be defined") + .split("\n}") + .next() + .unwrap() +} + +fn assert_tls_pool(ir: &str, prefix: &str) { + assert!(ir + .lines() + .any(|line| line.contains(".handle = internal thread_local global double"))); + assert!(ir.contains(&format!( + "@__perry_agent_strings_ready_{prefix} = internal thread_local global i8 0" + ))); +} + +fn assert_string_only_callback(ir: &str, prefix: &str, graph: &[&str]) { + let cb = callback(ir, prefix); + let calls: Vec<_> = cb + .lines() + .map(str::trim) + .filter(|line| line.starts_with("call void @")) + .collect(); + let expected: Vec<_> = graph + .iter() + .map(|prefix| format!("call void @__perry_prepare_agent_strings_{prefix}()")) + .collect(); + assert_eq!(calls, expected, "one pure-string call per graph module"); + for forbidden in [ + "__init", + "__perry_prepare_literals_", + "js_class", + "js_shape", + "js_gc", + ] { + assert!( + !cb.contains(forbidden), + "graph callback contains {forbidden}" + ); + } +} + +#[test] +fn standalone_launches_prepare_local_tls_literals_without_cli_flags() { + for is_entry in [false, true] { + for (method, runtime) in [ + ("spawn", "js_thread_spawn_with_literals"), + ("parallelMap", "js_thread_parallel_map_with_literals"), + ("parallelFilter", "js_thread_parallel_filter_with_literals"), + ] { + let ir = literal_ir( + "thread_direct.ts", + Some(method), + options(&[], is_entry), + false, + ); + assert!(ir.contains(&format!("call double @{runtime}("))); + assert!(ir.contains( + "ptrtoint (ptr @__perry_prepare_thread_strings_thread_direct_ts to i64)" + )); + assert_tls_pool(&ir, "thread_direct_ts"); + assert_eq!( + ir.matches("define void @__perry_prepare_thread_strings_") + .count(), + 1 + ); + assert_string_only_callback(&ir, "thread_direct_ts", &["thread_direct_ts"]); + } + } +} + +#[test] +fn graph_emits_one_callback_in_actual_entry_and_all_launches_reference_it() { + // The actual entry sorts last and contains no launch. Deferred/eager helper + // pools must still be TLS and prepared without executing their bodies. + let graph = [ + "z_entry_ts", + "a_launcher_ts", + "b_mapper_ts", + "c_filter_ts", + "deferred_ts", + ]; + let modules = [ + ("z_entry.ts", None, true, false), + ("a_launcher.ts", Some("spawn"), false, false), + ("b_mapper.ts", Some("parallelMap"), false, false), + ("c_filter.ts", Some("parallelFilter"), false, false), + ("deferred.ts", None, false, true), + ]; + let mut definitions = 0; + for ((name, method, entry, deferred), prefix) in modules.into_iter().zip(graph) { + let ir = literal_ir(name, method, options(&graph, entry), deferred); + assert_tls_pool(&ir, prefix); + definitions += ir + .matches("define void @__perry_prepare_thread_strings_") + .count(); + if entry { + assert_string_only_callback(&ir, "z_entry_ts", &graph); + } else { + assert!(!ir.contains("define void @__perry_prepare_thread_strings_")); + for foreign in graph.iter().filter(|other| **other != prefix) { + assert!(!ir.contains(&format!( + "declare void @__perry_prepare_agent_strings_{foreign}(" + ))); + } + } + if method.is_some() { + assert!(ir.contains("ptrtoint (ptr @__perry_prepare_thread_strings_z_entry_ts to i64)")); + assert!(!ir.contains(&format!("@__perry_prepare_thread_strings_{prefix}("))); + } + } + assert_eq!( + definitions, 1, + "one callback definition across the compiled graph" + ); +} + +#[test] +fn explicit_first_prefix_owns_callback_and_owner_normalizes_duplicates() { + let graph = ["z_owner_ts", "helper_ts", "z_owner_ts", "helper_ts"]; + // Direct embedders may choose any linked module; the first graph prefix, + // rather than the entry boolean or lexicographic minimum, selects it. + let owner = literal_ir("z_owner.ts", None, options(&graph, false), false); + assert_string_only_callback(&owner, "z_owner_ts", &["z_owner_ts", "helper_ts"]); + let helper = literal_ir("helper.ts", Some("spawn"), options(&graph, true), false); + assert_tls_pool(&helper, "helper_ts"); + assert!(!helper.contains("define void @__perry_prepare_thread_strings_")); + assert!(helper.contains("ptrtoint (ptr @__perry_prepare_thread_strings_z_owner_ts to i64)")); +} + +#[test] +fn thread_free_direct_module_keeps_process_wide_string_handles() { + let ir = literal_ir("thread_direct.ts", None, options(&[], false), false); + assert!(ir + .lines() + .any(|line| line.contains(".handle = internal global double"))); + assert!(!ir.contains("define void @__perry_prepare_thread_strings_")); +} diff --git a/crates/perry-codegen/tests/thread_immutable_globals.rs b/crates/perry-codegen/tests/thread_immutable_globals.rs index e36e45233a..5da8702706 100644 --- a/crates/perry-codegen/tests/thread_immutable_globals.rs +++ b/crates/perry-codegen/tests/thread_immutable_globals.rs @@ -9,7 +9,7 @@ //! programs, structurally non-leaf initializers, scalars, or reassigned //! bindings. -use perry_codegen::{compile_module, set_program_has_thread_agents, CompileOptions}; +use perry_codegen::{compile_module, CompileOptions}; use perry_hir::types::Type; use perry_hir::{BinaryOp, Export, Expr, Function, Module, Stmt}; @@ -99,24 +99,18 @@ fn producer(reassign: bool) -> Module { module } -/// The whole-program perry/thread flag is process state that the driver sets -/// before codegen; tests in this binary take turns holding it. -static PROGRAM_FLAG: std::sync::Mutex<()> = std::sync::Mutex::new(()); - -/// Compile the producer as a non-entry module of a program that does -/// (`thread_graph`) or does not launch perry/thread agents. The producer -/// itself never launches one. fn ir(module: &Module, thread_graph: bool) -> String { - let _flag = PROGRAM_FLAG.lock().unwrap_or_else(|e| e.into_inner()); - set_program_has_thread_agents(thread_graph); let opts = CompileOptions { emit_ir_only: true, is_entry_module: false, + thread_literal_module_prefixes: if thread_graph { + vec!["main_ts".into(), PREFIX.into()] + } else { + Vec::new() + }, ..Default::default() }; - let out = compile_module(module, opts); - set_program_has_thread_agents(false); - String::from_utf8(out.expect("producer compiles")).unwrap() + String::from_utf8(compile_module(module, opts).expect("producer compiles")).unwrap() } fn function_body<'a>(ir: &'a str, symbol: &str) -> &'a str { diff --git a/crates/perry-codegen/tests/typed_feedback.rs b/crates/perry-codegen/tests/typed_feedback.rs index ae4df6631b..cb1e9eda17 100644 --- a/crates/perry-codegen/tests/typed_feedback.rs +++ b/crates/perry-codegen/tests/typed_feedback.rs @@ -93,6 +93,7 @@ fn empty_opts() -> CompileOptions { target: None, is_entry_module: false, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), import_function_origin_names: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/typed_shape_descriptor.rs b/crates/perry-codegen/tests/typed_shape_descriptor.rs index 2a1d7a354b..53b88b8b83 100644 --- a/crates/perry-codegen/tests/typed_shape_descriptor.rs +++ b/crates/perry-codegen/tests/typed_shape_descriptor.rs @@ -9,6 +9,7 @@ fn empty_opts() -> CompileOptions { target: None, is_entry_module: false, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), import_function_origin_names: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/tests/typed_shape_descriptors.rs b/crates/perry-codegen/tests/typed_shape_descriptors.rs index b6a949c46d..5594fc8e70 100644 --- a/crates/perry-codegen/tests/typed_shape_descriptors.rs +++ b/crates/perry-codegen/tests/typed_shape_descriptors.rs @@ -12,6 +12,7 @@ fn empty_opts() -> CompileOptions { target: None, is_entry_module: false, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), import_function_origin_names: std::collections::HashMap::new(), diff --git a/crates/perry-hir/src/lower/context_new.rs b/crates/perry-hir/src/lower/context_new.rs index e38345f4f4..dc4cc77188 100644 --- a/crates/perry-hir/src/lower/context_new.rs +++ b/crates/perry-hir/src/lower/context_new.rs @@ -43,6 +43,7 @@ impl LoweringContext { let module_identity = salt_identity.into(); let tagged_template_site_salt = super::context::stable_module_salt(&module_identity); Self { + iterator_loop_labels: Vec::new(), next_local_id: 0, local_source_spans: HashMap::new(), classic_for_lexical_bindings: HashSet::new(), diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index 9f6034a2ec..08ba929f45 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -128,6 +128,8 @@ pub(crate) struct MixinFn { } pub struct LoweringContext { + /// Labels and source spans of the for-of loops currently being lowered. + pub iterator_loop_labels: Vec<(u32, String)>, /// Counter for generating unique local IDs pub(crate) next_local_id: LocalId, /// User-visible declaration spans keyed by the `LocalId` allocated during diff --git a/crates/perry-hir/src/lower/mod.rs b/crates/perry-hir/src/lower/mod.rs index 47f36b8611..0ead70175a 100644 --- a/crates/perry-hir/src/lower/mod.rs +++ b/crates/perry-hir/src/lower/mod.rs @@ -66,7 +66,7 @@ mod for_of_guard; pub(crate) use for_of_guard::lower_stmt_for_of; mod stmt_loops; pub(crate) use stmt_loops::{ - insert_iterator_close_on_abrupt, lazy_iter_for_stmt, lazy_or_index_elem, lower_stmt_for_in, + lazy_iter_for_stmt, lazy_or_index_elem, lower_stmt_for_in, record_iterator_loop_label, wrap_lazy_for_of_body_close_on_throw, }; mod module_decl; diff --git a/crates/perry-hir/src/lower/stmt.rs b/crates/perry-hir/src/lower/stmt.rs index 033a8d4110..4e6c119c3b 100644 --- a/crates/perry-hir/src/lower/stmt.rs +++ b/crates/perry-hir/src/lower/stmt.rs @@ -1522,7 +1522,10 @@ pub(crate) fn lower_stmt( }); return Ok(()); } + let label_scope = ctx.iterator_loop_labels.len(); + crate::lower::record_iterator_loop_label(ctx, &labeled_stmt.body, &label); let inner = lower_body_stmt(ctx, &labeled_stmt.body)?; + ctx.iterator_loop_labels.truncate(label_scope); if inner.len() == 1 { let body = inner.into_iter().next().unwrap(); module.init.push(Stmt::Labeled { diff --git a/crates/perry-hir/src/lower/stmt_loops.rs b/crates/perry-hir/src/lower/stmt_loops.rs index 4230d36cde..f71731c660 100644 --- a/crates/perry-hir/src/lower/stmt_loops.rs +++ b/crates/perry-hir/src/lower/stmt_loops.rs @@ -125,21 +125,6 @@ pub(crate) fn lazy_or_index_elem( } } -/// Wrap an iterator-protocol call result in the spec "If innerResult is not -/// an Object, throw a TypeError" check (IteratorNext / IteratorClose). -fn iterator_result_validated(call: Expr) -> Expr { - Expr::Call { - callee: Box::new(Expr::ExternFuncRef { - name: "js_iterator_result_validate".to_string(), - param_types: vec![Type::Any], - return_type: Type::Any, - }), - args: vec![call], - type_args: vec![], - byte_offset: 0, - } -} - /// One fused IteratorNext (`js_for_of_next(__iter)`): builtin Map/Set /// iterators advance in place; everything else runs the dynamic `.next()` /// plus result validation inside the entry — the two-call shape this emitted. @@ -225,46 +210,71 @@ pub(crate) fn lazy_iter_for_stmt( } } -/// Spec IteratorClose, guarded: `if (__iter.return != null) __iter.return();`. -/// Array iterators have no `return` method, so the guard makes close a no-op -/// for them (closing an array iterator is a spec no-op); generators / custom -/// iterators run their `return` (which executes pending `finally` blocks). -pub(crate) fn iterator_close_guarded_stmt(iter_id: LocalId) -> Stmt { - Stmt::If { - condition: Expr::Compare { - op: CompareOp::LooseNe, - left: Box::new(Expr::PropertyGet { - byte_offset: 0, - object: Box::new(Expr::LocalGet(iter_id)), - property: "return".to_string(), - }), - right: Box::new(Expr::Null), - }, - then_branch: vec![Stmt::Expr(iterator_result_validated(iterator_return_call( - iter_id, false, - )))], - else_branch: None, +/// Use the runtime GetMethod/Call entry: read `return` once and preserve +/// the iterator as the receiver without consulting a user-visible `.call`. +fn iterator_close_stmt(iter_id: LocalId) -> Stmt { + Stmt::Expr(Expr::Call { + callee: Box::new(Expr::ExternFuncRef { + name: "js_iterator_close_if_not_done".to_string(), + param_types: vec![Type::Any, Type::Any], + return_type: Type::Any, + }), + args: vec![Expr::LocalGet(iter_id), Expr::Bool(false)], + type_args: vec![], + byte_offset: 0, + }) +} + +/// Record a label only for the for-of loop it directly targets (including +/// label chains). Lowering the body can then distinguish `continue here` +/// from a continue that exits to an outer loop. +pub(crate) fn record_iterator_loop_label(ctx: &mut LoweringContext, body: &ast::Stmt, label: &str) { + match body { + ast::Stmt::Labeled(labeled) => record_iterator_loop_label(ctx, &labeled.body, label), + ast::Stmt::ForOf(loop_stmt) => ctx + .iterator_loop_labels + .push((loop_stmt.span.lo.0, label.to_string())), + _ => {} } } -/// Wrap a lazy `for...of` body (binding + user statements) in a `try/catch` -/// that runs IteratorClose when the body completes abruptly with a *throw* — -/// either an explicit `throw` statement or a runtime exception (a throwing -/// setter in the LHS `PutValue`, a destructuring error, an assertion failure, -/// a generator `.throw()` propagation). The `break`/`return`/labeled cases are -/// handled separately by `insert_iterator_close_on_abrupt` (they are control -/// flow, not exceptions, so this `catch` never sees them — no double-close). -/// -/// Per spec, for a throw completion IteratorClose invokes `return` but does -/// NOT validate its result and SWALLOWS any exception it raises — the original -/// throw is the one that propagates. So the close here is unvalidated and -/// itself wrapped in a result-swallowing `try/catch`, then the caught error is -/// re-thrown. +fn iterator_completion_stmt(state_id: LocalId, state: f64) -> Stmt { + Stmt::Expr(Expr::LocalSet(state_id, Box::new(Expr::Number(state)))) +} + +/// One existing body handler also owns close on control-flow completion. +/// State 0 means normal/continue, 1 means an outgoing break/return, and 2 +/// means close has started. The finally-inlining pass runs inner user +/// finallies before this close and evaluates return operands first. Setting +/// 2 BEFORE GetMethod/Call prevents a close error caught here from closing +/// again. Body throws still close once and retain their original exception. pub(crate) fn wrap_lazy_for_of_body_close_on_throw( ctx: &mut LoweringContext, iter_id: LocalId, - body: Vec, + loop_byte_offset: u32, + mut body: Vec, ) -> Stmt { + let state_id = ctx.fresh_local(); + let state_name = format!("__forof_completion_{}", state_id); + ctx.locals + .push((state_name.clone(), state_id, Type::Number)); + let loop_labels: Vec = ctx + .iterator_loop_labels + .iter() + .filter(|(offset, _)| *offset == loop_byte_offset) + .map(|(_, label)| label.clone()) + .collect(); + mark_iterator_completion_on_abrupt(ctx, &mut body, state_id, None, None, &[], &loop_labels); + body.insert( + 0, + Stmt::Let { + id: state_id, + name: state_name, + ty: Type::Number, + mutable: true, + init: Some(Expr::Number(0.0)), + }, + ); let err_id = ctx.fresh_local(); let err_name = format!("__forof_err_{}", err_id); ctx.locals.push((err_name.clone(), err_id, Type::Any)); @@ -273,47 +283,70 @@ pub(crate) fn wrap_lazy_for_of_body_close_on_throw( ctx.locals .push((ret_err_name.clone(), ret_err_id, Type::Any)); - // try { if (__iter.return != null) __iter.return(); } catch (_) {} - // - // The whole close — including the `__iter.return` *read* (which may be an - // accessor that throws) and the call's result — is inside the swallowing - // `try`: for a throw completion the close's own abrupt completion is - // discarded and the ORIGINAL throw propagates (spec IteratorClose, throw - // case). Keeping the `.return` read outside would let a throwing getter - // (`iterator-close-throw-get-method-abrupt`) replace the original error. - let guarded_close = Stmt::Try { - body: vec![Stmt::If { - condition: Expr::Compare { - op: CompareOp::LooseNe, - left: Box::new(Expr::PropertyGet { - byte_offset: 0, - object: Box::new(Expr::LocalGet(iter_id)), - property: "return".to_string(), + let close_on_throw = Stmt::If { + condition: Expr::Compare { + op: CompareOp::Ne, + left: Box::new(Expr::LocalGet(state_id)), + right: Box::new(Expr::Number(2.0)), + }, + then_branch: vec![ + iterator_completion_stmt(state_id, 2.0), + Stmt::Try { + body: vec![iterator_close_stmt(iter_id)], + catch: Some(CatchClause { + param: Some((ret_err_id, ret_err_name)), + body: Vec::new(), }), - right: Box::new(Expr::Null), + finally: None, }, - then_branch: vec![Stmt::Expr(iterator_return_call(iter_id, false))], - else_branch: None, - }], - catch: Some(CatchClause { - param: Some((ret_err_id, ret_err_name)), - body: Vec::new(), - }), - finally: None, + ], + else_branch: None, }; - Stmt::Try { body, catch: Some(CatchClause { param: Some((err_id, err_name)), - body: vec![guarded_close, Stmt::Throw(Expr::LocalGet(err_id))], + body: vec![close_on_throw, Stmt::Throw(Expr::LocalGet(err_id))], }), - finally: None, + finally: Some(vec![Stmt::If { + condition: Expr::Compare { + op: CompareOp::Eq, + left: Box::new(Expr::LocalGet(state_id)), + right: Box::new(Expr::Number(1.0)), + }, + then_branch: vec![ + iterator_completion_stmt(state_id, 2.0), + iterator_close_stmt(iter_id), + ], + else_branch: None, + }]), } } +fn iterator_completion_snapshot( + ctx: &mut LoweringContext, + stmts: &mut Vec, + state_id: LocalId, +) -> LocalId { + let id = ctx.fresh_local(); + let name = format!("__forof_incoming_{}", id); + ctx.locals.push((name.clone(), id, Type::Number)); + stmts.push(Stmt::Let { + id, + name, + ty: Type::Number, + mutable: false, + init: Some(Expr::LocalGet(state_id)), + }); + id +} + +fn iterator_completion_restore(state_id: LocalId, saved_id: LocalId) -> Stmt { + Stmt::Expr(Expr::LocalSet(state_id, Box::new(Expr::LocalGet(saved_id)))) +} + /// Rewrite a synchronous `for...of` body so every abrupt completion that -/// escapes the loop runs IteratorClose first. Per spec ForIn/OfBodyEvaluation: +/// escapes the loop requests IteratorClose in its enclosing finally. Per spec ForIn/OfBodyEvaluation: /// an unlabeled `break` that targets this loop, a labeled `break`/`continue` /// that targets an enclosing construct, and a `return` all close the iterator. /// Unlabeled `continue` (next iteration) and `break`/`continue` captured by a @@ -321,32 +354,76 @@ pub(crate) fn wrap_lazy_for_of_body_close_on_throw( /// rewritten here: a `throw` caught by an in-body `try/catch` must not close, /// and the uncaught case is handled separately. /// -/// `break_capture_depth` counts enclosing loops/switches inside the body (which -/// capture an unlabeled `break`); `inner_labels` are labels declared within the -/// body (which capture a matching labeled `break`/`continue`). -pub(crate) fn insert_iterator_close_on_abrupt( +/// Captured exits restore the completion present when their target was entered. +/// This cancels a return overridden by an inner exit from finally, but retains +/// an outer return when a loop inside its cleanup completes normally. +/// Switches supply only a break target. Labels carry their own entry snapshot. +/// Continuing a label on this for-of resets the pending close. +fn mark_iterator_completion_on_abrupt( + ctx: &mut LoweringContext, stmts: &mut Vec, - iter_id: LocalId, - break_capture_depth: usize, - inner_labels: &[String], + state_id: LocalId, + break_target: Option, + continue_target: Option, + inner_labels: &[(String, LocalId)], + loop_labels: &[String], ) { let mut rewritten = Vec::with_capacity(stmts.len()); for stmt in stmts.drain(..) { match stmt { - Stmt::Break if break_capture_depth == 0 => { - rewritten.push(iterator_close_guarded_stmt(iter_id)); + Stmt::Continue => { + rewritten.push(match continue_target { + Some(saved_id) => iterator_completion_restore(state_id, saved_id), + None => iterator_completion_stmt(state_id, 0.0), + }); + rewritten.push(Stmt::Continue); + } + Stmt::Break => { + rewritten.push(match break_target { + Some(saved_id) => iterator_completion_restore(state_id, saved_id), + None => iterator_completion_stmt(state_id, 1.0), + }); rewritten.push(Stmt::Break); } - Stmt::LabeledBreak(label) if !inner_labels.contains(&label) => { - rewritten.push(iterator_close_guarded_stmt(iter_id)); + Stmt::LabeledBreak(label) => { + rewritten.push(match inner_labels.iter().rev().find(|(l, _)| l == &label) { + Some((_, saved_id)) => iterator_completion_restore(state_id, *saved_id), + None => iterator_completion_stmt(state_id, 1.0), + }); rewritten.push(Stmt::LabeledBreak(label)); } - Stmt::LabeledContinue(label) if !inner_labels.contains(&label) => { - rewritten.push(iterator_close_guarded_stmt(iter_id)); + Stmt::LabeledContinue(label) => { + rewritten.push(match inner_labels.iter().rev().find(|(l, _)| l == &label) { + Some((_, saved_id)) => iterator_completion_restore(state_id, *saved_id), + None => iterator_completion_stmt( + state_id, + if loop_labels.contains(&label) { + 0.0 + } else { + 1.0 + }, + ), + }); rewritten.push(Stmt::LabeledContinue(label)); } Stmt::Return(value) => { - rewritten.push(iterator_close_guarded_stmt(iter_id)); + // Operand failure remains a throw, not an outgoing return. + // Register the value as Any so cleanup can allocate/collect + // while the pending return remains rooted. + let value = value.map(|operand| { + let id = ctx.fresh_local(); + let name = format!("__forof_return_{}", id); + ctx.locals.push((name.clone(), id, Type::Any)); + rewritten.push(Stmt::Let { + id, + name, + ty: Type::Any, + mutable: false, + init: Some(operand), + }); + Expr::LocalGet(id) + }); + rewritten.push(iterator_completion_stmt(state_id, 1.0)); rewritten.push(Stmt::Return(value)); } Stmt::If { @@ -354,18 +431,24 @@ pub(crate) fn insert_iterator_close_on_abrupt( mut then_branch, mut else_branch, } => { - insert_iterator_close_on_abrupt( + mark_iterator_completion_on_abrupt( + ctx, &mut then_branch, - iter_id, - break_capture_depth, + state_id, + break_target, + continue_target, inner_labels, + loop_labels, ); if let Some(else_stmts) = else_branch.as_mut() { - insert_iterator_close_on_abrupt( + mark_iterator_completion_on_abrupt( + ctx, else_stmts, - iter_id, - break_capture_depth, + state_id, + break_target, + continue_target, inner_labels, + loop_labels, ); } rewritten.push(Stmt::If { @@ -379,22 +462,44 @@ pub(crate) fn insert_iterator_close_on_abrupt( mut catch, mut finally, } => { - insert_iterator_close_on_abrupt( + mark_iterator_completion_on_abrupt( + ctx, &mut body, - iter_id, - break_capture_depth, + state_id, + break_target, + continue_target, inner_labels, + loop_labels, ); if let Some(c) = catch.as_mut() { - insert_iterator_close_on_abrupt( + // A handled override cancels only completion originating + // in this try. A catch inside a pending return's cleanup + // inherits state 1 and must retain that pending return. + let mut entry = Vec::new(); + let saved_id = iterator_completion_snapshot(ctx, &mut entry, state_id); + body.splice(0..0, entry); + c.body + .insert(0, iterator_completion_restore(state_id, saved_id)); + mark_iterator_completion_on_abrupt( + ctx, &mut c.body, - iter_id, - break_capture_depth, + state_id, + break_target, + continue_target, inner_labels, + loop_labels, ); } if let Some(f) = finally.as_mut() { - insert_iterator_close_on_abrupt(f, iter_id, break_capture_depth, inner_labels); + mark_iterator_completion_on_abrupt( + ctx, + f, + state_id, + break_target, + continue_target, + inner_labels, + loop_labels, + ); } rewritten.push(Stmt::Try { body, @@ -406,11 +511,15 @@ pub(crate) fn insert_iterator_close_on_abrupt( condition, mut body, } => { - insert_iterator_close_on_abrupt( + let saved_id = iterator_completion_snapshot(ctx, &mut rewritten, state_id); + mark_iterator_completion_on_abrupt( + ctx, &mut body, - iter_id, - break_capture_depth + 1, + state_id, + Some(saved_id), + Some(saved_id), inner_labels, + loop_labels, ); rewritten.push(Stmt::While { condition, body }); } @@ -418,11 +527,15 @@ pub(crate) fn insert_iterator_close_on_abrupt( mut body, condition, } => { - insert_iterator_close_on_abrupt( + let saved_id = iterator_completion_snapshot(ctx, &mut rewritten, state_id); + mark_iterator_completion_on_abrupt( + ctx, &mut body, - iter_id, - break_capture_depth + 1, + state_id, + Some(saved_id), + Some(saved_id), inner_labels, + loop_labels, ); rewritten.push(Stmt::DoWhile { body, condition }); } @@ -432,11 +545,15 @@ pub(crate) fn insert_iterator_close_on_abrupt( update, mut body, } => { - insert_iterator_close_on_abrupt( + let saved_id = iterator_completion_snapshot(ctx, &mut rewritten, state_id); + mark_iterator_completion_on_abrupt( + ctx, &mut body, - iter_id, - break_capture_depth + 1, + state_id, + Some(saved_id), + Some(saved_id), inner_labels, + loop_labels, ); rewritten.push(Stmt::For { init, @@ -449,12 +566,16 @@ pub(crate) fn insert_iterator_close_on_abrupt( discriminant, mut cases, } => { + let saved_id = iterator_completion_snapshot(ctx, &mut rewritten, state_id); for case in cases.iter_mut() { - insert_iterator_close_on_abrupt( + mark_iterator_completion_on_abrupt( + ctx, &mut case.body, - iter_id, - break_capture_depth + 1, + state_id, + Some(saved_id), + continue_target, inner_labels, + loop_labels, ); } rewritten.push(Stmt::Switch { @@ -463,16 +584,23 @@ pub(crate) fn insert_iterator_close_on_abrupt( }); } Stmt::Labeled { label, mut body } => { + let saved_id = iterator_completion_snapshot(ctx, &mut rewritten, state_id); let mut labels = inner_labels.to_vec(); - labels.push(label.clone()); + labels.push((label.clone(), saved_id)); let mut body_vec = vec![*body]; - insert_iterator_close_on_abrupt( + mark_iterator_completion_on_abrupt( + ctx, &mut body_vec, - iter_id, - break_capture_depth, + state_id, + break_target, + continue_target, &labels, + loop_labels, ); - body = Box::new(body_vec.into_iter().next().unwrap()); + // Snapshot preludes belong before the labeled target; retain + // the actual loop as the label body (including label chains). + body = Box::new(body_vec.pop().unwrap()); + rewritten.extend(body_vec); rewritten.push(Stmt::Labeled { label, body }); } other => rewritten.push(other), @@ -1612,18 +1740,16 @@ pub(super) fn lower_stmt_for_of_inner( _ => return Err(anyhow!("Unsupported for-of left-hand side")), }; - // Lazy iterator path: rewrite the user body so every abrupt completion - // escaping the loop runs IteratorClose (`__iter.return()`) first. + // The lazy iterator body owns close via its generated catch/finally. if use_lazy_iter { - insert_iterator_close_on_abrupt(&mut loop_body, arr_id, 0, &[]); // Wrap ONLY the user body so a throw escaping it runs IteratorClose. - // break/return/labeled abrupts were already handled above; this covers - // the throw-completion case those intentionally leave alone. The + // The // element-`.value` read and binding statements stay OUTSIDE the wrapper: // per spec, IteratorValue throwing sets the iterator done and does NOT // close it (`iterator-next-result-value-attr-error`) — only an abrupt // body completion does. - let guarded_body = wrap_lazy_for_of_body_close_on_throw(ctx, arr_id, loop_body); + let guarded_body = + wrap_lazy_for_of_body_close_on_throw(ctx, arr_id, for_of_stmt.span.lo.0, loop_body); let mut full_body = binding_stmts; full_body.push(guarded_body); module diff --git a/crates/perry-hir/src/lower_decl/body_stmt.rs b/crates/perry-hir/src/lower_decl/body_stmt.rs index b2a8ca1b53..f7562a2969 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt.rs @@ -7,9 +7,9 @@ use crate::analysis::*; use crate::destructuring::*; use crate::ir::*; use crate::lower::{ - collect_for_of_pattern_leaves, emit_for_of_pattern_binding, insert_iterator_close_on_abrupt, - labeled_body_targets_loop, lazy_iter_for_stmt, lazy_or_index_elem, lower_expr, - wrap_lazy_for_of_body_close_on_throw, LoweringContext, + collect_for_of_pattern_leaves, emit_for_of_pattern_binding, labeled_body_targets_loop, + lazy_iter_for_stmt, lazy_or_index_elem, lower_expr, wrap_lazy_for_of_body_close_on_throw, + LoweringContext, }; use crate::lower_patterns::*; @@ -713,7 +713,10 @@ fn lower_body_stmt_impl(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result Result f64 { /// reports itself rather than corrupting a result. const MAX_ITERATOR_DRAIN: usize = u32::MAX as usize - 1; -/// `IteratorClose(iterator)` when destructuring exits before the iterator is done. +/// IteratorClose when a consumer exits before exhaustion. As with the +/// iterator materializer, debug/test builds must allow a catchable JS throw +/// to cross this entry; production uses the plain C exception transport. +#[cfg(panic = "abort")] #[no_mangle] pub extern "C" fn js_iterator_close_if_not_done(iter_f64: f64, done_f64: f64) -> f64 { + iterator_close_if_not_done(iter_f64, done_f64) +} + +#[cfg(not(panic = "abort"))] +#[no_mangle] +pub extern "C-unwind" fn js_iterator_close_if_not_done(iter_f64: f64, done_f64: f64) -> f64 { + iterator_close_if_not_done(iter_f64, done_f64) +} + +fn iterator_close_if_not_done(iter_f64: f64, done_f64: f64) -> f64 { if crate::value::js_is_truthy(done_f64) != 0 { return f64::from_bits(crate::value::TAG_UNDEFINED); } - let ret = named_field(iter_f64, b"return"); - if ret.to_bits() == crate::value::TAG_UNDEFINED { + // GetMethod may invoke a getter and collect. Root the receiver before + // allocating its key, then reload it for Call. Root the returned method + // too: neither value may remain a raw local across user code. + let scope = crate::gc::RuntimeHandleScope::new(); + let iter = scope.root_nanbox_f64(iter_f64); + let key = crate::string::js_string_from_bytes_longlived(b"return".as_ptr(), 6); + let ret = crate::object::js_object_get_field_by_name_f64( + crate::value::js_nanbox_get_pointer(iter.get_nanbox_f64()) + as *const crate::object::ObjectHeader, + key, + ); + if matches!( + ret.to_bits(), + crate::value::TAG_UNDEFINED | crate::value::TAG_NULL + ) { return f64::from_bits(crate::value::TAG_UNDEFINED); } - if !is_callable_value(ret) { + if !crate::proxy::is_callable_function(ret) { crate::closure::throw_not_callable(); } - + let method = scope.root_nanbox_f64(ret); let result = unsafe { crate::closure::native_call_value_this( - ret, - crate::closure::JsThis::from_f64(iter_f64), + method.get_nanbox_f64(), + crate::closure::JsThis::from_f64(iter.get_nanbox_f64()), std::ptr::null(), 0, ) diff --git a/crates/perry-runtime/src/gc/barrier_store.rs b/crates/perry-runtime/src/gc/barrier_store.rs index b19fd749d7..9c7025dc57 100644 --- a/crates/perry-runtime/src/gc/barrier_store.rs +++ b/crates/perry-runtime/src/gc/barrier_store.rs @@ -107,10 +107,12 @@ fn slot_holds_raw_f64(parent_user: usize, slot_index: usize) -> bool { if (*header).gc_flags & GC_FLAG_FORWARDED != 0 { return false; } - return crate::object::field_rep_store::object_slot_rep( + let rep = crate::object::field_rep_store::object_slot_rep( parent_user as *const crate::object::ObjectHeader, slot_index, - ) != crate::object::field_rep::REP_ANY; + ); + return rep == crate::object::field_rep::REP_F64 + || rep == crate::object::field_rep::REP_F64_DEPRECATED; } } false diff --git a/crates/perry-runtime/src/gc/layout/by_shape.rs b/crates/perry-runtime/src/gc/layout/by_shape.rs index 62e61429c4..0f88a9c6b1 100644 --- a/crates/perry-runtime/src/gc/layout/by_shape.rs +++ b/crates/perry-runtime/src/gc/layout/by_shape.rs @@ -25,18 +25,32 @@ pub(super) fn selection_by_shape( shape: Option, payload: HeapSlotRange, ) -> HeapPayloadSlotSelection { - selection_for_rep(shape.map_or(0, |record| record.rep()), payload) + selection_for_rep_with_special( + shape.map_or(0, |record| record.rep()), + shape.map_or(0, |record| record.special_constfn_mask()), + payload, + ) } /// The payload selection for an object whose shape carries `rep`: every /// non-`Any` lane is skipped, everything else gets the tag test. #[inline] +#[cfg(test)] fn selection_for_rep(rep: u64, payload: HeapSlotRange) -> HeapPayloadSlotSelection { + selection_for_rep_with_special(rep, 0, payload) +} + +#[inline] +fn selection_for_rep_with_special( + rep: u64, + special_constfn_mask: u32, + payload: HeapSlotRange, +) -> HeapPayloadSlotSelection { let slot_count = payload.slot_count(); if slot_count == 0 { return HeapPayloadSlotSelection::Empty; } - let skip = field_rep::non_any_slot_bits(rep); + let skip = field_rep::non_pointer_slot_bits(rep, special_constfn_mask); if skip == 0 || slot_count > INLINE_MASK_SLOTS { return HeapPayloadSlotSelection::All { cursor: 0 }; } @@ -155,6 +169,22 @@ mod tests { )); } + #[test] + fn constfn_special_lane_is_traced_but_optional_nopointer_is_skipped() { + let mut slots = [0u64; 4]; + let payload = HeapSlotRange::new(slots.as_mut_ptr(), slots.len()); + let rep = field_rep::with_slot_rep( + field_rep::with_slot_rep(0, 0, field_rep::REP_SPECIAL), + 1, + field_rep::REP_SPECIAL, + ); + // Slot 0 is a current closure; slot 1 demonstrates the reserved + // NoPointer interpretation if P5 is accepted. No producer exists yet. + let selection = selection_for_rep_with_special(rep, 0b01, payload); + assert!(selection_visits(&selection, 0)); + assert!(!selection_visits(&selection, 1)); + } + /// `{n: 1.5, s: "txt"}` and its shape; the key-adds earn `n` an `F64` /// lane and leave `s` `Any` (P2b). unsafe fn number_and_string() -> (*mut crate::object::ObjectHeader, u32) { diff --git a/crates/perry-runtime/src/hot_diag.rs b/crates/perry-runtime/src/hot_diag.rs index 81430f219d..173f9f9a9c 100644 --- a/crates/perry-runtime/src/hot_diag.rs +++ b/crates/perry-runtime/src/hot_diag.rs @@ -1524,7 +1524,7 @@ fn buffer_dump() { /// Receiver-route admission census names, indexed by the route number the /// emitted call passes. **Must match `receiver_range::Route` in perry-codegen.** -const RECV_ROUTE_NAMES: [&str; 33] = [ +const RECV_ROUTE_NAMES: [&str; 35] = [ "generic", "generic_mru_hit", "generic_way_hit", @@ -1578,6 +1578,10 @@ const RECV_ROUTE_NAMES: [&str; 33] = [ // Runtime-counted by `js_region_loop_prime`: refused because a key a bare // store may write a non-double into is not an `Any` lane (charter step 5). "rt_rloop_refuse_f64_stored", + "rt_rloop_refuse_rep", + // Emitted only in a route-census build: F ran with at least one R bit + // backed by its chosen static or learned supplier. + "rloop_f_rep", ]; /// The runtime-counted routes: see [`RECV_ROUTE_NAMES`]. @@ -1596,9 +1600,10 @@ pub(crate) const RT_ROUTE_RLOOP_REFUSE_SPILL_UNSERVABLE: u32 = 25; pub(crate) const RT_ROUTE_RLOOP_REFUSE_RANGE: u32 = 26; pub(crate) const RT_ROUTE_RLOOP_RETIRE: u32 = 27; pub(crate) const RT_ROUTE_RLOOP_REFUSE_F64_STORED: u32 = 32; +pub(crate) const RT_ROUTE_RLOOP_REFUSE_REP: u32 = 33; -static RECV_ROUTES: [std::sync::atomic::AtomicU64; 33] = - [const { std::sync::atomic::AtomicU64::new(0) }; 33]; +static RECV_ROUTES: [std::sync::atomic::AtomicU64; 35] = + [const { std::sync::atomic::AtomicU64::new(0) }; 35]; static RECV_ROUTES_REPORT: std::sync::Once = std::sync::Once::new(); /// Set by the first emitted `js_recv_route_note`, i.e. only in a binary /// compiled with `PERRY_RECV_ROUTE_COUNT=1`; the runtime-counted routes are a diff --git a/crates/perry-runtime/src/object/alloc_plain.rs b/crates/perry-runtime/src/object/alloc_plain.rs index 82d9325907..a4aeedb2b6 100644 --- a/crates/perry-runtime/src/object/alloc_plain.rs +++ b/crates/perry-runtime/src/object/alloc_plain.rs @@ -166,21 +166,33 @@ pub(super) fn alloc_class_inline_keys_stamped_impl( } /// A class keys global's keys, with the count its module-init ShapeId names. -/// A worker agent may not have installed that id yet, and an id that names a -/// different array is not this global's; both fall back to the array itself, -/// which module init built exact. So does an id whose count the array no -/// longer holds: the id's facts diverged from the global beside it, and a -/// count past the array's initialized slots would name keys that are not -/// there. The fallback's count then differs from the id's, so the stamp -/// declines it and publishes an exact descriptor. +/// A worker installs that id with its own canonical backing, so a global +/// owned by the spawning arena must resolve through the worker's descriptor. +/// A different LOCAL array still takes the exact-array fallback: its facts +/// can diverge from the id beside it, so the birth stamp must validate them. #[inline] fn preinstalled_class_keys( keys_array: *mut ArrayHeader, shape_id: u32, ) -> crate::object::ObjectKeys { - // SAFETY: a module keys global is a live keys array (or null). + let descriptor = crate::object::shapes::shape_descriptor_by_id(shape_id); + if let Some(descriptor) = descriptor { + if descriptor.keys != keys_array as u64 + && !keys_array.is_null() + // The global can belong to the spawning arena. Check ownership + // before reading its header; the ShapeId already names this + // agent's canonical keys, copied by install_worker_shape_seed. + && unsafe { + crate::value::addr_class::try_read_tracked_gc_header(keys_array as usize) + } + .is_none() + { + return descriptor.keys_view(); + } + } + // SAFETY: a local module keys global is a live keys array (or null). let owned = unsafe { crate::object::ObjectKeys::owned(keys_array) }; - match crate::object::shapes::shape_descriptor_by_id(shape_id) { + match descriptor { Some(descriptor) if descriptor.keys == keys_array as u64 && descriptor.logical_key_count <= owned.count() => diff --git a/crates/perry-runtime/src/object/class_birth_rep_tests.rs b/crates/perry-runtime/src/object/class_birth_rep_tests.rs index 09780b1921..4d24999eb8 100644 --- a/crates/perry-runtime/src/object/class_birth_rep_tests.rs +++ b/crates/perry-runtime/src/object/class_birth_rep_tests.rs @@ -157,6 +157,45 @@ fn a_region_word_refuses_a_boxed_store_into_an_f64_lane() { ); } +/// P7: a learned region may publish a Number-read word only for an exact +/// F64 identity lane. A wrong-rep receiver keeps the site empty for G. +#[test] +fn a_region_prime_refuses_a_requested_number_read_on_an_any_lane() { + use super::shapes::{js_region_loop_prime, REGION_GUARD_WORD_EMPTY}; + use core::sync::atomic::{AtomicU64, Ordering}; + + let k = keys(b"p7a\0p7b\0", 2); + let typed = js_object_shape_id_for_class_keys(k, 2, CID, REP_F64); + let untyped = js_object_shape_id_for_class_keys(k, 2, CID, REP_ANY); + let (a, b) = unsafe { + let (slots, len) = crate::object::keys_array_dense_slots_resolved( + k as usize as *const crate::array::ArrayHeader, + ); + assert!(len >= 2); + ((*slots).to_bits(), (*slots.add(1)).to_bits()) + }; + let site = AtomicU64::new(REGION_GUARD_WORD_EMPTY); + let prime = |id, key, r_mask| unsafe { + js_region_loop_prime(&site, id, 1, key, 0, 0, 0, 0, 0, 0, 0, r_mask) + }; + assert_eq!(prime(untyped, a, 1), REGION_GUARD_WORD_EMPTY); + assert_eq!(site.load(Ordering::Relaxed), REGION_GUARD_WORD_EMPTY); + assert_eq!(prime(typed, b, 1), REGION_GUARD_WORD_EMPTY); + assert_eq!(site.load(Ordering::Relaxed), REGION_GUARD_WORD_EMPTY); + let word = prime(typed, a, 1); + assert_ne!(word, REGION_GUARD_WORD_EMPTY); + assert_eq!(site.load(Ordering::Relaxed), word); + + // A deprecated lane is still safe for existing objects but is no + // longer a publishable identity fact for a new learned region. + assert!(super::shapes::shape_record_by_id(typed) + .expect("typed shape record") + .deprecate_rep_slot(0)); + site.store(REGION_GUARD_WORD_EMPTY, Ordering::Relaxed); + assert_eq!(prime(typed, a, 1), REGION_GUARD_WORD_EMPTY); + assert_eq!(site.load(Ordering::Relaxed), REGION_GUARD_WORD_EMPTY); +} + /// Design step 4 x T1: the rep is part of a static id's content, so a class /// birth with an `F64` lane adopts its static id like an all-`Any` one, and /// the same keys with the other rep are another content under another id. @@ -296,3 +335,94 @@ fn every_birth_path_of_a_rep_literal_and_class_stamps_one_shape_and_fills_its_f6 } } } + +/// A completed CF shape can serve an unrelated numeric lane, but a raw +/// Number store cannot preserve the method body's invariant. In particular, +/// boxed_mask=0 is not permission to bypass the checked SPECIAL store funnel. +#[test] +fn a_region_constfn_shape_keeps_numeric_admission_and_refuses_special_stores() { + use super::field_rep::REP_SPECIAL; + use super::shapes::{js_region_loop_pack, js_region_loop_prime, REGION_GUARD_WORD_EMPTY}; + use super::static_shapes::{ + js_object_final_shape_id_for_class_keys_static_constfn, ConstFnStaticEntry, + }; + use core::sync::atomic::{AtomicU64, Ordering}; + + extern "C" fn body( + _closure: *const crate::closure::ClosureHeader, + _this: crate::closure::JsThis, + ) -> f64 { + 7.0 + } + let _lock = crate::gc::global_side_table_test_lock(); + let info = crate::fn_info!(body, 0; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)); + let entries = [ConstFnStaticEntry { slot: 0, info }]; + let k = keys(b"p7cf_method\0p7cf_x\0", 2); + let rep = REP_SPECIAL | (REP_F64 << 2); + let completed = js_object_final_shape_id_for_class_keys_static_constfn( + k, + 2, + 2, + CID, + 0, + rep, + entries.as_ptr(), + 1, + ); + let descriptor = shape_descriptor_by_id(completed).expect("completed CF record"); + assert_eq!(descriptor.special_constfn_mask, 1); + assert_eq!(descriptor.rep, rep); + assert_eq!(descriptor.constfn_infos()[0].info, info as usize as u64); + let (method, x) = unsafe { + let (slots, len) = crate::object::keys_array_dense_slots_resolved( + k as usize as *const crate::array::ArrayHeader, + ); + assert!(len >= 2); + ((*slots).to_bits(), (*slots.add(1)).to_bits()) + }; + let pack = |stored_mask| js_region_loop_pack(completed, 2, method, x, 0, 0, 0, stored_mask, 0); + assert_ne!( + pack(0), + REGION_GUARD_WORD_EMPTY, + "read-only CF keys remain admitted" + ); + assert_ne!( + pack(2), + REGION_GUARD_WORD_EMPTY, + "Number store to x remains admitted" + ); + assert_eq!( + pack(1), + REGION_GUARD_WORD_EMPTY, + "even a Number cannot bare-store the CF method" + ); + let site = AtomicU64::new(REGION_GUARD_WORD_EMPTY); + let prime = |stored_mask, r_mask| unsafe { + js_region_loop_prime( + &site, + completed, + 2, + method, + x, + 0, + 0, + 0, + 0, + stored_mask, + 0, + r_mask, + ) + }; + assert_ne!( + prime(2, 2), + REGION_GUARD_WORD_EMPTY, + "numeric x read/store really primes R" + ); + site.store(REGION_GUARD_WORD_EMPTY, Ordering::Relaxed); + assert_eq!( + prime(1, 0), + REGION_GUARD_WORD_EMPTY, + "CF store refused even without R or boxed bits" + ); + assert_eq!(site.load(Ordering::Relaxed), REGION_GUARD_WORD_EMPTY); +} diff --git a/crates/perry-runtime/src/object/constfn_unload_tests.rs b/crates/perry-runtime/src/object/constfn_unload_tests.rs new file mode 100644 index 0000000000..387e199207 --- /dev/null +++ b/crates/perry-runtime/src/object/constfn_unload_tests.rs @@ -0,0 +1,110 @@ +//! Linux executable gate: info and code really live in a dlopen/dlclose image. +use super::*; + +#[test] +fn constfn_refuses_real_unloadable_image_before_dlclose() { + let _lock = crate::gc::global_side_table_test_lock(); + let _no_gc = crate::gc::GcSuppressScope::new(); + let dir = std::env::temp_dir().join(format!("perry-constfn-unload-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let source = dir.join("image.c"); + let image = dir.join("image.so"); + std::fs::write(&source, format!( + "#include \nunsigned char image_info[{}] __attribute__((aligned(8)));\ndouble image_method(void *closure, uint64_t receiver) {{ return 73.0; }}\n", + std::mem::size_of::(), + )).unwrap(); + let result = std::process::Command::new("cc") + .args(["-shared", "-fPIC", "-o"]) + .arg(&image) + .arg(&source) + .output() + .unwrap(); + assert!( + result.status.success(), + "fixture image build failed: {}", + String::from_utf8_lossy(&result.stderr) + ); + let path = std::ffi::CString::new(image.to_str().unwrap()).unwrap(); + unsafe { + let handle = libc::dlopen(path.as_ptr(), libc::RTLD_NOW | libc::RTLD_LOCAL); + assert!(!handle.is_null(), "fixture image must actually load"); + let code = libc::dlsym(handle, c"image_method".as_ptr()); + let info = + libc::dlsym(handle, c"image_info".as_ptr()) as *mut crate::closure::JsFunctionInfo; + assert!(!code.is_null() && !info.is_null()); + // The info itself lives in the image's writable BSS, then stays + // immutable while any live closure can use it. It lacks permanence. + // GC_STORE_AUDIT(POINTER_FREE): native image metadata contains code + // addresses and scalar flags, with no managed-heap edge. + info.write(crate::closure::JsFunctionInfo::from_code( + code as *const u8, + 0, + )); + let c = crate::closure::js_closure_alloc(info, 0); + assert_eq!( + crate::closure::js_closure_call0( + c as *const crate::closure::ClosureHeader, + crate::closure::JsThis::UNDEFINED + ), + 73.0, + "loaded-image body control" + ); + let obj = crate::object::js_object_alloc(0, 4); + let name = crate::string::js_string_from_bytes(b"unload_cf".as_ptr(), 9); + crate::object::js_object_set_field_by_name( + obj, + name, + f64::from_bits(crate::JSValue::object_ptr(c.cast()).bits()), + ); + let old = shapes::object_shape_stamp(obj); + assert_eq!( + shapes::shape_descriptor_by_id(old) + .unwrap() + .special_constfn_mask, + 0, + "generic key-add must exclude unloadable info" + ); + let entries = [ConstFnStaticEntry { slot: 0, info }]; + assert!(parse_constfn_static_entries(entries.as_ptr(), 1).is_none()); + let final_obj = js_object_finalize_constfn_static( + obj as usize as u64, + 0, + b"unload_cf\0".as_ptr(), + 10, + 1, + 1, + 0, + 3, + entries.as_ptr(), + 1, + ); + assert_eq!( + shapes::object_shape_stamp(final_obj as usize as *mut _), + old + ); + assert!(shapes::shape_descriptor_by_id(old) + .unwrap() + .constfn_infos() + .is_empty()); + // Revoke the only caller and its borrowed info before unmapping it. + crate::object::js_object_set_field_by_name( + obj, + name, + f64::from_bits(crate::value::TAG_UNDEFINED), + ); + (*(c as *mut crate::closure::ClosureHeader)).info = std::ptr::null(); + assert_eq!( + libc::dlclose(handle), + 0, + "fixture image must actually unload" + ); + assert!( + shapes::shape_descriptor_by_id(shapes::object_shape_stamp(obj)) + .unwrap() + .constfn_infos() + .is_empty(), + "no shape-owned image address may survive unload" + ); + } + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/perry-runtime/src/object/field_rep.rs b/crates/perry-runtime/src/object/field_rep.rs index e8c43c8cfc..b4312a1a23 100644 --- a/crates/perry-runtime/src/object/field_rep.rs +++ b/crates/perry-runtime/src/object/field_rep.rs @@ -8,7 +8,7 @@ //! | `00` | [`REP_ANY`] | the slot holds a NaN-boxed value | //! | `01` | [`REP_F64`] | the slot holds a JS Number as raw IEEE bits: never in the tag band, any NaN canonical, an INT32 box stored as a double | //! | `10` | [`REP_F64_DEPRECATED`] | the same invariant as `F64` for the objects that still carry the shape; the lineage has generalized the slot. A learned fact, never identity | -//! | `11` | [`REP_RESERVED`] | kept free for a later `I32` representation; nothing produces it | +//! | `11` | [`REP_SPECIAL`] | a shape-owned side fact selects ConstFn (the slot is a closure of one static body) or optional NoPointer (the slot has no GC pointer) | //! //! A slot at or past [`REP_SLOTS`], and every spill slot, is `Any`. //! @@ -24,8 +24,7 @@ pub(crate) const REP_SLOTS: u32 = 32; pub(crate) const REP_ANY: u64 = 0b00; pub(crate) const REP_F64: u64 = 0b01; pub(crate) const REP_F64_DEPRECATED: u64 = 0b10; -#[cfg(test)] // nothing produces it (the store check rejects it in debug) -pub(crate) const REP_RESERVED: u64 = 0b11; +pub(crate) const REP_SPECIAL: u64 = 0b11; /// The low bit of every 2-bit lane. const LANE_LOW: u64 = 0x5555_5555_5555_5555; @@ -66,6 +65,30 @@ pub(crate) fn is_valid(rep: u64) -> bool { rep & (rep >> 1) & LANE_LOW == 0 } +/// One bit per `11` lane. The shape's `special_constfn_mask` names the +/// ConstFn subset; the complement is reserved for NoPointer if P5 is accepted. +/// Existing callers of [`is_valid`] still reject every special lane. +#[inline] +pub(crate) fn special_lane_slots(rep: u64) -> u32 { + let mut lanes = rep & (rep >> 1) & LANE_LOW; + let mut slots = 0u32; + while lanes != 0 { + let bit = lanes.trailing_zeros(); + slots |= 1 << (bit / 2); + lanes &= lanes - 1; + } + slots +} + +/// The extended representation is valid only when every ConstFn bit names a +/// `11` lane. A `11` lane without that bit is the *reserved* NoPointer state; +/// no current producer requests it. ConstFn metadata coverage is checked by +/// the shape interner, which owns that metadata. +#[inline] +pub(crate) fn is_valid_with_special(rep: u64, special_constfn_mask: u32) -> bool { + special_constfn_mask & !special_lane_slots(rep) == 0 +} + /// The part of `rep` that is shape identity: every deprecated lane (`10`) /// reads as `F64` (`01`). The deprecated state is a learned fact of a record, /// like the #10905 width fields, and two records that differ only in it are @@ -73,6 +96,14 @@ pub(crate) fn is_valid(rep: u64) -> bool { #[inline] pub(crate) fn identity(rep: u64) -> u64 { debug_assert!(is_valid(rep), "reserved rep lane in {rep:#x}"); + identity_with_special(rep) +} + +/// Like [`identity`], preserving `11` as an identity code. The mask and the +/// static body identities of ConstFn lanes are folded separately by the shape +/// interner. This keeps every pre-SPECIAL F64 hash byte-identical. +#[inline] +pub(crate) fn identity_with_special(rep: u64) -> u64 { let deprecated = rep & LANE_HIGH & !(rep << 1); (rep & !deprecated) | (deprecated >> 1) } @@ -107,9 +138,61 @@ pub(crate) fn normalized(rep: u64) -> u64 { rep & LANE_LOW } -/// One bit per slot 0..[`REP_SLOTS`] whose lane is not `Any` (`F64` or -/// deprecated `F64`): the slots the collector skips when it traces an object -/// by its shape (DESIGN §3.1). +/// Normalize learned transitions without changing the identity of a carried +/// shape. `to_any` wins if a lineage first learned F64→NoPointer and later +/// observed a pointer. A special ConstFn or NoPointer lane can only go to Any. +/// With both masks zero this agrees with [`normalized`] for every old rep. +#[inline] +pub(crate) fn normalized_with_special(rep: u64, to_nopointer: u32, to_any: u32) -> u64 { + let mut normalized = rep; + for slot in 0..REP_SLOTS { + let bit = 1 << slot; + let lane = slot_rep(rep, slot); + let next = if to_any & bit != 0 { + REP_ANY + } else if lane == REP_F64_DEPRECATED { + if to_nopointer & bit != 0 { + REP_SPECIAL + } else { + REP_ANY + } + } else { + lane + }; + if next != lane { + normalized = with_slot_rep(normalized, slot, next); + } + } + normalized +} + +/// A structural publisher that has no static body list may keep old numeric +/// lanes but must conservatively drop ConstFn (and optional NoPointer) lanes. +/// Its result is valid for the legacy rep-only interner. +#[inline] +pub(crate) fn normalized_without_special(rep: u64) -> u64 { + let mut ordinary = rep; + let mut special = special_lane_slots(rep); + while special != 0 { + let slot = special.trailing_zeros(); + special &= special - 1; + ordinary = with_slot_rep(ordinary, slot, REP_ANY); + } + normalized(ordinary) +} + +/// Slots the collector may skip. ConstFn is pointer-bearing, while the +/// optional NoPointer half of `11` is not. This must not be used as a Number +/// fact by the type guard: NoPointer also admits booleans/null/undefined. +#[inline] +pub(crate) fn non_pointer_slot_bits(rep: u64, special_constfn_mask: u32) -> u32 { + let numeric = non_any_slot_bits(rep); + numeric | (special_lane_slots(rep) & !special_constfn_mask) +} + +/// One bit per slot whose lane is `F64` or deprecated `F64`: a Number fact +/// used by the type guard. The collector also skips the optional NoPointer +/// subset of SPECIAL, via [`non_pointer_slot_bits`]. #[inline] pub(crate) fn non_any_slot_bits(rep: u64) -> u32 { let mut x = (rep | (rep >> 1)) & LANE_LOW; @@ -118,7 +201,7 @@ pub(crate) fn non_any_slot_bits(rep: u64) -> u32 { x = (x | (x >> 4)) & 0x00FF_00FF_00FF_00FF; x = (x | (x >> 8)) & 0x0000_FFFF_0000_FFFF; x = (x | (x >> 16)) & 0x0000_0000_FFFF_FFFF; - x as u32 + (x as u32) & !special_lane_slots(rep) } /// The bits an `F64` slot stores for the JS value `value_bits`, or `None` @@ -197,6 +280,40 @@ mod tests { fn the_reserved_lane_is_rejected() { assert!(is_valid(LANE_LOW)); assert!(is_valid(LANE_HIGH)); - assert!(!is_valid(with_slot_rep(0, 7, REP_RESERVED))); + assert!(!is_valid(with_slot_rep(0, 7, REP_SPECIAL))); + } + + #[test] + fn special_lanes_preserve_old_f64_identity_and_separate_gc_facts() { + let old = with_slot_rep(0, 2, REP_F64_DEPRECATED); + assert_eq!(identity_with_special(old), identity(old)); + assert_eq!(normalized_with_special(old, 0, 0), normalized(old)); + let rep = with_slot_rep(with_slot_rep(old, 5, REP_SPECIAL), 8, REP_SPECIAL); + let constfn = 1 << 5; + assert_eq!(special_lane_slots(rep), constfn | (1 << 8)); + assert!(is_valid_with_special(rep, constfn)); + assert!(!is_valid_with_special(rep, constfn | (1 << 7))); + assert_eq!(non_any_slot_bits(rep), 1 << 2); + assert_eq!(non_pointer_slot_bits(rep, constfn), (1 << 2) | (1 << 8)); + assert_eq!(slot_rep(identity_with_special(rep), 5), REP_SPECIAL); + assert_eq!(normalized_without_special(rep), 0); + } + + #[test] + fn learned_target_can_escalate_from_nopointer_to_any() { + let old = with_slot_rep(0, 3, REP_F64_DEPRECATED); + assert_eq!( + slot_rep(normalized_with_special(old, 1 << 3, 0), 3), + REP_SPECIAL + ); + assert_eq!( + slot_rep(normalized_with_special(old, 1 << 3, 1 << 3), 3), + REP_ANY + ); + let special = with_slot_rep(0, 3, REP_SPECIAL); + assert_eq!( + slot_rep(normalized_with_special(special, 0, 1 << 3), 3), + REP_ANY + ); } } diff --git a/crates/perry-runtime/src/object/field_rep_store.rs b/crates/perry-runtime/src/object/field_rep_store.rs index eb6fe7e0bc..cc527a3880 100644 --- a/crates/perry-runtime/src/object/field_rep_store.rs +++ b/crates/perry-runtime/src/object/field_rep_store.rs @@ -20,13 +20,14 @@ //! ([`migrate_deprecated_receiver`]): one header store, no data movement, //! because a raw-double Number IS a boxed Number. //! -//! Nothing is added besides the record word: no per-object bit, no side -//! table. The only table is `by_facts`. +//! No per-object bit or side table is added. ConstFn body facts live in the +//! optional extension owned by that shape record; `by_facts` remains the +//! single identity table. use super::field_rep::{self, slot_rep, REP_ANY, REP_SLOTS}; use super::shapes::{ - object_shape_stamp, publish_shape_result, shape_descriptor_by_id, - shape_descriptor_intern_with_rep, shape_record_by_id, stamp_object_shape_id_with_carrier_note, + object_shape_stamp, publish_shape_result, shape_descriptor_by_id, shape_record_by_id, + shape_rep_by_id, stamp_object_shape_id_with_carrier_note, }; use super::ObjectHeader; @@ -37,14 +38,9 @@ pub(crate) unsafe fn object_slot_rep(obj: *const ObjectHeader, field_index: usiz if field_index >= REP_SLOTS as usize { return REP_ANY; } - let id = object_shape_stamp(obj); - if id == 0 { - return REP_ANY; - } - match shape_record_by_id(id) { - Some(record) => slot_rep(record.rep(), field_index as u32), - None => REP_ANY, - } + // An unstamped receiver (0) or an id with no record reads the absent + // record's word: `Any` in every lane. + slot_rep(shape_rep_by_id(object_shape_stamp(obj)), field_index as u32) } /// The store check of the runtime slot funnel (`slot_store`): the bits to @@ -59,7 +55,19 @@ pub(crate) unsafe fn checked_slot_bits( field_index: usize, value_bits: u64, ) -> u64 { - if object_slot_rep(obj, field_index) == REP_ANY { + let rep = object_slot_rep(obj, field_index); + if rep == REP_ANY { + return value_bits; + } + if rep == field_rep::REP_SPECIAL { + let record = shape_record_by_id(object_shape_stamp(obj)); + let expected = record.and_then(|r| r.constfn_info(field_index as u32)); + if expected.is_some() && expected == constfn_store_info(value_bits) { + return value_bits; + } + // A NoPointer producer, if P5 accepts it, needs its own admission + // rule. Until then any unmatched SPECIAL slot fails closed to Any. + object_store_generalize(obj, field_index as u32); return value_bits; } match field_rep::f64_slot_bits(value_bits) { @@ -71,6 +79,35 @@ pub(crate) unsafe fn checked_slot_bits( } } +/// A closure that can preserve an existing ConstFn body claim. Its address +/// is deliberately discarded: factory instances must keep their own current +/// closure and captured values in the slot. A rebindable `this` clone with +/// the same info is not safe for the method site's direct body call. +#[inline] +pub(crate) unsafe fn constfn_store_info(value_bits: u64) -> Option { + if value_bits & !crate::value::POINTER_MASK != crate::value::POINTER_TAG { + return None; + } + let addr = (value_bits & crate::value::POINTER_MASK) as usize; + if !crate::closure::is_closure_ptr(addr) { + return None; + } + let closure = addr as *const crate::closure::ClosureHeader; + let raw_count = (*closure).capture_count; + if raw_count & crate::closure::CAPTURES_THIS_FLAG != 0 + && raw_count & crate::closure::NO_THIS_REBIND_FLAG == 0 + && !crate::closure::closure_is_arrow(closure) + { + return None; + } + let info = (*closure).info; + let info = info.as_ref()?; + if info.flags & crate::codegen_abi::FN_PERMANENT_IMAGE == 0 { + return None; + } + Some(info as *const crate::closure::JsFunctionInfo as usize as u64) +} + /// T4: `obj` is about to store a non-Number into its `F64` (or deprecated) /// lane `slot`. Deprecate the lane on the carried shape and restamp `obj` to /// the normalized successor. The caller writes the value afterwards. @@ -100,7 +137,12 @@ pub(crate) unsafe fn object_store_generalize(obj: *mut ObjectHeader, slot: u32) /// normalized shape and new objects are born into it (DESIGN §1.5 step 4). /// Bounded like generalization itself: once per lane of a lineage. fn deprecate_lane(record: super::shapes::ShapeRecordRef, slot: u32) { - if record.deprecate_rep_slot(slot) { + let changed = if record.special_constfn_mask() & (1u32 << slot) != 0 { + record.deprecate_special_to_any(slot) + } else { + record.deprecate_rep_slot(slot) + }; + if changed { crate::object::proto_validity::bump_proto_validity(); crate::proxy::store_census(crate::proxy::C_REP_VALIDITY_BUMP); } @@ -171,7 +213,8 @@ pub(crate) unsafe fn migrate_deprecated_receiver(obj: *mut ObjectHeader) -> bool return false; } match shape_record_by_id(id) { - Some(record) if field_rep::has_deprecated(record.rep()) => {} + Some(record) + if field_rep::has_deprecated(record.rep()) || record.has_special_deprecation() => {} _ => return false, } let target = normalized_shape(id); @@ -204,6 +247,40 @@ pub(crate) fn migrate_on_miss_value(bits: u64) { } } +/// A completed ordinary ConstFn shape can serve the allocation's field +/// offsets and numeric lanes. The two records supply every fact; no mapping +/// from allocation id to final id is maintained. Writes still check the live +/// slot's rep before skipping the checked store funnel. +pub(crate) fn final_shape_matches_birth(actual: u32, expected: u32) -> bool { + if actual == expected { + return true; + } + let (Some(a), Some(b)) = ( + shape_descriptor_by_id(actual), + shape_descriptor_by_id(expected), + ) else { + return false; + }; + let base_rep = a.constfn_infos().iter().fold(a.rep, |rep, i| { + field_rep::with_slot_rep(rep, i.slot as u32, REP_ANY) + }); + a.special_constfn_mask != 0 + && b.special_constfn_mask == 0 + && a.object_kind == super::shapes::ShapeObjectKind::Ordinary + && a.object_kind == b.object_kind + && a.keys == b.keys + && a.logical_key_count == b.logical_key_count + && a.live_inline_slot_count == b.live_inline_slot_count + && a.proto_id == b.proto_id + && a.semantic_generation == 0 + && b.semantic_generation == 0 + && a.hole_count == 0 + && b.hole_count == 0 + && a.summary == 0 + && b.summary == 0 + && field_rep::identity_with_special(base_rep) == field_rep::identity_with_special(b.rep) +} + /// The rep of shape `id` (`Any` for an unknown id). #[inline] pub(crate) fn shape_rep(id: u32) -> u64 { @@ -218,7 +295,7 @@ pub(crate) fn shape_rep(id: u32) -> u64 { /// stores no value yet: its lane is `Any`. #[inline] pub(crate) fn key_add_rep(pred_rep: u64, slot: u32, value_bits: Option, inline: bool) -> u64 { - let carried = field_rep::normalized(pred_rep) & field_rep::lanes_below(slot); + let carried = field_rep::normalized_without_special(pred_rep) & field_rep::lanes_below(slot); if slot >= REP_SLOTS { return carried; } @@ -235,7 +312,9 @@ pub(crate) fn key_add_rep(pred_rep: u64, slot: u32, value_bits: Option, inl /// never serves (the slow path resolves onward to its normalized form), and /// an `Any` lane admits every value (always a valid claim; a lineage whose /// edge was learned from a non-Number converges on it). `value_bits` = `None` -/// is a key-only add, which an `F64` lane refuses. +/// is a key-only add, which an `F64` lane refuses. A SPECIAL target is +/// refused until the cached publication path can write the current closure +/// under Any before it stamps the body-specific shape. #[inline] pub(crate) fn cached_key_add_admits(target: u32, slot: u32, value_bits: Option) -> bool { let rep = shape_rep(target); @@ -245,22 +324,33 @@ pub(crate) fn cached_key_add_admits(target: u32, slot: u32, value_bits: Option true, + field_rep::REP_F64 => { + value_bits.is_some_and(|bits| field_rep::f64_slot_bits(bits).is_some()) + } + // The cached transition stamps its target before widening and + // writing the new slot. A SPECIAL target must take the ordered slow + // path until the cache hit prewrites under an Any predecessor. + field_rep::REP_SPECIAL => false, + _ => false, + } } /// T2 at a slow-path key-add: publish the keys edge `new_keys`, which appends -/// `slot`, with the successor's rep in the LAST publish before the caller's -/// value store, so the all-`Any` twin of the successor is never minted. +/// `slot`, with the successor's rep in the last publish. For F64 the final +/// shape precedes the caller's value store. For ConstFn, an Any intermediate +/// is minted first, the rooted closure is stored and traced there, and only +/// then is the body-specific successor stamped. /// Returns the id `obj` carries (the id to teach the transition cache). May /// mint, so it is a collection point: callers re-read their roots after it. /// /// * The bound grows (`slot` is outside the live bound): the keys edge is /// published at the OLD bound (the new slot is outside it, so that /// intermediate is the same shape as without step 5), then the bound -/// publish carries the rep. The slot holds its allocation-time `undefined` -/// from that stamp to the caller's store; nothing in between collects, and -/// no mint happens after the stamp (mint-then-stamp). +/// publish carries the rep. For F64 the slot holds its allocation-time +/// `undefined` until the caller's store. For ConstFn the bound publish is +/// Any and the closure is prewritten before SPECIAL is stamped. /// * The slot is already inside the live bound: a Number is written into it /// FIRST (a non-pointer, and the slot is past the key list, so no reader /// sees it), then the keys edge carries the rep. The `F64` claim holds at @@ -276,6 +366,20 @@ pub(crate) unsafe fn publish_key_add_edge( inline: bool, ) -> u32 { let rep = key_add_rep(pred_rep, slot, value_bits, inline); + // A ConstFn birth may be minted only for an executable-image body. Root + // the closure across the structural Any publication, which can collect. + // The final SPECIAL shape is published only after the current closure + // has been written into the now traced Any slot. + let candidate = if inline && slot < REP_SLOTS && !super::dictionary::is_dictionary(obj) { + value_bits.and_then(|bits| unsafe { constfn_store_info(bits) }) + } else { + None + }; + let scope = candidate.map(|_| crate::gc::RuntimeHandleScope::new()); + let value_root = scope + .as_ref() + .zip(value_bits) + .map(|(scope, bits)| scope.root_nanbox_f64(f64::from_bits(bits))); if inline && slot >= super::object_live_slot_count(obj) { super::set_object_keys(obj, new_keys); super::shapes::publish_object_live_slot_count_rep(obj, slot + 1, Some(rep)); @@ -288,7 +392,24 @@ pub(crate) unsafe fn publish_key_add_edge( let live = super::object_live_slot_count(obj); super::set_object_keys_with_live_rep(obj, new_keys, live, rep); } - let id = publish_key_add_rep(obj, pred_rep, slot, value_bits, inline); + let fresh_bits = value_root + .as_ref() + .map(|root| root.get_nanbox_f64().to_bits()) + .or(value_bits); + let constfn_info = candidate.filter(|&info| { + fresh_bits.is_some_and(|bits| unsafe { constfn_store_info(bits) } == Some(info)) + }); + if let (Some(_), Some(bits)) = (constfn_info, fresh_bits) { + // The current shape describes this slot as Any. The regular store + // barrier makes the closure visible to a moving collection before + // the body-specific shape is minted or stamped. + super::slot_store::store_object_field_slot(obj, slot as usize, bits); + } + let id = publish_key_add_rep(obj, pred_rep, slot, fresh_bits, inline, constfn_info); + let value_bits = value_root + .as_ref() + .map(|root| root.get_nanbox_f64().to_bits()) + .or(value_bits); match value_bits { Some(bits) if inline && id != 0 && !crate::object::dictionary::is_dictionary(obj) => { converge_key_add(obj, id, slot, bits) @@ -309,6 +430,7 @@ unsafe fn publish_key_add_rep( slot: u32, value_bits: Option, inline: bool, + constfn_info: Option, ) -> u32 { let id = object_shape_stamp(obj); if id == 0 || crate::object::dictionary::is_dictionary(obj) { @@ -318,24 +440,33 @@ unsafe fn publish_key_add_rep( return id; }; let rep = key_add_rep(pred_rep, slot, value_bits, inline); + let rep = if constfn_info.is_some() { + field_rep::with_slot_rep(rep, slot, field_rep::REP_SPECIAL) + } else { + rep + }; if rep == d.rep { return id; } - let target = normalized_shape(publish_shape_result(shape_descriptor_intern_with_rep( - d.keys as usize as *const crate::array::ArrayHeader, - d.logical_key_count, - d.live_inline_slot_count, - d.semantic_generation, - d.object_kind, - d.hole_count, - d.proto_id, - // The record's complete summary: the same facts, another rep. - d.summary, - rep, - // A re-intern of a live record's facts under another rep names no - // static id. - None, - ))); + let infos = constfn_info.map(|info| super::shapes::ConstFnSlotInfo { + slot: slot as u8, + info, + }); + let target = normalized_shape(publish_shape_result( + super::shapes::shape_descriptor_intern_with_special( + d.keys as usize as *const crate::array::ArrayHeader, + d.logical_key_count, + d.live_inline_slot_count, + d.semantic_generation, + d.object_kind, + d.hole_count, + d.proto_id, + d.summary, + rep, + infos.as_slice(), + None, + ), + )); if target != id { stamp_object_shape_id_with_carrier_note(obj, target); } @@ -350,6 +481,16 @@ pub(crate) fn shape_slot_is_any(id: u32, slot: u32) -> bool { object_slot_rep_of(id, slot) == REP_ANY } +/// Exact raw-double admission. SPECIAL may also be non-Any, but ConstFn +/// holds a pointer and must never take an emitted F64 store/read path. +#[inline] +pub(crate) fn shape_slot_is_f64(id: u32, slot: u32) -> bool { + matches!( + object_slot_rep_of(id, slot), + field_rep::REP_F64 | field_rep::REP_F64_DEPRECATED + ) +} + /// Does every trace of an object check the field-representation invariant /// ([`assert_f64_lanes_hold_numbers`])? Always in a debug build or with the /// `field-rep-assert` feature; with `gc-instruments`, when @@ -396,7 +537,7 @@ pub(crate) unsafe fn birth_fill_f64_lanes(obj: *mut ObjectHeader) { }; // Deprecated lanes too: a birth into a lineage that has generalized a // lane still carries it (the id is fixed), and the invariant covers it. - let mut lanes = field_rep::f64_lane_slots(field_rep::identity(record.rep())); + let mut lanes = field_rep::f64_lane_slots(field_rep::identity_with_special(record.rep())); if lanes == 0 { return; } @@ -437,7 +578,10 @@ pub(crate) unsafe fn assert_f64_lanes_hold_numbers( } let fields = (obj as *const u8).add(std::mem::size_of::()) as *const u64; for slot in 0..live.min(REP_SLOTS as usize) { - if slot_rep(rep, slot as u32) == REP_ANY { + if !matches!( + slot_rep(rep, slot as u32), + field_rep::REP_F64 | field_rep::REP_F64_DEPRECATED + ) { continue; } let bits = *fields.add(slot); @@ -460,11 +604,20 @@ pub(crate) fn normalized_shape(mut id: u32) -> u32 { let Some(d) = shape_descriptor_by_id(id) else { return id; }; - let rep = field_rep::normalized(d.rep); + let (to_nopointer, to_any) = d.deprecation_targets(); + let rep = field_rep::normalized_with_special(d.rep, to_nopointer, to_any); if rep == d.rep { return id; } - let next = publish_shape_result(shape_descriptor_intern_with_rep( + let infos: Vec<_> = d + .constfn_infos() + .iter() + .copied() + .filter(|entry| { + field_rep::slot_rep(rep, u32::from(entry.slot)) == field_rep::REP_SPECIAL + }) + .collect(); + let next = publish_shape_result(super::shapes::shape_descriptor_intern_with_special( d.keys as usize as *const crate::array::ArrayHeader, d.logical_key_count, d.live_inline_slot_count, @@ -475,6 +628,7 @@ pub(crate) fn normalized_shape(mut id: u32) -> u32 { // The record's complete summary: the same facts, another rep. d.summary, rep, + &infos, // A re-intern of a live record's facts under another rep names // no static id. None, @@ -489,5 +643,5 @@ pub(crate) fn normalized_shape(mut id: u32) -> u32 { #[inline] fn object_slot_rep_of(id: u32, slot: u32) -> u64 { - shape_record_by_id(id).map_or(REP_ANY, |record| slot_rep(record.rep(), slot)) + slot_rep(shape_rep_by_id(id), slot) } diff --git a/crates/perry-runtime/src/object/field_rep_store_tests.rs b/crates/perry-runtime/src/object/field_rep_store_tests.rs index e64b0ccccf..37436db353 100644 --- a/crates/perry-runtime/src/object/field_rep_store_tests.rs +++ b/crates/perry-runtime/src/object/field_rep_store_tests.rs @@ -12,6 +12,20 @@ use super::shapes::{ }; use super::ObjectHeader; +extern "C" fn constfn_body_a( + _closure: *const crate::closure::ClosureHeader, + _this: crate::closure::JsThis, +) -> f64 { + 11.0 +} + +extern "C" fn constfn_body_b( + _closure: *const crate::closure::ClosureHeader, + _this: crate::closure::JsThis, +) -> f64 { + 22.0 +} + fn key(name: &str) -> *mut crate::StringHeader { crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32) } @@ -57,6 +71,115 @@ unsafe fn slot_bits(obj: *mut ObjectHeader, index: usize) -> u64 { *fields.add(index) } +#[test] +fn constfn_same_body_keeps_shape_and_different_body_deprecates_on_store() { + let _lock = crate::gc::global_side_table_test_lock(); + unsafe { + let _no_move = crate::gc::GcSuppressScope::new(); + let body_a = + crate::fn_info!(constfn_body_a, 0; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)); + let first = crate::closure::js_closure_alloc(body_a, 0); + let second = crate::closure::js_closure_alloc(body_a, 0); + let other = crate::closure::js_closure_alloc( + crate::fn_info!(constfn_body_b, 0; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)), + 0, + ); + let bits = |c: *mut crate::closure::ClosureHeader| { + crate::value::js_nanbox_pointer(c as i64).to_bits() + }; + let obj = crate::object::js_object_alloc(0, 4); + crate::object::js_object_set_field_by_name( + obj, + key("constfn_method"), + f64::from_bits(bits(first)), + ); + let constfn = object_shape_stamp(obj); + let d = shape_descriptor_by_id(constfn).expect("keyed shape"); + assert_eq!(d.special_constfn_mask, 1, "runtime key-add minted ConstFn"); + assert!(!super::field_rep_store::shape_slot_is_f64(constfn, 0)); + assert_eq!(d.constfn_infos()[0].info, (*first).info as usize as u64); + let any = with_rep(constfn, REP_ANY); + assert_ne!(any, constfn); + // A cached edge cannot publish SPECIAL until its store is ordered + // before the shape stamp; it deliberately takes the slow path. + assert!(!super::field_rep_store::cached_key_add_admits( + constfn, + 0, + Some(bits(second)) + )); + let sibling = crate::object::js_object_alloc(0, 4); + crate::object::js_object_set_field_by_name( + sibling, + key("constfn_method"), + f64::from_bits(bits(second)), + ); + assert_eq!( + object_shape_stamp(sibling), + constfn, + "fresh closures share body shape" + ); + assert_eq!(slot_bits(sibling, 0), bits(second)); + let other_obj = crate::object::js_object_alloc(0, 4); + crate::object::js_object_set_field_by_name( + other_obj, + key("constfn_method"), + f64::from_bits(bits(other)), + ); + assert_ne!( + object_shape_stamp(other_obj), + constfn, + "different bodies split" + ); + crate::object::store_object_field_slot(obj, 0, bits(second)); + assert_eq!(object_shape_stamp(obj), constfn); + assert_eq!(slot_bits(obj, 0), bits(second), "load current closure"); + + crate::object::store_object_field_slot(obj, 0, bits(other)); + assert_eq!(object_shape_stamp(obj), any, "different body goes to Any"); + assert_eq!(slot_bits(obj, 0), bits(other)); + assert_eq!(object_shape_stamp(sibling), constfn); + assert!(migrate_deprecated_receiver(sibling)); + assert_eq!(object_shape_stamp(sibling), any); + assert_eq!(slot_bits(sibling, 0), bits(second)); + } +} + +#[test] +fn unloadable_body_stays_any_on_runtime_key_add() { + let _lock = crate::gc::global_side_table_test_lock(); + unsafe { + let closure = crate::closure::js_closure_alloc(crate::fn_info!(constfn_body_b, 0), 0); + let obj = crate::object::js_object_alloc(0, 4); + crate::object::js_object_set_field_by_name( + obj, + key("unloadable_method"), + crate::value::js_nanbox_pointer(closure as i64), + ); + let d = shape_descriptor_by_id(object_shape_stamp(obj)).expect("keyed shape"); + assert_eq!(d.special_constfn_mask, 0); + assert_eq!(slot_rep(d.rep, 0), REP_ANY); + } +} + +#[test] +fn rebindable_this_closure_stays_any_on_runtime_key_add() { + let _lock = crate::gc::global_side_table_test_lock(); + unsafe { + let info = + crate::fn_info!(constfn_body_a, 0; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)); + let closure = crate::closure::js_closure_alloc(info, crate::closure::CAPTURES_THIS_FLAG); + let obj = crate::object::js_object_alloc(0, 4); + crate::object::js_object_set_field_by_name( + obj, + key("rebindable_method"), + crate::value::js_nanbox_pointer(closure as i64), + ); + let d = shape_descriptor_by_id(object_shape_stamp(obj)).expect("keyed shape"); + assert_eq!(d.special_constfn_mask, 0); + assert_eq!(slot_rep(d.rep, 0), REP_ANY); + } +} + /// T3: a Number keeps an `F64` lane `F64`, and the funnel stores its /// canonical double (an INT32 box as its double, any NaN as the canonical /// NaN). Sabotage: skipping the check in `slot_store` stores the INT32 box @@ -432,8 +555,7 @@ fn a_class_instance_key_add_earns_the_lane_too() { REP_F64, "a plain object earns the lane" ); - let inst = crate::object::js_object_alloc(0, 4); - (*inst).class_id = 0x00C0_FFEE; + let inst = crate::object::js_object_alloc(0x00C0_FFEE, 4); assert!(!crate::object::is_anon_shape_class_id((*inst).class_id)); crate::object::js_object_set_field_by_name(inst, key("n"), 1.5); let inst_rep = super::field_rep_store::shape_rep(object_shape_stamp(inst)); diff --git a/crates/perry-runtime/src/object/field_set_by_name/tail.rs b/crates/perry-runtime/src/object/field_set_by_name/tail.rs index 8a2851508c..518f3daf0c 100644 --- a/crates/perry-runtime/src/object/field_set_by_name/tail.rs +++ b/crates/perry-runtime/src/object/field_set_by_name/tail.rs @@ -715,7 +715,13 @@ pub(crate) fn set_field_by_name_object_tail( // any other receiver. // #10868 step 2.5 stage 1: same un-latch hazard as the read // path's field-cache stamp — this publishes an explicit keys edge. - if !crate::object::dictionary::is_dictionary(obj) { + if !crate::object::dictionary::is_dictionary(obj) + && !super::shapes::shape_descriptor_by_id(super::shapes::object_shape_stamp(obj)) + .is_some_and(|d| d.special_constfn_mask != 0) + { + // `publish_key_add_edge` already minted the exact ConstFn + // successor after writing the closure. The legacy redundant + // birth stamp would conservatively erase that body fact. super::shapes::stamp_object_shape(obj, new_keys.arr(), 1, 1); } return; diff --git a/crates/perry-runtime/src/object/method_site.rs b/crates/perry-runtime/src/object/method_site.rs index 6cbb48530c..45e9d4f8fb 100644 --- a/crates/perry-runtime/src/object/method_site.rs +++ b/crates/perry-runtime/src/object/method_site.rs @@ -100,6 +100,8 @@ pub const METHOD_SITE_SPILL: u64 = 1 << 62; /// (`ClosureHeader::props`, `closure/props.rs`). A keyed Function ShapeId is /// canonical per that object's key list, so the receiver word pins the slot. pub const METHOD_SITE_FUNCTION_BAG: u64 = crate::codegen_abi::METHOD_SITE_FUNCTION_BAG; +/// An own inline method whose ShapeId owns the body's identity. +pub const METHOD_SITE_CONSTFN: u64 = crate::codegen_abi::METHOD_SITE_CONSTFN; /// The index bits of an entry's `slot` word. pub const METHOD_SITE_INDEX_MASK: u64 = crate::codegen_abi::METHOD_SITE_INDEX_MASK; @@ -110,7 +112,8 @@ pub const METHOD_SITE_INDEX_MASK: u64 = crate::codegen_abi::METHOD_SITE_INDEX_MA pub struct MethodEntry { /// The receiver's `(class_id | ShapeId << 32)` word. pub word: u64, - /// Own entry: the inline slot. Inherited entry: [`METHOD_SITE_INHERITED`]. + /// Own entry: the inline slot, optionally tagged as ConstFn. Inherited + /// entry: [`METHOD_SITE_INHERITED`] plus the direct holder's slot index. pub slot: u64, /// The method body's `JsFunctionInfo` (the identity an own hit compares /// the slot closure's info word with). @@ -272,6 +275,7 @@ per_test_global! { static HOLDER_REWRITES: AtomicU64 = AtomicU64::new(0); static MISSES: AtomicU64 = AtomicU64::new(0); static PRIMES_FUNCTION: AtomicU64 = AtomicU64::new(0); + static PRIMES_CONSTFN: AtomicU64 = AtomicU64::new(0); } /// Function-bag entries primed ([`METHOD_SITE_FUNCTION_BAG`]). @@ -289,7 +293,7 @@ pub fn method_site_stats() -> (u64, u64, u64) { } /// `js_method_site_stats(which)`: 0 own primes, 1 inherited primes, 2 misses, -/// 3 function-bag primes. +/// 3 function-bag primes, 4 ConstFn own primes. /// Exposed so gap tests can prove a path ran. #[no_mangle] pub extern "C" fn js_method_site_stats(which: i32) -> f64 { @@ -298,6 +302,7 @@ pub extern "C" fn js_method_site_stats(which: i32) -> f64 { 0 => a, 1 => b, 3 => method_site_function_primes(), + 4 => PRIMES_CONSTFN.load(Ordering::Relaxed), _ => c, }) as f64 } @@ -322,8 +327,9 @@ fn stats_report_enabled() -> bool { let (ap, ah) = read_holder::read_accessor_stats(); let (cp, ch, cr) = read_holder::class_read_stats(); eprintln!( - "[method-site] primes_own={a} primes_inherited={b} primes_function={} holder_rewrites={} misses={c} read_holder_primes={hd} read_absent_primes={ha} read_accessor_primes={ap} read_accessor_hits={ah} read_accessor_class_primes={} class_read_primes={cp} class_read_hits={ch} class_read_root_rewrites={cr} read_holder_rewrites={} read_accessor_rewrites={} read_accessor_same_shape_relinks={} read_holder_refused={hr}{refused}", + "[method-site] primes_own={a} primes_inherited={b} primes_function={} primes_constfn={} holder_rewrites={} misses={c} read_holder_primes={hd} read_absent_primes={ha} read_accessor_primes={ap} read_accessor_hits={ah} read_accessor_class_primes={} class_read_primes={cp} class_read_hits={ch} class_read_root_rewrites={cr} read_holder_rewrites={} read_accessor_rewrites={} read_accessor_same_shape_relinks={} read_holder_refused={hr}{refused}", method_site_function_primes(), + PRIMES_CONSTFN.load(Ordering::Relaxed), HOLDER_REWRITES.load(Ordering::Relaxed), read_holder::read_accessor_class_primes(), read_holder::read_holder_rewrites(), @@ -625,6 +631,27 @@ unsafe fn prime(slot: *mut MethodSiteSlot, recv: f64, name: &[u8], argc: usize) refuse(13); return; } + let slot_word = if s < crate::object::field_rep::REP_SLOTS + && shape.special_constfn_mask & (1 << s) != 0 + { + // The shape, not this closure object, owns the body fact. A + // freshly allocated factory closure may have different captures; + // the hit must still load that receiver's current slot. + let body = shape + .constfn_infos() + .iter() + .find(|entry| u32::from(entry.slot) == s) + .map(|entry| entry.info); + if body != Some(info as *const crate::closure::JsFunctionInfo as u64) + || slot_word & METHOD_SITE_SPILL != 0 + { + refuse(17); + return; + } + slot_word | METHOD_SITE_CONSTFN + } else { + slot_word + }; let entry = MethodEntry { word, slot: slot_word, @@ -635,6 +662,9 @@ unsafe fn prime(slot: *mut MethodSiteSlot, recv: f64, name: &[u8], argc: usize) }; if publish(slot, entry) { PRIMES_OWN.fetch_add(1, Ordering::Relaxed); + if slot_word & METHOD_SITE_CONSTFN != 0 { + PRIMES_CONSTFN.fetch_add(1, Ordering::Relaxed); + } } return; } @@ -1043,3 +1073,150 @@ pub(crate) fn scan_method_site_roots_mut(visitor: &mut crate::gc::RuntimeRootVis } } } + +#[cfg(test)] +mod constfn_tests { + use super::*; + + extern "C" fn method( + _closure: *const crate::closure::ClosureHeader, + _this: crate::closure::JsThis, + ) -> f64 { + 7.0 + } + + unsafe fn one_method(info: *const crate::closure::JsFunctionInfo) -> (*mut ObjectHeader, u32) { + let closure = crate::closure::js_closure_alloc(info, 0); + let obj = crate::object::js_object_alloc(0, 4); + let key = b"constfn_site_method"; + let name = crate::string::js_string_from_bytes(key.as_ptr(), key.len() as u32); + crate::object::js_object_set_field_by_name( + obj, + name, + crate::value::js_nanbox_pointer(closure as i64), + ); + (obj, super::super::shapes::object_shape_stamp(obj)) + } + + unsafe fn primed_slot(obj: *mut ObjectHeader) -> u64 { + let mut slot: MethodSiteSlot = std::ptr::null_mut(); + prime( + &mut slot, + crate::value::js_nanbox_pointer(obj as i64), + b"constfn_site_method", + 0, + ); + assert!(!slot.is_null(), "eligible method site must prime"); + let word = std::ptr::read(obj as *const u64); + (*slot) + .entries + .iter() + .find(|entry| entry.word == word) + .expect("site entry for receiver shape") + .slot + } + + #[test] + fn constfn_site_uses_shape_body_fact_only_for_permanent_images() { + if !run_with_fresh_worker_gate( + "constfn_site_uses_shape_body_fact_only_for_permanent_images", + ) { + return; + } + let _lock = crate::gc::global_side_table_test_lock(); + unsafe { + let _no_move = crate::gc::GcSuppressScope::new(); + let permanent = + crate::fn_info!(method, 0; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)); + let (object, id) = one_method(permanent); + let d = super::super::shapes::shape_descriptor_by_id(id).expect("shape"); + assert_eq!(d.special_constfn_mask, 1); + assert_eq!(primed_slot(object), METHOD_SITE_CONSTFN); + + // An unloadable image has no ConstFn shape fact. It may still use + // the existing guarded own-method entry, which validates the + // closure's kind and body info on every hit. + let transient = crate::fn_info!(method, 0); + let (object, id) = one_method(transient); + let d = super::super::shapes::shape_descriptor_by_id(id).expect("shape"); + assert_eq!(d.special_constfn_mask, 0); + assert_eq!(primed_slot(object), 0); + } + } + + #[test] + fn constfn_static_captured_this_arrow_primes_and_rebinding_closure_refuses() { + if !run_with_fresh_worker_gate( + "constfn_static_captured_this_arrow_primes_and_rebinding_closure_refuses", + ) { + return; + } + let _lock = crate::gc::global_side_table_test_lock(); + unsafe { + let _no_gc = crate::gc::GcSuppressScope::new(); + let arrow = crate::fn_info!(method, 0; with_flags( + crate::codegen_abi::FN_PERMANENT_IMAGE | crate::closure::FN_ARROW + )); + let rebinding = + crate::fn_info!(method, 0; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)); + let packed = b"constfn_site_method\0"; + let keys = + super::super::static_shapes::canonical_keys_for_names(&[b"constfn_site_method"]); + for (info, admitted) in [(arrow, true), (rebinding, false)] { + let obj = crate::object::alloc_plain::alloc_plain_record_inline_keys_stamped( + 1, + keys.arr() as *mut _, + 0, + ); + let base = super::super::shapes::object_shape_stamp(obj); + let birth = super::super::shapes::shape_descriptor_by_id(base).unwrap(); + assert_eq!( + birth.object_kind, + super::super::shapes::ShapeObjectKind::Ordinary + ); + assert_eq!(birth.special_constfn_mask, 0, "allocation must stay Any"); + let c = + crate::closure::js_closure_alloc(info, crate::closure::CAPTURES_THIS_FLAG | 1); + crate::closure::js_closure_set_capture_bits( + c, + 0, + crate::JSValue::object_ptr(obj.cast()).bits(), + ); + crate::object::store_object_field_slot( + obj, + 0, + crate::JSValue::object_ptr(c.cast()).bits(), + ); + let entries = [super::super::static_shapes::ConstFnStaticEntry { slot: 0, info }]; + let finalized = super::super::static_shapes::js_object_finalize_constfn_static( + obj as usize as u64, + 0, + packed.as_ptr(), + packed.len() as u32, + 1, + 1, + 0, + super::super::field_rep::REP_SPECIAL, + entries.as_ptr(), + 1, + ) as usize as *mut ObjectHeader; + let id = super::super::shapes::object_shape_stamp(finalized); + let d = super::super::shapes::shape_descriptor_by_id(id).unwrap(); + assert_eq!(d.special_constfn_mask != 0, admitted); + if admitted { + assert_ne!(base, id, "ordinary allocation must finalize after stores"); + assert!(super::super::field_rep_store::final_shape_matches_birth( + id, base + )); + assert_eq!(primed_slot(finalized), METHOD_SITE_CONSTFN); + assert_eq!( + crate::closure::js_closure_get_capture_bits(c, 0), + crate::JSValue::object_ptr(finalized.cast()).bits() + ); + } else { + assert_eq!(id, base, "captured-this rebinding remains excluded"); + } + } + } + } +} diff --git a/crates/perry-runtime/src/object/region_numeric_read_tests.rs b/crates/perry-runtime/src/object/region_numeric_read_tests.rs new file mode 100644 index 0000000000..02a23c1ced --- /dev/null +++ b/crates/perry-runtime/src/object/region_numeric_read_tests.rs @@ -0,0 +1,328 @@ +//! Read-only numeric words use own-slot proof without granting store rights. + +use super::*; +use crate::object::field_rep::{slot_rep, REP_ANY, REP_F64}; +use core::sync::atomic::{AtomicU64, Ordering}; + +fn key(name: &str) -> *mut crate::StringHeader { + crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32) +} + +fn bits(key: *const crate::StringHeader) -> u64 { + crate::value::js_nanbox_string(key as i64).to_bits() +} + +unsafe fn record(name: &str) -> (*mut crate::ObjectHeader, *mut crate::StringHeader) { + let obj = crate::object::js_object_alloc(0, 0); + let key = key(name); + let boxed = crate::value::js_nanbox_pointer(obj as i64); + crate::proxy::js_put_value_set( + boxed, + f64::from_bits(bits(self::key("rnr_kind"))), + f64::from_bits(bits(self::key("min"))), + boxed, + 0, + ); + crate::proxy::js_put_value_set(boxed, f64::from_bits(bits(key)), 7.0, boxed, 0); + let d = object_shape_descriptor(obj).unwrap(); + assert_eq!(d.object_kind, ShapeObjectKind::OrdinaryUnmarked); + assert_eq!(d.semantic_generation, 0); + assert_eq!(d.hole_count, 0); + assert_eq!(d.summary, 0); + assert_eq!(slot_rep(d.rep, 0), REP_ANY); + assert_eq!(slot_rep(d.rep, 1), REP_F64); + (obj, key) +} + +unsafe fn prime(site: &AtomicU64, obj: *const crate::ObjectHeader, key: u64) -> u64 { + js_region_loop_prime( + site, + object_shape_stamp(obj), + 1, + key, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + ) +} + +#[test] +fn unmarked_numeric_read_publishes_and_reads_the_production_slot() { + let _lock = crate::gc::global_side_table_test_lock(); + let _gc = crate::gc::GcSuppressScope::new(); + unsafe { + let (obj, key) = record("rnr_positive"); + let site = AtomicU64::new(REGION_GUARD_WORD_EMPTY); + let word = prime(&site, obj, bits(key)); + assert_ne!(word, REGION_GUARD_WORD_EMPTY); + assert_eq!(site.load(Ordering::Relaxed), word); + assert_eq!(word as u32, object_shape_stamp(obj)); + let slot = ((word >> 32) & 63) as usize; + assert_eq!( + slot, 1, + "the string kind precedes the numeric value, as in Zod" + ); + let raw = (obj as *const u8).add(core::mem::size_of::() + slot * 8) + as *const f64; + assert_eq!(*raw, 7.0); + assert_eq!( + crate::object::js_object_get_field_by_name(obj, key).bits(), + (*raw).to_bits() + ); + assert!(!store_kind::shape_admits_plain_store(word as u32)); + } +} + +#[test] +fn unmarked_numeric_read_does_not_admit_stores_or_non_numeric_regions() { + let _lock = crate::gc::global_side_table_test_lock(); + let _gc = crate::gc::GcSuppressScope::new(); + unsafe { + let (obj, key) = record("rnr_store"); + let id = object_shape_stamp(obj); + for (stored, boxed, r) in [ + (1, 0, 1), + (1, 1, 1), + (0, 1, 1), + (0, 0, 0), + (2, 0, 1), + (0, 0, 1 << 31), + (0, 0, 3), + ] { + assert_eq!( + region_loop_pack(id, 1, [bits(key), 0, 0, 0, 0], stored, boxed, r), + Err(RegionRefusal::Kind), + "stored={stored} boxed={boxed} R={r}" + ); + } + } +} + +#[test] +fn unmarked_numeric_read_retains_generation_holes_rep_and_absence_checks() { + let _lock = crate::gc::global_side_table_test_lock(); + let _gc = crate::gc::GcSuppressScope::new(); + unsafe { + let (obj, key) = record("rnr_facts"); + let d = object_shape_descriptor(obj).unwrap(); + let mint = |generation, holes, rep| { + publish_shape_result(shape_descriptor_ensure_with_rep( + d.keys as usize as *const ArrayHeader, + d.logical_key_count, + d.live_inline_slot_count, + generation, + d.object_kind, + holes, + d.proto_id, + 0, + rep, + None, + )) + }; + for (id, expected) in [ + (mint(1, 0, d.rep), RegionRefusal::Kind), + (mint(0, 1, d.rep), RegionRefusal::Kind), + (mint(0, 0, REP_ANY), RegionRefusal::Rep), + ] { + assert_eq!( + region_loop_pack(id, 1, [bits(key), 0, 0, 0, 0], 0, 0, 1), + Err(expected) + ); + } + assert_eq!( + region_loop_pack( + object_shape_stamp(obj), + 1, + [bits(self::key("rnr_absent")), 0, 0, 0, 0], + 0, + 0, + 1 + ), + Err(RegionRefusal::Absent) + ); + } +} + +#[test] +fn unmarked_numeric_read_refuses_accessors_and_deprecated_lanes() { + let _lock = crate::gc::global_side_table_test_lock(); + let _gc = crate::gc::GcSuppressScope::new(); + unsafe { + let (obj, key) = record("rnr_accessor"); + let site = AtomicU64::new(REGION_GUARD_WORD_EMPTY); + let before = prime(&site, obj, bits(key)); + let boxed = crate::value::js_nanbox_pointer(obj as i64); + let undefined = f64::from_bits(crate::value::TAG_UNDEFINED); + crate::object::js_object_define_accessor( + boxed, + f64::from_bits(bits(key)), + undefined, + undefined, + ); + assert_ne!(object_shape_stamp(obj), before as u32); + assert_ne!(object_shape_descriptor(obj).unwrap().summary, 0); + assert_eq!(prime(&site, obj, bits(key)), REGION_GUARD_WORD_EMPTY); + + let (other, other_key) = record("rnr_deprecated"); + shape_record_by_id(object_shape_stamp(other)) + .unwrap() + .deprecate_rep_slot(1); + assert_eq!( + prime(&site, other, bits(other_key)), + REGION_GUARD_WORD_EMPTY + ); + } +} + +#[test] +fn unmarked_numeric_read_word_misses_after_mutation_or_exotic_reclassification() { + let _lock = crate::gc::global_side_table_test_lock(); + let _gc = crate::gc::GcSuppressScope::new(); + unsafe { + let (obj, key) = record("rnr_mutation"); + let site = AtomicU64::new(REGION_GUARD_WORD_EMPTY); + let before = prime(&site, obj, bits(key)); + let boxed = crate::value::js_nanbox_pointer(obj as i64); + crate::proxy::js_put_value_set( + boxed, + f64::from_bits(bits(key)), + f64::from_bits(crate::value::TAG_TRUE), + boxed, + 0, + ); + assert_ne!(object_shape_stamp(obj), before as u32); + assert_eq!(prime(&site, obj, bits(key)), REGION_GUARD_WORD_EMPTY); + + let (proto, proto_key) = record("rnr_proto"); + let before = prime(&site, proto, bits(proto_key)); + crate::object::js_object_set_prototype_of( + crate::value::js_nanbox_pointer(proto as i64), + f64::from_bits(crate::value::TAG_NULL), + ); + assert_ne!(object_shape_stamp(proto), before as u32); + assert_eq!( + object_shape_descriptor(proto).unwrap().proto_id, + PROTO_ID_NULL + ); + + let (exotic, exotic_key) = record("rnr_exotic"); + let before = prime(&site, exotic, bits(exotic_key)); + crate::object::proto_validity::mark_exotic_read_receiver(exotic as usize); + assert_ne!(object_shape_stamp(exotic), before as u32); + assert_eq!( + object_shape_descriptor(exotic).unwrap().proto_id, + PROTO_ID_PER_OBJECT + ); + assert_eq!( + prime(&site, exotic, bits(exotic_key)), + REGION_GUARD_WORD_EMPTY + ); + crate::object::js_object_set_prototype_of( + crate::value::js_nanbox_pointer(exotic as i64), + f64::from_bits(crate::value::TAG_NULL), + ); + assert_eq!(object_proto_id(exotic), PROTO_ID_PER_OBJECT); + assert_eq!( + object_shape_descriptor(exotic).unwrap().proto_id, + PROTO_ID_PER_OBJECT + ); + assert_eq!( + prime(&site, exotic, bits(exotic_key)), + REGION_GUARD_WORD_EMPTY, + "a null prototype cannot erase virtual read semantics" + ); + } +} + +#[test] +fn native_namespace_virtual_value_cannot_publish_a_numeric_own_slot_word() { + let _lock = crate::gc::global_side_table_test_lock(); + let _gc = crate::gc::GcSuppressScope::new(); + unsafe { + // Manufacture an F64 physical slot before giving the receiver its + // namespace brand. The virtual override then differs from the slot. + let obj = crate::object::js_object_alloc(0, 0); + let module_key = self::key("__module__"); + let module_value = self::key("rnr_test_namespace"); + crate::object::js_object_set_field_by_name( + obj, + module_key, + f64::from_bits(bits(module_value)), + ); + let key = self::key("rnr_native"); + crate::object::js_object_set_field_by_name(obj, key, 7.0); + let rep = object_shape_descriptor(obj).unwrap().rep; + assert_eq!(slot_rep(rep, 1), REP_F64); + (*obj).class_id = crate::object::NATIVE_MODULE_CLASS_ID; + restamp_object_proto_id(obj); + // The prototype transition conservatively loses rep facts. Mint an + // artificial but compatible native F64 descriptor, so rejection + // cannot be explained by a missing numeric lane. + let native = object_shape_descriptor(obj).unwrap(); + let native_f64 = publish_shape_result(shape_descriptor_ensure_with_rep( + native.keys as usize as *const ArrayHeader, + native.logical_key_count, + native.live_inline_slot_count, + native.semantic_generation, + native.object_kind, + native.hole_count, + native.proto_id, + native.summary, + rep, + None, + )); + stamp_object_shape_id_with_carrier_note(obj, native_f64); + crate::object::native_module::install_native_module_vtable(); + crate::object::native_module::native_namespace_prop_override_store( + "rnr_test_namespace", + "rnr_native", + 17.0, + ); + assert_eq!( + crate::object::js_object_get_field_by_name(obj, key).bits(), + 17.0f64.to_bits() + ); + let d = object_shape_descriptor(obj).unwrap(); + assert_eq!(d.object_kind, ShapeObjectKind::OrdinaryUnmarked); + assert_eq!(slot_rep(d.rep, 1), REP_F64); + assert_eq!(d.proto_id, PROTO_ID_PER_OBJECT); + let site = AtomicU64::new(REGION_GUARD_WORD_EMPTY); + assert_eq!(prime(&site, obj, bits(key)), REGION_GUARD_WORD_EMPTY); + assert_eq!(site.load(Ordering::Relaxed), REGION_GUARD_WORD_EMPTY); + + let born = crate::object::js_object_alloc(crate::object::NATIVE_MODULE_CLASS_ID, 0); + assert_eq!( + object_shape_descriptor(born).unwrap().proto_id, + PROTO_ID_PER_OBJECT, + "namespace classification is present before any key or cache is installed" + ); + } +} + +#[test] +fn unmarked_numeric_read_refuses_spill_even_when_the_generic_value_is_number() { + let _lock = crate::gc::global_side_table_test_lock(); + let _gc = crate::gc::GcSuppressScope::new(); + unsafe { + let obj = crate::object::js_object_alloc(0, 0); + let mut last = core::ptr::null_mut(); + for i in 0..40 { + last = key(&format!("rnr_spill_{i}")); + crate::object::js_object_set_field_by_name(obj, last, i as f64); + } + let d = object_shape_descriptor(obj).unwrap(); + let location = region_key_location(&d, bits(last)).unwrap(); + assert!(location.0, "premise: requested numeric value is spilled"); + let site = AtomicU64::new(REGION_GUARD_WORD_EMPTY); + assert_eq!( + crate::object::js_object_get_field_by_name(obj, last).bits(), + 39.0f64.to_bits() + ); + assert_eq!(prime(&site, obj, bits(last)), REGION_GUARD_WORD_EMPTY); + } +} diff --git a/crates/perry-runtime/src/object/shapes.rs b/crates/perry-runtime/src/object/shapes.rs index b36b24c654..86ffae07bc 100644 --- a/crates/perry-runtime/src/object/shapes.rs +++ b/crates/perry-runtime/src/object/shapes.rs @@ -54,6 +54,7 @@ pub(crate) use shapes_slot_list::{ shape_index_migrate_after_delete, shape_index_shift_in_place, try_update_stable_tombstone_shape, try_update_stable_tombstone_shape_cached, SlotIndex, }; +pub(crate) use shapes_store::ConstFnSlotInfo; pub(crate) use shapes_store::PERRY_EMPTY_SHAPE_DIR; use shapes_store::{ IdList, ShapeRecord, ShapeSlab, RECORD_FLAG_BIRTH_OWNER, RECORD_FLAG_CACHE_CARRIER, @@ -151,11 +152,43 @@ pub(crate) struct ShapeDescriptor { /// Charter step 5: the per-slot field representation (`field_rep`). /// Compared under [`field_rep::identity`](super::field_rep::identity). pub(crate) rep: u64, + /// For a `REP_SPECIAL` lane, one means ConstFn; zero reserves NoPointer. + pub(crate) special_constfn_mask: u32, + /// Borrowed record-owned extension; valid while this descriptor's ShapeId + /// remains live. Never an independent GC root or a lookup table. + pub(crate) extras: u64, } /// Shape identity is the FACTS, never the storage address. A descriptor value /// lifted out of the table compares equal to the record it came from. impl ShapeDescriptor { + #[inline] + pub(crate) fn constfn_infos(&self) -> &[shapes_store::ConstFnSlotInfo] { + if self.extras == 0 { + &[] + } else { + // SAFETY: the live slab record owns the extension; the descriptor + // is only used while its id is live, like its `record` pointer. + unsafe { &(*(self.extras as usize as *const shapes_store::ShapeExtras)).constfn_infos } + } + } + + #[inline] + pub(crate) fn deprecation_targets(&self) -> (u32, u32) { + if self.extras == 0 { + (0, 0) + } else { + // SAFETY: a live descriptor borrows the record-owned extension. + let extras = unsafe { &*(self.extras as usize as *const shapes_store::ShapeExtras) }; + ( + extras + .to_nopointer + .load(std::sync::atomic::Ordering::Acquire), + extras.to_any.load(std::sync::atomic::Ordering::Acquire), + ) + } + } + /// This shape's ordered keys: the keys array and the shape's own count, /// which is the authority (the array can be a longer shared backing). #[inline] @@ -247,6 +280,38 @@ impl ShapeRecordRef { self.rep_word().load(std::sync::atomic::Ordering::Relaxed) } + /// Which `REP_SPECIAL` lanes hold closure pointers, so the collector + /// still visits them. A zero bit reserves NoPointer for a later P5 mint. + #[inline] + pub(crate) fn special_constfn_mask(self) -> u32 { + // SAFETY: a live slab record (type docs); identity is immutable. + unsafe { (*self.0.as_ptr()).special_constfn_mask() } + } + + #[inline] + pub(crate) fn constfn_info(self, slot: u32) -> Option { + // SAFETY: a live slab record (type docs). + unsafe { + (*self.0.as_ptr()) + .constfn_infos() + .iter() + .find(|entry| u32::from(entry.slot) == slot) + .map(|entry| entry.info) + } + } + + #[inline] + pub(crate) fn deprecate_special_to_any(self, slot: u32) -> bool { + // SAFETY: a live slab record (type docs), with an atomic learned bit. + unsafe { (*self.0.as_ptr()).deprecate_special_to_any(slot) } + } + + #[inline] + pub(crate) fn has_special_deprecation(self) -> bool { + // SAFETY: a live slab record (type docs). + unsafe { (*self.0.as_ptr()).deprecation_targets().1 != 0 } + } + /// Mark `slot` deprecated (`F64` -> `10`, `field_rep`): the lineage has /// generalized it. A learned fact of the record, masked out of identity, /// so the record's facts key and its `by_facts` bucket do not move, and @@ -577,7 +642,10 @@ impl PartialEq for ShapeDescriptor { && self.object_kind == other.object_kind && self.hole_count == other.hole_count && self.summary == other.summary - && super::field_rep::identity(self.rep) == super::field_rep::identity(other.rep) + && super::field_rep::identity_with_special(self.rep) + == super::field_rep::identity_with_special(other.rep) + && self.special_constfn_mask == other.special_constfn_mask + && self.constfn_infos() == other.constfn_infos() } } @@ -914,7 +982,7 @@ pub(crate) struct ShapeTable { impl ShapeTable { pub(crate) fn new() -> Self { ShapeTable { - slab: std::cell::UnsafeCell::new(ShapeSlab::new()), + slab: std::cell::UnsafeCell::new(ShapeSlab::new_agent()), inner: RefCell::new(ShapeTableInner { indices: crate::fast_hash::new_ptr_hash_map(), by_facts: crate::fast_hash::new_ptr_hash_map(), @@ -1427,6 +1495,88 @@ pub(crate) fn shape_descriptor_intern_with_rep( if !super::field_rep::is_valid(rep) { return Err(ShapeDescriptorError::InvalidFacts); } + shape_descriptor_intern_with_special( + keys, + logical_key_count, + live_inline_slot_count, + semantic_generation, + object_kind, + hole_count, + proto_id, + summary, + rep, + &[], + requested, + ) +} + +/// The exact shape mint for an optional set of static ConstFn body facts. +/// Existing callers use the wrapper above and preserve their old identity. +#[allow(clippy::too_many_arguments)] +#[cfg_attr(feature = "shape-mint-diag", track_caller)] +pub(crate) fn shape_descriptor_intern_with_special( + keys: *const ArrayHeader, + logical_key_count: u32, + live_inline_slot_count: u32, + semantic_generation: u64, + object_kind: ShapeObjectKind, + hole_count: u32, + proto_id: u64, + summary: u8, + rep: u64, + infos: &[shapes_store::ConstFnSlotInfo], + requested: Option, +) -> Result { + shape_descriptor_intern_with_special_mode( + keys, + logical_key_count, + live_inline_slot_count, + semantic_generation, + object_kind, + hole_count, + proto_id, + summary, + rep, + infos, + requested, + false, + ) +} + +/// Only a body-aware static birth/seed may admit a requested ConstFn id. +/// The ordinary interner above keeps refusing it even if a caller passes a +/// requested id. All other validation and by-facts interning is shared. +#[allow(clippy::too_many_arguments)] +#[cfg_attr(feature = "shape-mint-diag", track_caller)] +fn shape_descriptor_intern_with_special_mode( + keys: *const ArrayHeader, + logical_key_count: u32, + live_inline_slot_count: u32, + semantic_generation: u64, + object_kind: ShapeObjectKind, + hole_count: u32, + proto_id: u64, + summary: u8, + rep: u64, + infos: &[shapes_store::ConstFnSlotInfo], + requested: Option, + static_constfn: bool, +) -> Result { + let Some(mask) = shapes_store::constfn_mask(infos) else { + return Err(ShapeDescriptorError::InvalidFacts); + }; + if !super::field_rep::is_valid_with_special(rep, mask) { + return Err(ShapeDescriptorError::InvalidFacts); + } + // Every SPECIAL lane in Step 5C names a ConstFn body. P5's optional + // NoPointer producer needs its own mask and remains gated by census. + // A requested ConstFn id is only legal through the body-aware static + // birth/seed entry points; generic dynamic callers fail closed. + if mask != super::field_rep::special_lane_slots(rep) + || (requested.is_some() && mask != 0 && !static_constfn) + { + return Err(ShapeDescriptorError::InvalidFacts); + } let keys_id = keys as usize as u64; if keys_id == 0 && logical_key_count != 0 { return Err(ShapeDescriptorError::InvalidFacts); @@ -1454,7 +1604,7 @@ pub(crate) fn shape_descriptor_intern_with_rep( } else { (0, "", 0) }; - let facts = shapes_store::facts_key_proto( + let facts = shapes_store::facts_key_proto_with_special( keys_id, logical_key_count, live_inline_slot_count, @@ -1464,6 +1614,7 @@ pub(crate) fn shape_descriptor_intern_with_rep( proto_id, summary, rep, + infos, ); let table = &crate::state::state().shapes; let mut inner = table.inner.borrow_mut(); @@ -1477,7 +1628,7 @@ pub(crate) fn shape_descriptor_intern_with_rep( let record = unsafe { *record }; // The bucket is a 64-bit fold: validate the facts on every hit. if record.has(RECORD_FLAG_FACTS_INDEXED) - && record.facts_match_proto( + && record.facts_match_proto_with_special( keys_id, logical_key_count, live_inline_slot_count, @@ -1487,6 +1638,7 @@ pub(crate) fn shape_descriptor_intern_with_rep( proto_id, summary, rep, + infos, ) { #[cfg(feature = "shape-mint-diag")] @@ -1561,7 +1713,7 @@ pub(crate) fn shape_descriptor_intern_with_rep( ) .with_proto_id(proto_id) .with_summary(summary) - .with_rep(rep); + .with_special_facts(rep, infos); let mut record = record; if adopted.is_some() { record.set(RECORD_FLAG_EXTERNAL_CARRIER, true); @@ -1695,8 +1847,8 @@ pub(crate) fn shape_id_for_keys_ensure(keys: *const ArrayHeader, key_count: u32) /// of it but `live_inline_slot_count`. #[inline] fn shape_descriptor_field_by_id(shape_id: u32, read: impl Fn(&ShapeRecord) -> T) -> Option { - let record = crate::state::state().shapes.slab().record_ptr(shape_id)?; - // SAFETY: `record_ptr` only returns a live slab record. + let record = ShapeSlab::agent_record_present(shape_id)?; + // SAFETY: `agent_record_present` only returns a live slab record. Some(read(unsafe { &*record })) } @@ -1708,23 +1860,46 @@ pub(crate) fn shape_live_inline_slot_count_by_id(shape_id: u32) -> Option { /// The descriptor named by `shape_id`, or `None` when the id names no /// descriptor in this agent. /// -/// #9706: a slab probe — range check, chunk index, record — with no hash, -/// no `RefCell` borrow and no invalidation epoch. The direct-mapped way cache +/// #9706: a slab probe — band select, page, chunk, record — with no hash, +/// no `RefCell` borrow and no invalidation epoch; and no runtime-state fetch +/// either: it reads this agent's published directory +/// ([`ShapeSlab::agent_record`]). The direct-mapped way cache /// that used to front the hash map is gone because the slab IS that cache: /// a hit was "mask, compare, deref" and a probe is "shift, index, deref". #[inline] pub(crate) fn shape_descriptor_by_id(shape_id: u32) -> Option { - crate::state::state().shapes.slab().lift(shape_id) + let record = ShapeSlab::agent_record_present(shape_id)?; + // SAFETY: `agent_record_present` only returns a live slab record. + Some(unsafe { (*record).lift(record) }) } /// The record named by `shape_id`, borrowed in place: the same slab probe as /// [`shape_descriptor_by_id`], without lifting a copy (#10362). #[inline] pub(crate) fn shape_record_by_id(shape_id: u32) -> Option { - let record = crate::state::state().shapes.slab().record_ptr(shape_id)?; + let record = ShapeSlab::agent_record_present(shape_id)?; std::ptr::NonNull::new(record).map(ShapeRecordRef) } +/// The field-representation word (`field_rep`) of `shape_id` in this agent, +/// deprecated lanes included. An id that names no record here reads the +/// absent record's word, 0 — `Any` in every lane, which is exactly the +/// answer for a receiver with no shape record — so there is no presence +/// test: the step 5 store check asks this on every checked store. +#[inline] +pub(crate) fn shape_rep_by_id(shape_id: u32) -> u64 { + let record = ShapeSlab::agent_record(shape_id); + // SAFETY: `agent_record` never returns null; `rep` is an 8-aligned u64 of + // a `#[repr(C)]` record (asserted 8-aligned), read the way + // `ShapeRecordRef::rep` reads it because a published record's word is + // rewritten atomically (`deprecate_rep_slot`). The shared empty record + // is only ever read. + unsafe { + (*std::ptr::addr_of!((*record).rep).cast::()) + .load(std::sync::atomic::Ordering::Relaxed) + } +} + /// Immutable ordinary-vs-class fact with a pointer-free, per-agent direct /// cache. The first observation remains the authoritative descriptor lookup_ways; /// subsequent observations avoid the hot ShapeId HashMap borrow. @@ -2016,6 +2191,46 @@ pub(crate) fn class_birth_shape_ensure( ) } +/// Body-aware final-shape mint. This mints facts only; callers must never +/// stamp its result on an allocation with uninitialized closure slots. +/// A seed and post-construction finalizer enter with identical facts. +#[allow(clippy::too_many_arguments)] +pub(crate) fn final_shape_ensure_constfn( + keys: *const ArrayHeader, + key_count: u32, + live: u32, + class_id: u32, + rep: u64, + infos: &[shapes_store::ConstFnSlotInfo], + requested: Option, +) -> Result { + let key_lanes = if key_count >= super::field_rep::REP_SLOTS { + u64::MAX + } else { + super::field_rep::lanes_below(key_count) + }; + if infos.is_empty() || rep & !key_lanes != 0 || keys.is_null() || key_count == 0 { + return Err(ShapeDescriptorError::InvalidFacts); + } + // As in `shape_descriptor_ensure_with_rep`, derive the summary from the + // canonical keys rather than trusting a caller-supplied summary. + let summary = unsafe { crate::object::key_attrs::keys_summary_checked(keys, key_count) }; + shape_descriptor_intern_with_special_mode( + keys, + key_count, + live.max(key_count), + 0, + ShapeObjectKind::Ordinary, + 0, + class_proto_id(class_id), + summary, + rep, + infos, + requested, + true, + ) +} + /// #10123: the inline slot a PLAIN ordinary shape assigns to `key`, or `-1`. /// /// The element-shape loop clone's shape-keyed arm asks this once per tracked @@ -2271,7 +2486,13 @@ static KEEP_JS_REGION_GUARD_PRIME: unsafe extern "C" fn( /// A key in `boxed_mask` is one a bare store may write a value the compiler /// did not prove a canonical double (charter step 5): the bare store runs no /// field-representation check, so its slot must be an `Any` lane of the -/// shape. A proven canonical double is a valid value of every lane. +/// shape. A proven canonical double is valid for `Any` and `F64` lanes. +/// Any stored SPECIAL lane is refused: a ConstFn body change requires the +/// checked slot funnel even when the new value is a canonical Number. +/// A read-only numeric region may also use `OrdinaryUnmarked`: the missing +/// birth mark withdraws store permission, not the own-data slot layout. +/// Receivers with virtual read semantics remain refused by their prototype +/// classification, and every covered key must be requested as inline F64. #[no_mangle] #[allow(clippy::too_many_arguments)] pub extern "C" fn js_region_loop_pack( @@ -2285,8 +2506,15 @@ pub extern "C" fn js_region_loop_pack( stored_mask: u32, boxed_mask: u32, ) -> u64 { - region_loop_pack(shape_id, n, [k0, k1, k2, k3, k4], stored_mask, boxed_mask) - .unwrap_or(REGION_GUARD_WORD_EMPTY) + region_loop_pack( + shape_id, + n, + [k0, k1, k2, k3, k4], + stored_mask, + boxed_mask, + 0, + ) + .unwrap_or(REGION_GUARD_WORD_EMPTY) } /// Why [`js_region_loop_pack`] refused a shape — the route census's refusal @@ -2297,7 +2525,7 @@ enum RegionRefusal { Band, /// A key is an accessor or not writable/enumerable/configurable data. Summary, - /// Not `Ordinary`, a non-zero semantic generation, or tombstones. + /// Ineligible receiver/read kind, non-zero semantic generation, or tombstones. Kind, /// A key is not in the shape at all (inherited, or absent). Absent, @@ -2310,6 +2538,9 @@ enum RegionRefusal { Range, /// A key a bare store may write a non-double into is not an `Any` lane. F64Stored, + /// A requested Number read is not on an identity F64 lane, or a bare + /// store targets a SPECIAL lane that requires the checked slot funnel. + Rep, } fn region_loop_pack( @@ -2318,6 +2549,7 @@ fn region_loop_pack( keys: [u64; 5], stored_mask: u32, boxed_mask: u32, + r_mask: u32, ) -> Result { use RegionRefusal::*; if !is_site_matchable_shape_id(shape_id) || n == 0 || n > REGION_GUARD_MAX_KEYS { @@ -2331,7 +2563,12 @@ fn region_loop_pack( let Some(descriptor) = shape_descriptor_by_id(shape_id) else { return Err(Band); }; - if descriptor.object_kind != ShapeObjectKind::Ordinary + let unmarked_numeric_read = descriptor.object_kind == ShapeObjectKind::OrdinaryUnmarked + && stored_mask == 0 + && boxed_mask == 0 + && r_mask == (1 << n) - 1; + if (descriptor.object_kind != ShapeObjectKind::Ordinary && !unmarked_numeric_read) + || descriptor.proto_id == PROTO_ID_PER_OBJECT || descriptor.semantic_generation != 0 || descriptor.hole_count != 0 { @@ -2364,6 +2601,25 @@ fn region_loop_pack( }; let mut word = u64::from(id); for (i, &(spilled, n_at)) in at.iter().enumerate().take(n as usize) { + // A canonical Number cannot preserve a ConstFn body identity. Every + // SPECIAL write must use the checked slot funnel before storing; + // numeric writes to other lanes and read-only SPECIAL keys are safe. + if !spilled + && stored_mask & (1 << i) != 0 + && n_at < super::field_rep::REP_SLOTS as usize + && super::field_rep::slot_rep(descriptor.rep, n_at as u32) + == super::field_rep::REP_SPECIAL + { + return Err(Rep); + } + if r_mask & (1 << i) != 0 + && (spilled + || n_at >= super::field_rep::REP_SLOTS as usize + || super::field_rep::slot_rep(descriptor.rep, n_at as u32) + != super::field_rep::REP_F64) + { + return Err(Rep); + } if !spilled && boxed_mask & (1 << i) != 0 && (n_at as u32) < super::field_rep::REP_SLOTS @@ -2409,8 +2665,16 @@ pub unsafe extern "C" fn js_region_loop_prime( last: u32, stored_mask: u32, boxed_mask: u32, + r_mask: u32, ) -> u64 { - let verdict = region_loop_pack(shape_id, n, [k0, k1, k2, k3, k4], stored_mask, boxed_mask); + let verdict = region_loop_pack( + shape_id, + n, + [k0, k1, k2, k3, k4], + stored_mask, + boxed_mask, + r_mask, + ); region_loop_prime_census(verdict); let packed = verdict.unwrap_or(REGION_GUARD_WORD_EMPTY); if word.is_null() { @@ -2437,7 +2701,7 @@ fn region_loop_prime_census(verdict: Result) { use crate::hot_diag::{ recv_route_note_runtime, RT_ROUTE_RLOOP_PRIME_OK, RT_ROUTE_RLOOP_REFUSE_ABSENT, RT_ROUTE_RLOOP_REFUSE_BAND, RT_ROUTE_RLOOP_REFUSE_F64_STORED, RT_ROUTE_RLOOP_REFUSE_KIND, - RT_ROUTE_RLOOP_REFUSE_RANGE, RT_ROUTE_RLOOP_REFUSE_SPILL_STORED, + RT_ROUTE_RLOOP_REFUSE_RANGE, RT_ROUTE_RLOOP_REFUSE_REP, RT_ROUTE_RLOOP_REFUSE_SPILL_STORED, RT_ROUTE_RLOOP_REFUSE_SPILL_UNSERVABLE, RT_ROUTE_RLOOP_REFUSE_SUMMARY, }; let route = match verdict { @@ -2450,6 +2714,7 @@ fn region_loop_prime_census(verdict: Result) { Err(RegionRefusal::SpillUnservable) => RT_ROUTE_RLOOP_REFUSE_SPILL_UNSERVABLE, Err(RegionRefusal::Range) => RT_ROUTE_RLOOP_REFUSE_RANGE, Err(RegionRefusal::F64Stored) => RT_ROUTE_RLOOP_REFUSE_F64_STORED, + Err(RegionRefusal::Rep) => RT_ROUTE_RLOOP_REFUSE_REP, }; recv_route_note_runtime(route); } @@ -2470,6 +2735,7 @@ static KEEP_JS_REGION_LOOP_PRIME: unsafe extern "C" fn( u32, u32, u32, + u32, ) -> u64 = js_region_loop_prime; // --------------------------------------------------------------------------- @@ -2692,7 +2958,7 @@ pub(crate) unsafe fn stamp_object_shape( // lineage's field representation carries (normalized). Any other edge // publishes all-`Any`, which is always a valid claim. let rep = if lineage.keys == keys as u64 && lineage.logical_key_count == key_count { - super::field_rep::normalized(lineage.rep) + super::field_rep::normalized_without_special(lineage.rep) } else { super::field_rep::REP_ANY }; @@ -2780,7 +3046,7 @@ pub(crate) unsafe fn birth_stamp_object_shape( let supplied_id_is_local = (descriptor_matches_object(runtime_shape_id, obj, live_inline_slot_count) && shape_descriptor_field_by_id(runtime_shape_id, |d| { - super::field_rep::identity(d.rep) == rep + super::field_rep::identity_with_special(d.rep) == rep }) == Some(true)) || shapes_slot_list::install_external_shape_id( runtime_shape_id, @@ -2938,7 +3204,7 @@ pub(crate) unsafe fn publish_object_live_slot_count_rep( current.keys_view(), live_inline_slot_count, rep.unwrap_or_else(|| { - super::field_rep::normalized(current.rep) + super::field_rep::normalized_without_special(current.rep) & super::field_rep::lanes_below(live_inline_slot_count) }), ), @@ -3452,6 +3718,20 @@ unsafe fn prototype_serial(bits: u64) -> u64 { /// # Safety /// `obj` is a live `ObjectHeader`. pub(crate) unsafe fn object_proto_id(obj: *const crate::object::ObjectHeader) -> u64 { + // A namespace's vtable/override registry can answer before its physical + // own slots. Project that read classification into the shape, as for + // process.env and arguments below; an own-slot region cannot admit it. + if (*obj).class_id == crate::object::NATIVE_MODULE_CLASS_ID { + return PROTO_ID_PER_OBJECT; + } + let meta = (*obj).meta; + // Read semantics take precedence over every prototype link, including + // null. Changing an exotic receiver's prototype cannot turn its virtual + // reads into physical own-slot reads. + if !meta.is_null() && (*meta).flags & crate::object::OBJECT_META_FLAG_EXOTIC_READ_RECEIVER != 0 + { + return PROTO_ID_PER_OBJECT; + } if let Some(header) = crate::value::addr_class::try_read_gc_header(obj as usize) { if header._reserved & crate::gc::OBJ_FLAG_NULL_PROTO != 0 { return PROTO_ID_NULL; @@ -3459,14 +3739,6 @@ pub(crate) unsafe fn object_proto_id(obj: *const crate::object::ObjectHeader) -> } let class_id = (*obj).class_id; let class = vtable_class(class_id); - let meta = (*obj).meta; - // An exotic read receiver (`process.env`, `arguments`) is answered by no - // shape: its identity is its own, so every lineage it mints keeps it and - // no shape-keyed memo admits it (`proto_validity::mark_exotic_read_receiver`). - if !meta.is_null() && (*meta).flags & crate::object::OBJECT_META_FLAG_EXOTIC_READ_RECEIVER != 0 - { - return PROTO_ID_PER_OBJECT; - } if !meta.is_null() && (*meta).prototype != 0 { let bits = (*meta).prototype; if bits == crate::value::TAG_NULL { @@ -3606,6 +3878,9 @@ fn remove_descriptor_indexed_under(inner: &mut ShapeTableInner, id: u32, indexed inner.facts_remove(record.facts_key_with_keys(indexed), id); } inner.family_remove(indexed, id); + // Unlike the two rekey paths, retirement does not transfer this record. + // SAFETY: slab removal returned the unique owner of the extension. + unsafe { record.release_extras() }; } /// Exact-facts test for a candidate id against the receiver's authoritative @@ -4446,6 +4721,10 @@ pub(crate) use shapes_test_support::*; #[path = "shapes_tests.rs"] mod shapes_tests; +#[cfg(test)] +#[path = "region_numeric_read_tests.rs"] +mod region_numeric_read_tests; + /// #9612: release the capacity that pruning left behind. /// /// hashbrown never shrinks on `remove`/`retain`, so the shape tables keep the diff --git a/crates/perry-runtime/src/object/shapes_slot_list.rs b/crates/perry-runtime/src/object/shapes_slot_list.rs index 636d80bd67..1acea176bf 100644 --- a/crates/perry-runtime/src/object/shapes_slot_list.rs +++ b/crates/perry-runtime/src/object/shapes_slot_list.rs @@ -485,6 +485,11 @@ pub(crate) unsafe fn try_update_stable_tombstone_shape( // SAFETY: live slab record, single-threaded agent; read then written // through the same pointer with nothing else holding a reference. let current = unsafe { *record }; + // A method slot can become TAG_HOLE at this mutation. Keeping its id + // would let a ConstFn site call the old body after `delete`. + if current.special_constfn_mask() != 0 { + return None; + } // A stable id may never silently retarget its collector-owned keys edge. // Array growth that reallocates falls back to a fresh descriptor. if current.keys != keys as u64 || !current.object_kind().is_ordinary_layout() { @@ -568,6 +573,9 @@ pub(crate) unsafe fn try_update_stable_tombstone_shape_cached( return None; } let record = &mut *live; + if record.special_constfn_mask() != 0 { + return None; + } if record.keys != current.keys || record.has(RECORD_FLAG_FACTS_INDEXED) || !record.object_kind().is_ordinary_layout() @@ -623,6 +631,9 @@ pub(crate) unsafe fn rekey_stable_tombstone_shape_after_squeeze( } // SAFETY: live slab record, read immediately. let live = unsafe { *live_ptr }; + if live.special_constfn_mask() != 0 { + return None; + } if live.keys != current.keys || live.has(RECORD_FLAG_FACTS_INDEXED) || !live.object_kind().is_ordinary_layout() @@ -1012,7 +1023,10 @@ fn rekey_predecessor_for_delete( }; // SAFETY: live slab record, single-threaded agent, read immediately. let live = unsafe { *live_ptr }; - if live.keys != keys || live.has(RECORD_FLAG_CACHE_CARRIER | RECORD_FLAG_EXTERNAL_CARRIER) { + if live.keys != keys + || live.special_constfn_mask() != 0 + || live.has(RECORD_FLAG_CACHE_CARRIER | RECORD_FLAG_EXTERNAL_CARRIER) + { return 0; } let Ok(id) = super::alloc_shape_id() else { @@ -1131,9 +1145,43 @@ pub(super) fn install_external_shape_id( object_kind: super::ShapeObjectKind, rep: u64, ) -> bool { + install_external_shape_id_with_constfn( + id, + keys, + logical_key_count, + live_inline_slot_count, + proto_id, + object_kind, + rep, + &[], + 0, + ) +} + +/// Body-aware worker replay. Extras belong to the receiving agent's record; +/// source closure addresses and source extension storage never cross. +#[allow(clippy::too_many_arguments)] +pub(super) fn install_external_shape_id_with_constfn( + id: u32, + keys: *const super::ArrayHeader, + logical_key_count: u32, + live_inline_slot_count: u32, + proto_id: u64, + object_kind: super::ShapeObjectKind, + rep: u64, + infos: &[super::shapes_store::ConstFnSlotInfo], + to_any: u32, +) -> bool { + let Some(mask) = super::shapes_store::constfn_mask(infos) else { + return false; + }; if !super::is_shape_id(id) || (keys.is_null() && logical_key_count != 0) - || !crate::object::field_rep::is_valid(rep) + || mask != crate::object::field_rep::special_lane_slots(rep) + || to_any & !mask != 0 + || infos + .iter() + .any(|i| i.slot as u32 >= logical_key_count || i.slot as u32 >= live_inline_slot_count) { return false; } @@ -1141,24 +1189,11 @@ pub(super) fn install_external_shape_id( let summary = unsafe { crate::object::key_attrs::keys_summary_checked(keys, logical_key_count) }; let keys = keys as usize as u64; - let mut record = ShapeRecord::new( - keys, - logical_key_count, - live_inline_slot_count, - 0, - object_kind, - 0, - ) - .with_proto_id(proto_id) - .with_summary(summary) - .with_rep(rep); - record.set(super::shapes_store::RECORD_FLAG_EXTERNAL_CARRIER, true); let table = &crate::state::state().shapes; let mut inner = table.inner.borrow_mut(); if let Some(existing) = table.slab().record_ptr(id) { - // SAFETY: live slab record, single-threaded agent. - let matches = unsafe { &*existing }.facts_match_proto( - keys, + let matches = unsafe { &*existing }.facts_match_proto_with_special( + keys as usize as u64, logical_key_count, live_inline_slot_count, 0, @@ -1167,13 +1202,35 @@ pub(super) fn install_external_shape_id( proto_id, summary, rep, + infos, ); if matches { - // SAFETY: same record and agent discipline as above. unsafe { (*existing).set(super::shapes_store::RECORD_FLAG_EXTERNAL_CARRIER, true) }; + for slot in 0..32 { + if to_any & (1 << slot) != 0 { + unsafe { &*existing }.deprecate_special_to_any(slot); + } + } } return matches; } + let mut record = ShapeRecord::new( + keys, + logical_key_count, + live_inline_slot_count, + 0, + object_kind, + 0, + ) + .with_proto_id(proto_id) + .with_summary(summary) + .with_special_facts(rep, infos); + record.set(super::shapes_store::RECORD_FLAG_EXTERNAL_CARRIER, true); + for slot in 0..32 { + if to_any & (1 << slot) != 0 { + record.deprecate_special_to_any(slot); + } + } // A worker can have minted an equivalent local descriptor before module // initialization installs the process-global codegen id. Keep both id // descriptors valid for already-published objects and make the external diff --git a/crates/perry-runtime/src/object/shapes_store.rs b/crates/perry-runtime/src/object/shapes_store.rs index de4ca0296e..8377e595e7 100644 --- a/crates/perry-runtime/src/object/shapes_store.rs +++ b/crates/perry-runtime/src/object/shapes_store.rs @@ -37,6 +37,42 @@ use super::{ }; use std::cell::UnsafeCell; +/// Static body identity of a ConstFn slot. This is image metadata, never a +/// closure pointer or a GC edge. Entries are sorted by slot in a shape. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct ConstFnSlotInfo { + pub slot: u8, + pub info: u64, +} + +/// Optional, record-owned extension. It has no keyed lookup: a method-site +/// hit reads only the receiver's shape and current closure slot. The learned +/// masks are not shape identity, so record copies/rekeys transfer this box. +#[derive(Debug)] +pub(super) struct ShapeExtras { + pub(super) constfn_infos: Box<[ConstFnSlotInfo]>, + pub(super) to_nopointer: std::sync::atomic::AtomicU32, + pub(super) to_any: std::sync::atomic::AtomicU32, +} + +/// The exact mask of a sorted body list, or `None` for a duplicate, absent +/// body, or slot outside the 32 inline representation lanes. +pub(crate) fn constfn_mask(infos: &[ConstFnSlotInfo]) -> Option { + let mut mask = 0u32; + let mut previous = None; + for entry in infos { + if entry.slot >= crate::object::field_rep::REP_SLOTS as u8 + || entry.info == 0 + || previous.is_some_and(|slot| entry.slot <= slot) + { + return None; + } + mask |= 1 << entry.slot; + previous = Some(entry.slot); + } + Some(mask) +} + pub(super) const RECORD_FLAG_PRESENT: u8 = 1 << 0; pub(super) const RECORD_FLAG_FACTS_INDEXED: u8 = 1 << 1; pub(super) const RECORD_FLAG_OLD_CARRIER: u8 = 1 << 2; @@ -95,14 +131,22 @@ pub(crate) struct ShapeRecord { /// ONE field so the megamorphic read confirm (`js_object_read_confirm`) /// answers "is the guess a position of this shape" with one compare — /// `guess < position_bound` — instead of a flag test and a `min`. - /// Offset 40; `rep` follows at 48 (4 bytes of padding between), so the - /// record is 56 bytes. + /// Offset 40; the special-lane mask uses the former padding at 44, and + /// `rep` follows at 48. The owned extension pointer at 56 makes the + /// record 64 bytes; ordinary shapes keep that pointer null. position_bound: u32, + /// For a `REP_SPECIAL` lane, one means ConstFn and zero reserves the + /// NoPointer interpretation for P5. This uses the old padding at 44; + /// with no special lanes it is zero and old shape identity is unchanged. + special_constfn_mask: u32, /// Charter step 5: the per-slot field representation, two bits per inline /// slot 0..32 (`field_rep`). An identity fact under /// [`field_rep::identity`](crate::object::field_rep::identity), folded into the /// facts key only when nonzero. pub(super) rep: u64, + /// Null for an ordinary shape. Otherwise a record-owned [`ShapeExtras`] + /// address, stored at fixed width so the slab layout agrees on ILP32/LP64. + extras: u64, } const RECORD_KIND_SHIFT: u32 = 8; @@ -154,10 +198,12 @@ const _: () = assert!( super::shapes_birth_width::TRACKING_BIRTHS <= RECORD_BIRTHS_MASK >> RECORD_BIRTHS_SHIFT ); -const _: () = assert!(std::mem::size_of::() == 56); +const _: () = assert!(std::mem::size_of::() == 64); const _: () = assert!(std::mem::align_of::() == 8); const _: () = assert!(std::mem::offset_of!(ShapeRecord, position_bound) == 40); +const _: () = assert!(std::mem::offset_of!(ShapeRecord, special_constfn_mask) == 44); const _: () = assert!(std::mem::offset_of!(ShapeRecord, rep) == 48); +const _: () = assert!(std::mem::offset_of!(ShapeRecord, extras) == 56); impl ShapeRecord { const EMPTY: ShapeRecord = ShapeRecord { @@ -169,7 +215,9 @@ impl ShapeRecord { hole_count: 0, flags_and_kind: 0, position_bound: 0, + special_constfn_mask: 0, rep: 0, + extras: 0, }; #[inline] @@ -288,7 +336,9 @@ impl ShapeRecord { hole_count, flags_and_kind: u32::from(flags) | kind_bits, position_bound: 0, + special_constfn_mask: 0, rep: 0, + extras: 0, }; record.refresh_positional(); record @@ -382,6 +432,7 @@ impl ShapeRecord { } /// The same record carrying field representation `rep` (`field_rep`). + #[cfg(test)] #[inline] pub(super) fn with_rep(mut self, rep: u64) -> ShapeRecord { debug_assert!(crate::object::field_rep::is_valid(rep), "reserved rep lane"); @@ -389,6 +440,88 @@ impl ShapeRecord { self } + /// Install a SPECIAL rep with its exact ConstFn body identities. A `11` + /// lane absent from `infos` is reserved for optional NoPointer. The old + /// `with_rep` keeps rejecting `11`, so no legacy mint silently omits the + /// identity fact. Called only after the interner misses. + #[inline] + pub(super) fn with_special_facts(mut self, rep: u64, infos: &[ConstFnSlotInfo]) -> ShapeRecord { + assert_eq!(self.extras, 0, "special facts replace a fresh record only"); + let mask = constfn_mask(infos).expect("invalid ConstFn slot list"); + assert!(crate::object::field_rep::is_valid_with_special(rep, mask)); + assert_eq!(mask & !crate::object::field_rep::special_lane_slots(rep), 0); + self.rep = rep; + self.special_constfn_mask = mask; + if !infos.is_empty() { + let extras = Box::new(ShapeExtras { + constfn_infos: infos.into(), + to_nopointer: std::sync::atomic::AtomicU32::new(0), + to_any: std::sync::atomic::AtomicU32::new(0), + }); + self.extras = Box::into_raw(extras) as usize as u64; + } + self + } + + /// ConstFn slots; a `REP_SPECIAL` slot outside this mask is the reserved + /// NoPointer representation. GC must visit ConstFn slots. + #[inline] + pub(crate) fn special_constfn_mask(&self) -> u32 { + self.special_constfn_mask + } + + #[inline] + pub(crate) fn constfn_infos(&self) -> &[ConstFnSlotInfo] { + if self.extras == 0 { + &[] + } else { + // SAFETY: the live slab record owns this allocation; copies of + // the record borrow it and a rekey transfers its ownership. + unsafe { &(*(self.extras as usize as *const ShapeExtras)).constfn_infos } + } + } + + #[inline] + pub(crate) fn deprecation_targets(&self) -> (u32, u32) { + if self.extras == 0 { + (0, 0) + } else { + // SAFETY: the extension is owned by this live record. + let extras = unsafe { &*(self.extras as usize as *const ShapeExtras) }; + ( + extras + .to_nopointer + .load(std::sync::atomic::Ordering::Acquire), + extras.to_any.load(std::sync::atomic::Ordering::Acquire), + ) + } + } + + /// A ConstFn lineage learned a different function or a nonfunction. It + /// keeps its old body invariant for existing carriers, but migrates them + /// to an Any successor on miss. NoPointer can use the same target later. + pub(crate) fn deprecate_special_to_any(&self, slot: u32) -> bool { + assert!(slot < crate::object::field_rep::REP_SLOTS); + let bit = 1u32 << slot; + assert_ne!(self.special_constfn_mask & bit, 0); + assert_ne!(self.extras, 0); + // SAFETY: the record owns the extension for its entire live span. + let extras = unsafe { &*(self.extras as usize as *const ShapeExtras) }; + extras + .to_any + .fetch_or(bit, std::sync::atomic::Ordering::AcqRel) + & bit + == 0 + } + + /// Called only when a record is retired, not when it is copied or rekeyed. + /// The returned slab record owns the pointer until this call consumes it. + pub(super) unsafe fn release_extras(self) { + if self.extras != 0 { + drop(Box::from_raw(self.extras as usize as *mut ShapeExtras)); + } + } + /// The same record for a receiver whose [[Prototype]] identity is /// `proto_id` (see [`ShapeRecord::proto_id`]). #[inline] @@ -413,10 +546,44 @@ impl ShapeRecord { summary: u8, rep: u64, ) -> bool { + self.facts_match_proto_with_special( + keys, + logical_key_count, + live_inline_slot_count, + semantic_generation, + object_kind, + hole_count, + proto_id, + summary, + rep, + &[], + ) + } + + #[allow(clippy::too_many_arguments)] + #[inline] + pub(super) fn facts_match_proto_with_special( + &self, + keys: u64, + logical_key_count: u32, + live_inline_slot_count: u32, + semantic_generation: u64, + object_kind: ShapeObjectKind, + hole_count: u32, + proto_id: u64, + summary: u8, + rep: u64, + infos: &[ConstFnSlotInfo], + ) -> bool { + let Some(mask) = constfn_mask(infos) else { + return false; + }; self.proto_id == proto_id && self.summary() == summary - && crate::object::field_rep::identity(self.rep) - == crate::object::field_rep::identity(rep) + && crate::object::field_rep::identity_with_special(self.rep) + == crate::object::field_rep::identity_with_special(rep) + && self.special_constfn_mask == mask + && self.constfn_infos() == infos && self.facts_match( keys, logical_key_count, @@ -455,7 +622,7 @@ impl ShapeRecord { /// re-indexes it. #[inline] pub(super) fn facts_key_with_keys(&self, keys: u64) -> u64 { - facts_key_proto( + facts_key_proto_with_special( keys, self.logical_key_count, self.live_inline_slot_count, @@ -465,6 +632,7 @@ impl ShapeRecord { self.proto_id, self.summary(), self.rep, + self.constfn_infos(), ) } @@ -486,6 +654,8 @@ impl ShapeRecord { hole_count: self.hole_count, summary: self.summary(), rep: self.rep, + special_constfn_mask: self.special_constfn_mask, + extras: self.extras, } } } @@ -534,6 +704,37 @@ pub(super) fn facts_key_proto( proto_id: u64, summary: u8, rep: u64, +) -> u64 { + facts_key_proto_with_special( + keys, + logical_key_count, + live_inline_slot_count, + semantic_generation, + object_kind, + hole_count, + proto_id, + summary, + rep, + &[], + ) +} + +/// Extended exact-facts hash. Old shapes take the wrapper above and get the +/// exact old fold; ConstFn adds a domain-separated ordered body list. Address +/// values are process-local identities, never serialized as static seed keys. +#[allow(clippy::too_many_arguments)] +#[inline] +pub(super) fn facts_key_proto_with_special( + keys: u64, + logical_key_count: u32, + live_inline_slot_count: u32, + semantic_generation: u64, + object_kind: ShapeObjectKind, + hole_count: u32, + proto_id: u64, + summary: u8, + rep: u64, + infos: &[ConstFnSlotInfo], ) -> u64 { const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; @@ -557,11 +758,18 @@ pub(super) fn facts_key_proto( // The same rule for the field representation: an all-`Any` shape keeps // the key it had before the word existed. The deprecated state is not // identity, so it is masked here as it is in `facts_match_proto`. - let rep = crate::object::field_rep::identity(rep); + let rep = crate::object::field_rep::identity_with_special(rep); if rep != 0 { h = fold(h, 0x6_0000); h = fold(h, rep); } + if !infos.is_empty() { + h = fold(h, 0x7_4346_4e); + for entry in infos { + h = fold(h, u64::from(entry.slot)); + h = fold(h, entry.info); + } + } // Final avalanche: FNV keeps most of its entropy in the high bits and // hashbrown's probe sequence starts from the LOW bits. h ^ (h >> 32) @@ -599,11 +807,12 @@ type PageSlots = [Slot; PAGE_LEN]; /// A directory or page entry: an allocation this slab owns, or the SHARED /// all-empty one of its level ([`EMPTY_CHUNK`], [`EMPTY_PAGE`]) — never null. /// An absent run therefore reads exactly like a present run of absent -/// records (`ShapeRecord::EMPTY`: not present, position bound 0), so a -/// reader walks page → chunk → record with no null test at either level -/// (the megamorphic read confirm, `ordinary_record_in`). Nothing is ever -/// written through a shared empty: every writer asks [`Slot::is_shared`] -/// first and allocates. The slab frees what it owns ([`ShapeSlab::free_dir`]). +/// records (`ShapeRecord::EMPTY`: not present, position bound 0, every lane +/// `Any`), so a reader walks page → chunk → record with no null test at +/// either level ([`ShapeSlab::agent_record`], [`ShapeSlab::ordinary_record_in`]). +/// Nothing is ever written +/// through a shared empty: every writer asks [`Slot::is_shared`] first and +/// allocates. The slab frees what it owns ([`ShapeSlab::free_dir`]). #[repr(transparent)] struct Slot(std::ptr::NonNull); @@ -628,6 +837,13 @@ static EMPTY_PAGE: SharedEmpty = SharedEmpty( std::ptr::NonNull::new_unchecked(std::ptr::addr_of!(EMPTY_CHUNK.0) as *mut ChunkCells) }); PAGE_LEN], ); +/// The directory entry a lookup reads for a page index past its band's +/// directory: the shared empty page. Lets [`ShapeSlab::agent_record`] turn +/// the bounds test into a select instead of an early return. +static EMPTY_PAGE_ENTRY: SharedEmpty = SharedEmpty(Slot( + // SAFETY: the address of a static is never null. + unsafe { std::ptr::NonNull::new_unchecked(std::ptr::addr_of!(EMPTY_PAGE.0) as *mut PageSlots) }, +)); trait Level: Sized + 'static { fn shared() -> std::ptr::NonNull; @@ -704,53 +920,93 @@ impl Slot { type Page = Slot; -/// The ordinary directory mirror's type: `(page pointers, page count)`. -#[repr(C)] -pub(crate) struct OrdinaryDir { +/// Band-relative starts: the dictionary band, the exotic band, and the end of +/// the ShapeId range, each relative to `SHAPE_ID_BASE`. +const DICT_REL: u32 = DICTIONARY_SHAPE_ID_BASE - SHAPE_ID_BASE; +const EXOTIC_REL: u32 = EXOTIC_SHAPE_ID_BASE - SHAPE_ID_BASE; +const END_REL: u32 = super::SHAPE_ID_END - SHAPE_ID_BASE; + +/// The band boundaries are multiples of `1 << BAND_SHIFT` (relative to +/// `SHAPE_ID_BASE`): the ordinary band is segments 0-5, the dictionary band +/// segment 6, the exotic band segment 7, and segment 8 on is not a ShapeId. +const BAND_SHIFT: u32 = 27; +const _: () = assert!(DICT_REL == 6 << BAND_SHIFT); +const _: () = assert!(EXOTIC_REL == 7 << BAND_SHIFT); +const _: () = assert!(END_REL == 8 << BAND_SHIFT); +/// Each band's first id, relative to `SHAPE_ID_BASE`, by band. +const BAND_BASE_REL: [u32; 4] = [0, DICT_REL, EXOTIC_REL, END_REL]; + +/// The band of `rel = id - SHAPE_ID_BASE` (wrapping): 0 ordinary, 1 +/// dictionary, 2 exotic receivers, and 3 "not a ShapeId" (an id below +/// `SHAPE_ID_BASE` wraps past `END_REL`), whose directory is always empty. +/// A shift, a saturating subtract and a min: no branch, because every +/// lookup starts here. +#[inline(always)] +fn band_of(rel: u32) -> usize { + (rel >> BAND_SHIFT).saturating_sub(5).min(3) as usize +} + +/// Where `id` lives: `(band, index within that band's directory)`. Total: +/// band 3's index is meaningless and its directory empty. +#[inline(always)] +fn locate(id: u32) -> (usize, usize) { + let rel = id.wrapping_sub(SHAPE_ID_BASE); + let band = band_of(rel); + (band, rel.wrapping_sub(BAND_BASE_REL[band]) as usize) +} + +/// One band's directory as published for [`ShapeSlab::agent_record`]: the +/// element pointer and length of that band's `Vec`, and the band's +/// first id relative to `SHAPE_ID_BASE` (a constant, kept beside the pair so +/// the lookup reads it from the same line). 32 bytes, so the band indexes +/// the directory with a shift. The ordinary band's entry is also what the +/// megamorphic read confirm reads through its address +/// ([`ShapeSlab::ordinary_dir_addr`]). +#[repr(C, align(32))] +pub(crate) struct BandDir { pages: std::cell::Cell<*const Page>, len: std::cell::Cell, + base_rel: u32, } -impl OrdinaryDir { - const fn empty() -> Self { - OrdinaryDir { +impl BandDir { + const fn empty(band: usize) -> BandDir { + BandDir { pages: std::cell::Cell::new(std::ptr::null()), len: std::cell::Cell::new(0), + base_rel: BAND_BASE_REL[band], } } - #[inline(always)] - fn get(&self) -> (*const Page, usize) { - (self.pages.get(), self.len.get()) - } - #[inline] - fn set(&self, (pages, len): (*const Page, usize)) { - self.pages.set(pages); - self.len.set(len); - } } -/// A directory of no pages, never written: the value of the agent's -/// shape-directory pointer slot until the agent publishes its own mirror +/// THIS agent's shape directory: the page pointers and page count of each +/// band of its slab (the `RuntimeState` shape table's), republished by the +/// slab after every change to a band's page vector and reset to all-empty +/// when the slab is dropped. Band 3 ("not a ShapeId") is never written. +/// +/// It is what makes the by-id lookup free of the runtime state: a const +/// `#[thread_local]` with no destructor is valid from the thread's first +/// instruction, so [`ShapeSlab::agent_record`] reads an always-valid +/// directory with one thread-pointer-relative load — no `state()` fetch, no +/// lazy-init branch. An agent that has not built its runtime state yet has +/// no shapes, and its all-empty directory says exactly that. Per agent by +/// construction: every agent is its own thread with its own directory. +#[thread_local] +static AGENT_SHAPE_DIR: [BandDir; 4] = [ + BandDir::empty(0), + BandDir::empty(1), + BandDir::empty(2), + BandDir::empty(3), +]; + +/// An ordinary-band directory of no pages, never written: the value of the +/// agent's shape-directory pointer slot until the agent publishes its own /// (`agent_ptrs::PERRY_AGENT_PTRS`), and what a `length` read site passes -/// (`perry-codegen` `generic_dispatch.rs`). Every id indexes past its length, -/// so a reader never needs a null test for the directory itself. +/// (`perry-codegen` `generic_dispatch.rs`). Every id indexes past its length +/// and reads the shared empty record, so a reader never needs a null test for +/// the directory itself. #[no_mangle] -pub static PERRY_EMPTY_SHAPE_DIR: SharedEmpty = SharedEmpty(OrdinaryDir::empty()); - -/// This thread's ordinary page directory as `(page pointers, page count)`: -/// `ShapeSlab::pages`' element pointer and length, republished after every -/// change to `pages` (`ShapeSlab::publish_dir`) and cleared before the slab -/// is dropped. The megamorphic read's slot-guess confirm -/// ([`ShapeSlab::ordinary_record_in`]) reads it through its ADDRESS, which -/// emitted code passes from the agent's pointer block, instead of resolving -/// the runtime state and walking `record_ptr`: the confirm itself then -/// touches no thread-local (a runtime thread-local access is a -/// `__tls_get_addr` call on ELF and a TLV thunk call on Darwin, which would -/// give the stub a frame). `#[thread_local]` (const, no destructor) rather -/// than `thread_local!`: the address is stable for the thread's life, and a -/// late read during thread teardown sees the cleared pair. -#[thread_local] -static ORDINARY_DIR: OrdinaryDir = OrdinaryDir::empty(); +pub static PERRY_EMPTY_SHAPE_DIR: SharedEmpty = SharedEmpty(BandDir::empty(0)); /// The by-id descriptor store. See the module docs. /// Two page directories: ordinary ShapeIds index from `SHAPE_ID_BASE`, and the @@ -760,17 +1016,6 @@ static ORDINARY_DIR: OrdinaryDir = OrdinaryDir::empty(); /// that one ~196 KB allocation moved the GC arena's pages relative to the /// page-class table window and cost +2.3% instructions (1.65 M vs 0.20 M /// registered-page misses in `classify_heap_generation`). -impl Drop for ShapeSlab { - fn drop(&mut self) { - if ORDINARY_DIR.get().0 == self.pages.as_ptr() { - ORDINARY_DIR.set((std::ptr::null(), 0)); - } - for band in [0u8, 1, 2] { - Self::free_dir(self.dir_mut(band)); - } - } -} - pub(crate) struct ShapeSlab { pages: Vec, dict_pages: Vec, @@ -778,32 +1023,48 @@ pub(crate) struct ShapeSlab { exotic_pages: Vec, /// Present records. len: usize, + /// This is the agent's slab (the runtime state's shape table), so it + /// publishes its directories into [`AGENT_SHAPE_DIR`]. A slab a test + /// builds on its own does not. + agent: bool, +} + +impl Drop for ShapeSlab { + fn drop(&mut self) { + if self.agent { + for band in &AGENT_SHAPE_DIR[..3] { + band.pages.set(std::ptr::null()); + band.len.set(0); + } + } + // Retired records release on removal; live records release at agent + // teardown. Rekeys transfer one pointer, never duplicate ownership. + self.for_each(|_, ptr| unsafe { (*ptr).release_extras() }); + for band in [0u8, 1, 2] { + Self::free_dir(self.dir_mut(band)); + } + } } impl ShapeSlab { + /// A slab that publishes nothing (tests). pub(super) fn new() -> Self { ShapeSlab { pages: Vec::new(), dict_pages: Vec::new(), exotic_pages: Vec::new(), len: 0, + agent: false, } } - /// `(band, index within that band's directory)`: band 0 is ordinary, - /// 1 dictionary, 2 exotic receivers. - #[inline] - fn index_of(id: u32) -> Option<(u8, usize)> { - if !super::is_shape_id(id) { - return None; - } - Some(if id >= EXOTIC_SHAPE_ID_BASE { - (2, (id - EXOTIC_SHAPE_ID_BASE) as usize) - } else if id >= DICTIONARY_SHAPE_ID_BASE { - (1, (id - DICTIONARY_SHAPE_ID_BASE) as usize) - } else { - (0, (id - SHAPE_ID_BASE) as usize) - }) + /// The agent's slab: the one [`Self::agent_record`] reads. One per + /// agent (the runtime state's shape table). + pub(super) fn new_agent() -> Self { + let mut slab = Self::new(); + slab.agent = true; + slab.publish_dir(); + slab } #[inline] @@ -834,7 +1095,7 @@ impl ShapeSlab { } /// `(page, chunk within page, record within chunk)` of a slab index. - #[inline] + #[inline(always)] fn split(index: usize) -> (usize, usize, usize) { ( index >> (CHUNK_SHIFT + PAGE_SHIFT), @@ -843,6 +1104,25 @@ impl ShapeSlab { ) } + /// The cell `index` names in a directory of `len` pages at `pages`: two + /// dependent loads and no test — a page past the directory is the shared + /// empty page, and an absent page or chunk is the shared empty one. + /// + /// # Safety + /// `pages` holds `len` live entries (or `len` is 0). + #[inline(always)] + unsafe fn walk(pages: *const Page, len: usize, index: usize) -> *mut ShapeRecord { + let (page, chunk, slot) = Self::split(index); + let entry: *const Page = if page < len { + pages.add(page) + } else { + &EMPTY_PAGE_ENTRY.0 + }; + let page = (*entry).0.as_ref(); + let chunk = page[chunk].0.as_ref(); + chunk[slot].get() + } + /// Present records. #[inline] pub(super) fn len(&self) -> usize { @@ -850,17 +1130,24 @@ impl ShapeSlab { } /// The record for `id`, or `None` when the id names no descriptor in this - /// agent. The pointer stays valid until the record is removed; a removal + /// slab. The pointer stays valid until the record is removed; a removal /// only ever happens through the table's own retirement paths. #[inline] pub(super) fn record_ptr(&self, id: u32) -> Option<*mut ShapeRecord> { - let (dict, index) = Self::index_of(id)?; - let (page, chunk, slot) = Self::split(index); - let chunk = self.dir(dict).get(page)?.owned()?[chunk].owned()?; - let cell = chunk[slot].get(); - // SAFETY: the cell belongs to a live chunk owned by this slab; reads - // and writes are serialized by the single-threaded agent discipline - // every other shape-table access already relies on. + let (band, index) = locate(id); + let cell = match band { + // SAFETY: a band's `Vec` holds `len()` live entries. + 0..=2 => unsafe { + let dir = self.dir(band as u8); + Self::walk(dir.as_ptr(), dir.len(), index) + }, + _ => return None, + }; + // The published directory is this slab's, entry for entry. + debug_assert!(!self.agent || cell == Self::agent_record(id)); + // SAFETY: `walk` returns a cell of a live chunk of this slab or of the + // shared empty chunk; reads are serialized by the single-threaded + // agent discipline every other shape-table access already relies on. if unsafe { (*cell).present() } { Some(cell) } else { @@ -885,17 +1172,15 @@ impl ShapeSlab { /// Install `record` under `id`, allocating the page and chunk on first /// touch. Returns the record it replaced, if the id was already present. pub(super) fn insert(&mut self, id: u32, mut record: ShapeRecord) -> Option { - let (band, index) = - Self::index_of(id).expect("ShapeSlab::insert: id outside the ShapeId range"); + let (band, index) = locate(id); + assert!(band < 3, "ShapeSlab::insert: id outside the ShapeId range"); + let band = band as u8; record.set(RECORD_FLAG_PRESENT, true); let (page, chunk, slot) = Self::split(index); let dir = self.dir_mut(band); if page >= dir.len() { dir.resize_with(page + 1, Slot::empty); - // Only the ordinary band is mirrored (`ORDINARY_DIR`). - if band == 0 { - self.publish_dir(); - } + self.publish_dir(); } let dir = self.dir_mut(band); let page = dir[page].owned_or_alloc(); @@ -914,9 +1199,12 @@ impl ShapeSlab { /// Clear the record under `id`, returning it if it was present. pub(super) fn remove(&mut self, id: u32) -> Option { - let (dict, index) = Self::index_of(id)?; + let (band, index) = locate(id); + if band >= 3 { + return None; + } let (page, chunk, slot) = Self::split(index); - let chunk = self.dir_mut(dict).get_mut(page)?.owned_mut()?[chunk].owned_mut()?; + let chunk = self.dir_mut(band as u8).get_mut(page)?.owned_mut()?[chunk].owned_mut()?; let cell = chunk[slot].get_mut(); if !cell.present() { return None; @@ -995,35 +1283,56 @@ impl ShapeSlab { self.publish_dir(); } - /// Publish `pages` for [`Self::ordinary_record_in`] (see [`ORDINARY_DIR`]). + /// Publish every band's page vector into [`AGENT_SHAPE_DIR`], if this is + /// the agent's slab. Called after every change to a page vector (a + /// resize or shrink moves its buffer); a page or chunk allocated or + /// released in place is visible through the published buffer already. fn publish_dir(&self) { - ORDINARY_DIR.set((self.pages.as_ptr(), self.pages.len())); - // Emitted read sites hand the mirror's address to the miss front - // from the agent's pointer block; publish it with the directory. + if !self.agent { + return; + } + for (band, dir) in [&self.pages, &self.dict_pages, &self.exotic_pages] + .into_iter() + .enumerate() + { + AGENT_SHAPE_DIR[band].pages.set(dir.as_ptr()); + AGENT_SHAPE_DIR[band].len.set(dir.len()); + } + // Emitted read sites hand the ordinary entry's address to the miss + // front from the agent's pointer block; publish it with the directory. crate::agent_ptrs::publish( crate::agent_ptrs::AGENT_PTR_SHAPE_DIR, Self::ordinary_dir_addr(), ); } - /// The address of THIS thread's [`ORDINARY_DIR`] mirror, as an opaque - /// pointer for [`Self::ordinary_record_in`]. Stable for the thread's - /// life (a const-initialised `#[thread_local]` with no destructor), so an - /// agent publishes it once into its `PERRY_AGENT_PTRS` slot - /// (`agent_ptrs::perry_shape_dir_cell`) and emitted code hands it to the - /// megamorphic read confirm, which then reads no thread-local at all. + /// The address of THIS thread's ordinary-band directory + /// (`AGENT_SHAPE_DIR[0]`), as an opaque pointer for + /// [`Self::ordinary_record_in`]. Stable for the thread's life (a + /// const-initialised `#[thread_local]` with no destructor), so the agent + /// publishes it into its `PERRY_AGENT_PTRS` slot and emitted code hands it + /// to the megamorphic read confirm, which then reads no thread-local at + /// all (a runtime thread-local access is a `__tls_get_addr` call on ELF + /// and a TLV thunk call on Darwin, which would give the stub a frame). #[inline] pub(crate) fn ordinary_dir_addr() -> *const u8 { - &ORDINARY_DIR as *const OrdinaryDir as *const u8 + &AGENT_SHAPE_DIR[0] as *const BandDir as *const u8 } - /// The record of ordinary ShapeId `id` in the slab whose [`ORDINARY_DIR`] - /// mirror is at `dir` (an [`Self::ordinary_dir_addr`] of this thread, or - /// `PERRY_EMPTY_SHAPE_DIR`), or `None` — the fast twin of [`Self::record_ptr`] for the - /// megamorphic read: two dependent directory loads, no `state()`, no - /// thread-local access. A dictionary- or exotic-band id indexes past the - /// ordinary directory's length. The record may be absent (`EMPTY`): its - /// position bound is 0. + /// The record of ShapeId `id` in the ordinary directory at `dir` (this + /// thread's [`Self::ordinary_dir_addr`], or `PERRY_EMPTY_SHAPE_DIR`), or + /// `None` past its pages: the ordinary band of [`Self::agent_record`], for + /// the megamorphic read, with no band select and no thread-local access. + /// Any other id (dictionary, exotic, not a ShapeId) indexes past the + /// ordinary directory, whose pages stop below the dictionary band. Inside + /// the pages the record may be absent (the shared `ShapeRecord::EMPTY`): + /// its position bound is 0, the confirm's answer for it anyway. + /// + /// The page bound is a branch here, not [`Self::walk`]'s select: the + /// megamorphic read's ids are the receivers' own, all inside the pages, + /// so the branch is predicted and costs less than the select's address + /// arithmetic (lead_mega1: 138.4 instructions/read with it, 139.9 with the + /// select). /// /// # Safety /// `dir` is this thread's [`Self::ordinary_dir_addr`] or @@ -1033,21 +1342,56 @@ impl ShapeSlab { dir: *const u8, id: u32, ) -> Option<&'a ShapeRecord> { - let (pages, len) = (*(dir as *const OrdinaryDir)).get(); - let index = id.wrapping_sub(SHAPE_ID_BASE) as usize; - let (page, chunk, slot) = Self::split(index); - if page >= len { + let dir = &*(dir as *const BandDir); + debug_assert_eq!(dir.base_rel, 0); + let (page, chunk, slot) = Self::split(id.wrapping_sub(SHAPE_ID_BASE) as usize); + if page >= dir.len.get() { return None; } - // SAFETY: `pages` holds `len` entries of this thread's slab, current - // as of the last change to it; nothing here can change it. An absent - // page or chunk is the shared empty one (`Slot`), never null, so - // both levels are plain loads and the record is never null. - let page = (*pages.add(page)).0.as_ref(); + // SAFETY: the published pair is the ordinary band's page vector, + // current as of its last change; nothing here can change it. An + // absent page or chunk is the shared empty one (`Slot`), never null. + let page = (*dir.pages.get().add(page)).0.as_ref(); let chunk = page[chunk].0.as_ref(); Some(&*chunk[slot].get()) } + /// The record `id` names in THIS agent's slab — never null. An id that + /// names no record here (never minted, retired, not a ShapeId at all, or + /// read before the agent built its runtime state) reads the shared + /// `ShapeRecord::EMPTY`: not present, position bound 0, every lane `Any`. + /// A caller whose answer for "no record" is exactly that reads the fields + /// with no presence test; any other caller tests `present()`. + /// + /// The whole lookup: one thread-local load of the band's directory, the + /// band select, and two dependent loads (page, chunk). No `state()`. + /// + /// The pointer must not be written unless the record is present (the + /// shared empty is never written), and stays valid until the record is + /// removed. + #[inline(always)] + pub(super) fn agent_record(id: u32) -> *mut ShapeRecord { + let rel = id.wrapping_sub(SHAPE_ID_BASE); + let band = band_of(rel); + debug_assert!(band < AGENT_SHAPE_DIR.len()); + // SAFETY: `band_of` is at most 3; the published pair is the agent slab's page vector, + // current as of its last change; nothing between here and the read + // changes it. + unsafe { + let dir = AGENT_SHAPE_DIR.get_unchecked(band); + let index = rel.wrapping_sub(dir.base_rel) as usize; + Self::walk(dir.pages.get(), dir.len.get(), index) + } + } + + /// [`Self::agent_record`], or `None` when the record is absent. + #[inline(always)] + pub(super) fn agent_record_present(id: u32) -> Option<*mut ShapeRecord> { + let record = Self::agent_record(id); + // SAFETY: `agent_record` never returns null or a dead cell. + unsafe { (*record).present() }.then_some(record) + } + #[cfg(test)] pub(super) fn clear(&mut self) { for band in [0u8, 1, 2] { @@ -1513,468 +1857,5 @@ impl IdList { } #[cfg(test)] -mod tests { - use super::*; - - /// Every kind survives the record field, and the two store-fact kinds - /// (charter step 3) occupy codes 5 and 6 — distinct values, so distinct - /// ShapeIds for otherwise identical facts. - #[test] - fn kind_codes_round_trip() { - for kind in [ - ShapeObjectKind::Ordinary, - ShapeObjectKind::Class, - ShapeObjectKind::Dictionary, - ShapeObjectKind::Function, - ShapeObjectKind::FunctionDictionary, - ShapeObjectKind::OrdinaryUnmarked, - ShapeObjectKind::OrdinaryNumericProof, - ] { - assert!(kind.code() as u32 <= RECORD_KIND_MAX_CODE); - let r = ShapeRecord::new(0x1000, 1, 1, 0, kind, 0); - assert_eq!(r.object_kind(), kind); - for other in [ShapeObjectKind::Ordinary, ShapeObjectKind::OrdinaryUnmarked] { - if other != kind { - assert!(!r.facts_match(0x1000, 1, 1, 0, other, 0)); - assert_ne!( - facts_key(0x1000, 1, 1, 0, kind, 0), - facts_key(0x1000, 1, 1, 0, other, 0) - ); - } - } - } - } - - #[test] - fn slab_records_are_addressed_by_id_and_keep_their_address() { - let mut slab = ShapeSlab::new(); - let id_a = SHAPE_ID_BASE + 5; - let id_b = SHAPE_ID_BASE + 5 + (CHUNK_LEN * PAGE_LEN) as u32 * 3; - assert_eq!(slab.get(id_a), None); - assert_eq!( - slab.insert( - id_a, - ShapeRecord::new(0x1000, 1, 1, 0, ShapeObjectKind::Ordinary, 0) - ) - .map(|r| r.keys), - None - ); - let a_ptr = slab.record_ptr(id_a).expect("present"); - // A later insert into another chunk must not move the first record. - slab.insert( - id_b, - ShapeRecord::new(0x2000, 2, 2, 7, ShapeObjectKind::Class, 1), - ); - assert_eq!(slab.record_ptr(id_a), Some(a_ptr)); - assert_eq!(slab.len(), 2); - assert_eq!(slab.chunk_count(), 2); - let b = slab.get(id_b).unwrap(); - assert_eq!(b.object_kind(), ShapeObjectKind::Class); - assert_eq!(b.semantic_generation, 7); - assert_eq!(b.hole_count, 1); - assert!(b.facts_match(0x2000, 2, 2, 7, ShapeObjectKind::Class, 1)); - assert!(!b.facts_match(0x2000, 2, 2, 7, ShapeObjectKind::Ordinary, 1)); - // Ids outside the range and never-minted ids resolve to nothing. - assert_eq!(slab.get(0), None); - assert_eq!(slab.get(SHAPE_ID_BASE + 6), None); - assert_eq!(slab.get(super::super::SHAPE_ID_END - 1), None); - assert_eq!(slab.ids(), vec![id_a, id_b]); - // Removal clears the record and, once a chunk is empty, the chunk. - assert_eq!(slab.remove(id_a).map(|r| r.keys), Some(0x1000)); - assert_eq!(slab.remove(id_a), None); - assert_eq!(slab.len(), 1); - slab.release_empty_chunks(); - assert_eq!(slab.chunk_count(), 1); - assert_eq!(slab.get(id_b).map(|r| r.keys), Some(0x2000)); - assert_eq!(slab.remove(id_b).map(|r| r.keys), Some(0x2000)); - slab.release_empty_chunks(); - assert_eq!(slab.chunk_count(), 0); - assert_eq!(slab.estimated_bytes(), 0); - } - - #[test] - fn lifted_descriptor_mirrors_the_record_and_names_its_address() { - let mut slab = ShapeSlab::new(); - let id = SHAPE_ID_BASE + 42; - let mut record = ShapeRecord::new(0x3000, 4, 6, 9, ShapeObjectKind::Ordinary, 2); - record.set(RECORD_FLAG_OLD_CARRIER, true); - record.set(RECORD_FLAG_CACHE_CARRIER, true); - record.set(RECORD_FLAG_FACTS_INDEXED, false); - slab.insert(id, record); - let ptr = slab.record_ptr(id).unwrap(); - let lifted = slab.lift(id).unwrap(); - assert_eq!(lifted.record, ptr as usize); - assert_eq!(lifted.keys, 0x3000); - assert_eq!(lifted.logical_key_count, 4); - assert_eq!(lifted.live_inline_slot_count, 6); - assert_eq!(lifted.semantic_generation, 9); - assert_eq!(lifted.hole_count, 2); - assert!(lifted.old_carrier); - assert!(lifted.cache_carrier); - assert!(!slab.get(id).unwrap().has(RECORD_FLAG_FACTS_INDEXED)); - assert_eq!(lifted.keys_slot(), Some(ptr as *mut u64)); - // Writing through the slot is what an evacuating visitor does. - unsafe { *lifted.keys_slot().unwrap() = 0x4000 }; - assert_eq!(slab.get(id).unwrap().keys, 0x4000); - } - - /// The geometry that makes the object kind FREE, and the O(1) property of - /// the probe path, asserted together on purpose: the kind fits only - /// because it lives in bytes that were already padding, so a future field - /// that grows the record silently takes that away. Fail here rather than - /// discovering it as RSS. - /// - /// 32 -> 40 bytes is deliberate: [[Prototype]] is a shape fact - /// (`proto_id`), and a 64-bit prototype identity does not fit the padding. - /// 40 -> 48 is deliberate too: the per-slot field representation (charter - /// step 5, `rep`) is a shape fact with no free bits left to live in. - /// 48 -> 56 is deliberate: POSBOUND (`position_bound`, offset 40) is the - /// one-compare position fact the megamorphic read confirm needs; `rep` - /// moves to offset 48 behind it. - #[test] - fn the_record_geometry_is_free_and_facts_key_is_o1() { - assert_eq!(std::mem::size_of::(), 56, "record grew"); - assert_eq!(std::mem::align_of::(), 8, "record realigned"); - - // `facts_key` folds the keys ADDRESS; it must never dereference it. - // A wild, unmapped address must be folded, not read. If the probe - // path is ever changed to walk key strings (an O(N) content fold), - // this reads garbage and the test dies -- which is the assertion. - let wild: u64 = 0xDEAD_BEEF_DEAD_BEEF; - let a = facts_key(wild, 3, 4, 7, ShapeObjectKind::Ordinary, 0); - let b = facts_key(wild, 3, 4, 7, ShapeObjectKind::Ordinary, 0); - assert_eq!(a, b, "facts_key must be a pure fold of its arguments"); - } - - /// Every kind must reach the fold distinctly. The predecessor of this - /// test folded `object_kind == Class` as a BOOL, which gave Ordinary and - /// Dictionary the same contribution; `facts_match` re-checked the full - /// enum so it was never a wrong answer, but the two kinds differ in every - /// consumer and must not share a hash slot by construction. - #[test] - fn every_object_kind_reaches_the_facts_fold() { - let w: u64 = 0x1234_5678; - let o = facts_key(w, 3, 4, 7, ShapeObjectKind::Ordinary, 0); - let c = facts_key(w, 3, 4, 7, ShapeObjectKind::Class, 0); - let d = facts_key(w, 3, 4, 7, ShapeObjectKind::Dictionary, 0); - assert_ne!(o, c, "Ordinary and Class collide"); - assert_ne!( - o, d, - "Ordinary and Dictionary collide -- the bool fold is back" - ); - assert_ne!(c, d, "Class and Dictionary collide"); - } - - /// A record must report the kind it was built with. Storing the kind in a - /// single flag bit could represent only two, so a Dictionary record read - /// back as Ordinary -- and because `facts_match` compares the full enum, - /// that is a WRONG IDENTITY MATCH, not a hash collision. - #[test] - fn a_record_round_trips_all_three_kinds_beside_its_flags() { - for kind in [ - ShapeObjectKind::Ordinary, - ShapeObjectKind::Class, - ShapeObjectKind::Dictionary, - ] { - let mut r = ShapeRecord::new(0x4000, 2, 2, 11, kind, 1); - assert_eq!(r.object_kind(), kind, "kind did not round-trip"); - assert!(r.has(RECORD_FLAG_PRESENT)); - assert!(r.has(RECORD_FLAG_FACTS_INDEXED)); - // flags and kind share one word: moving a flag must not move the - // kind, and vice versa. - r.set(RECORD_FLAG_OLD_CARRIER, true); - assert_eq!(r.object_kind(), kind, "setting a flag moved the kind"); - r.set(RECORD_FLAG_OLD_CARRIER, false); - r.set(RECORD_FLAG_CACHE_CARRIER, true); - assert_eq!(r.object_kind(), kind, "clearing a flag moved the kind"); - assert!( - !r.has(RECORD_FLAG_OLD_CARRIER), - "clear leaked into another flag" - ); - } - } - - /// Charter step 5, P1 is inert: an all-`Any` record's facts key is - /// EXACTLY the fold it had before the `rep` word existed (recomputed here - /// without it), so no existing shape changes bucket. - #[test] - fn an_all_any_rep_keeps_the_pre_rep_facts_key() { - const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; - const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; - let fold = |acc: u64, word: u64| (acc ^ word).wrapping_mul(FNV_PRIME); - for kind in [ShapeObjectKind::Ordinary, ShapeObjectKind::Class] { - let mut h = fold(FNV_OFFSET_BASIS, 0x1111_2222_3333_4444); - for word in [7, 3, 9, 2, kind.code(), 0x77] { - h = fold(h, word); - } - let pre_rep = h ^ (h >> 32); - let key = facts_key_proto(0x1111_2222_3333_4444, 7, 3, 9, kind, 2, 0x77, 0, 0); - assert_eq!(key, pre_rep, "an all-Any shape must keep its key"); - } - } - - /// `rep` is compared on every bucket hit, not only hashed: a 64-bit fold - /// collision must never hand an F64 shape to an all-`Any` request. - #[test] - fn facts_match_compares_the_rep_identity() { - use crate::object::field_rep::{with_slot_rep, REP_F64, REP_F64_DEPRECATED}; - let f64_at_0 = with_slot_rep(0, 0, REP_F64); - let record = - ShapeRecord::new(0x40, 1, 1, 0, ShapeObjectKind::Ordinary, 0).with_rep(f64_at_0); - let facts = - |rep| record.facts_match_proto(0x40, 1, 1, 0, ShapeObjectKind::Ordinary, 0, 0, 0, rep); - assert!(facts(f64_at_0)); - assert!(!facts(0), "same facts, all-Any rep: not this shape"); - assert!( - facts(with_slot_rep(0, 0, REP_F64_DEPRECATED)), - "deprecated is not identity" - ); - } - - /// Varying any ONE fact must change the key: a fold that dropped a field - /// would send two different shapes to one bucket for every value of it. - #[test] - fn facts_key_folds_every_field() { - let base = facts_key(0x1111_2222_3333_4444, 7, 3, 9, ShapeObjectKind::Ordinary, 0); - let variants = [ - ( - "keys", - facts_key(0x5555_6666_7777_8888, 7, 3, 9, ShapeObjectKind::Ordinary, 0), - ), - ( - "logical", - facts_key(0x1111_2222_3333_4444, 8, 3, 9, ShapeObjectKind::Ordinary, 0), - ), - ( - "live", - facts_key(0x1111_2222_3333_4444, 7, 4, 9, ShapeObjectKind::Ordinary, 0), - ), - ( - "generation", - facts_key( - 0x1111_2222_3333_4444, - 7, - 3, - 10, - ShapeObjectKind::Ordinary, - 0, - ), - ), - ( - "kind", - facts_key(0x1111_2222_3333_4444, 7, 3, 9, ShapeObjectKind::Class, 0), - ), - ( - "holes", - facts_key(0x1111_2222_3333_4444, 7, 3, 9, ShapeObjectKind::Ordinary, 1), - ), - ]; - for (field, key) in variants { - assert_ne!( - key, base, - "changing `{field}` alone must change the facts key" - ); - } - let rep = facts_key_proto( - 0x1111_2222_3333_4444, - 7, - 3, - 9, - ShapeObjectKind::Ordinary, - 0, - 0, - 0, - crate::object::field_rep::REP_F64, - ); - assert_ne!(rep, base, "changing `rep` alone must change the facts key"); - let record = ShapeRecord::new(0x1111_2222_3333_4444, 7, 3, 9, ShapeObjectKind::Ordinary, 0); - assert_eq!(record.facts_key_with_keys(0x1111_2222_3333_4444), base); - assert_eq!( - record.facts_key_with_keys(0x5555_6666_7777_8888), - variants[0].1 - ); - } - - #[test] - fn id_list_keeps_order_across_the_inline_to_spill_boundary() { - let mut list = IdList::default(); - assert!(list.is_empty()); - list.push_back(2); - list.push_back(3); - list.push_front(1); - list.push_back(2); // duplicate ignored - assert_eq!(list.as_slice(), &[1, 2, 3]); - assert!(matches!(list, IdList::Inline { .. })); - list.push_back(4); - assert!(matches!(list, IdList::Spill(_))); - assert_eq!(list.as_slice(), &[1, 2, 3, 4]); - list.push_front(0); - assert_eq!(list.as_slice(), &[0, 1, 2, 3, 4]); - // The ORDERED removal keeps this list's order, which is what - // `by_facts` depends on. - assert!(list.remove_ordered(2)); - assert!(!list.remove_ordered(2)); - assert_eq!(list.as_slice(), &[0, 1, 3, 4]); - assert!(list.replace(3, 30)); - assert!(!list.replace(3, 300)); - assert_eq!(list.as_slice(), &[0, 1, 30, 4]); - assert!(list.heap_bytes() >= 4 * 4); - - let mut inline = IdList::default(); - inline.push_back(7); - inline.push_back(8); - inline.push_back(9); - assert!(inline.remove_ordered(8)); - assert_eq!(inline.as_slice(), &[7, 9]); - assert!(inline.replace(9, 10)); - assert_eq!(inline.as_slice(), &[7, 10]); - assert!(inline.remove_ordered(7)); - assert!(inline.remove_ordered(10)); - assert!(inline.is_empty()); - assert_eq!(inline.heap_bytes(), 0); - } - - /// THE GUARD for this change, and it is an asymmetric one: the unordered - /// removal must move O(1) elements per call, and the ordered one is - /// allowed to move O(n) because that is what preserving the order costs. - /// - /// Front removal is the measured shape of the defect — removals sit at - /// position ~0.31 of a list up to 514,030 long — so the test removes from - /// the front, which is the worst case for `Vec::remove` and the best case - /// for nothing. - /// - /// **Sabotage: point `remove_unordered` at `remove_ordered`.** The bound - /// below is `4 * N`; the O(n) path moves `N * (N - 1) / 2` = 1,999,000 - /// elements for N = 2,000, i.e. 250x the bound, and this fails. A bound - /// expressed as a MULTIPLE of N rather than an absolute is what makes the - /// assertion about the complexity class instead of about one N. - #[test] - fn unordered_removal_moves_o1_elements_and_scans_o1_entries() { - const N: u32 = 2_000; - - let baseline = ID_LIST_OP_STATS.with(std::cell::Cell::get); - let mut list = IdList::default(); - for id in 1..=N { - // The interning sites' entry point: no membership probe. - list.append_unchecked(id); - } - assert_eq!(list.len(), N as usize); - assert!(matches!(list, IdList::Spill(_))); - - // Remove every id from the FRONT of the list, in insertion order. - for id in 1..=N { - assert!(list.remove_unordered(id), "id {id} was not present"); - } - assert!(list.is_empty()); - - let after = ID_LIST_OP_STATS.with(std::cell::Cell::get); - let moved = after.elems_moved - baseline.elems_moved; - let scanned = after.positions_scanned - baseline.positions_scanned; - let removals = after.removals - baseline.removals; - assert_eq!(removals, u64::from(N)); - - // O(1) per removal, with room for the swap itself. - assert!( - moved <= 4 * u64::from(N), - "unordered removal moved {moved} elements for {N} removals — that \ - is the O(n) tail shift this structure exists to remove \ - (the ordered path would move {})", - u64::from(N) * (u64::from(N) - 1) / 2 - ); - // The index answers `position`, so no linear scan may be charged for - // a list this long. Sabotage: raise SPILL_INDEX_MIN above N and this - // fails with ~N*N/2 scanned entries. - assert!( - scanned <= 4 * u64::from(N), - "unordered removal scanned {scanned} entries for {N} removals — \ - the spill index is not answering `position`" - ); - } - - /// The index must agree with the vector after every operation, including - /// the swap that moves a third element nobody named. Checked exhaustively - /// against a plain `Vec` oracle, because an index that drifts is a wrong - /// ANSWER (a descriptor that cannot be found, or one found under the wrong - /// id), not a slow one. - /// - /// Sabotage: drop the `self.pos.insert(self.ids[pos], pos as u32)` fixup - /// in `remove_unordered` — the element the swap relocated keeps a stale - /// index and the `contains` check below fails. - #[test] - fn the_spill_index_agrees_with_the_vector_after_every_operation() { - let mut list = IdList::default(); - let mut oracle: Vec = Vec::new(); - for id in 1..=200u32 { - list.append_unchecked(id); - oracle.push(id); - } - // Remove a scattered third of them, front, middle and back. - for &id in &[1u32, 2, 3, 100, 101, 199, 200, 50, 150, 7] { - assert!(list.remove_unordered(id)); - oracle.retain(|&x| x != id); - } - // Same SET, whatever the order. - let mut got = list.as_slice().to_vec(); - got.sort_unstable(); - let mut want = oracle.clone(); - want.sort_unstable(); - assert_eq!(got, want); - // And every survivor is still findable through the index. - for &id in &want { - assert!(list.contains(id), "id {id} lost its index entry"); - } - for &id in &[1u32, 2, 3, 100, 101, 199, 200, 50, 150, 7] { - assert!(!list.contains(id), "removed id {id} is still findable"); - } - // `replace` must keep the index coherent too. - let survivor = want[0]; - assert!(list.replace(survivor, 9_999)); - assert!(!list.contains(survivor)); - assert!(list.contains(9_999)); - } - - /// A list that never reaches `SPILL_INDEX_MIN` must not allocate an index - /// — the map is the structure's memory cost and it is only worth paying - /// where the scan hurts. `by_facts` lists, measured at length 1 on cc, - /// live entirely in this regime. - #[test] - fn a_short_spilled_list_builds_no_index() { - let mut list = IdList::default(); - for id in 1..=8u32 { - list.append_unchecked(id); - } - assert!(matches!(list, IdList::Spill(_))); - match &list { - IdList::Spill(v) => assert!( - !v.indexed(), - "a list of 8 built an index; SPILL_INDEX_MIN is {SPILL_INDEX_MIN}" - ), - IdList::Inline { .. } => unreachable!(), - } - // Still correct without one. - assert!(list.remove_unordered(4)); - assert!(!list.contains(4)); - assert!(list.contains(8)); - } - - /// A dictionary-band id lives in its own directory: inserting one must not - /// grow the ordinary directory to the band's offset (~24,577 page slots), - /// which moved the GC arena's pages and cost tsc +2.3% instructions. - #[test] - fn a_dictionary_band_id_does_not_grow_the_ordinary_directory() { - let mut slab = ShapeSlab::new(); - let ordinary = super::super::SHAPE_ID_BASE + 3; - let dict = super::super::DICTIONARY_SHAPE_ID_BASE + 5; - slab.insert(ordinary, ShapeRecord::EMPTY); - slab.insert(dict, ShapeRecord::EMPTY); - assert_eq!( - slab.pages.len(), - 1, - "one ordinary page slot, not the band offset" - ); - assert_eq!(slab.dict_pages.len(), 1); - assert!(slab.record_ptr(ordinary).is_some() && slab.record_ptr(dict).is_some()); - assert_eq!(slab.ids(), vec![ordinary, dict]); - assert!(slab.remove(dict).is_some() && slab.record_ptr(dict).is_none()); - } -} +#[path = "shapes_store_tests.rs"] +mod tests; diff --git a/crates/perry-runtime/src/object/shapes_store_tests.rs b/crates/perry-runtime/src/object/shapes_store_tests.rs new file mode 100644 index 0000000000..fc005a9f07 --- /dev/null +++ b/crates/perry-runtime/src/object/shapes_store_tests.rs @@ -0,0 +1,651 @@ +use super::*; + +/// Every kind survives the record field, and the two store-fact kinds +/// (charter step 3) occupy codes 5 and 6 — distinct values, so distinct +/// ShapeIds for otherwise identical facts. +#[test] +fn kind_codes_round_trip() { + for kind in [ + ShapeObjectKind::Ordinary, + ShapeObjectKind::Class, + ShapeObjectKind::Dictionary, + ShapeObjectKind::Function, + ShapeObjectKind::FunctionDictionary, + ShapeObjectKind::OrdinaryUnmarked, + ShapeObjectKind::OrdinaryNumericProof, + ] { + assert!(kind.code() as u32 <= RECORD_KIND_MAX_CODE); + let r = ShapeRecord::new(0x1000, 1, 1, 0, kind, 0); + assert_eq!(r.object_kind(), kind); + for other in [ShapeObjectKind::Ordinary, ShapeObjectKind::OrdinaryUnmarked] { + if other != kind { + assert!(!r.facts_match(0x1000, 1, 1, 0, other, 0)); + assert_ne!( + facts_key(0x1000, 1, 1, 0, kind, 0), + facts_key(0x1000, 1, 1, 0, other, 0) + ); + } + } + } +} + +/// `locate` agrees with the band constants at every boundary, and an id +/// outside the ShapeId range lands in the always-empty band 3. +#[test] +fn locate_names_the_band_and_index_at_every_boundary() { + use super::super::SHAPE_ID_END; + let cases = [ + (SHAPE_ID_BASE, 0, 0), + (DICTIONARY_SHAPE_ID_BASE - 1, 0, (DICT_REL - 1) as usize), + (DICTIONARY_SHAPE_ID_BASE, 1, 0), + ( + EXOTIC_SHAPE_ID_BASE - 1, + 1, + (EXOTIC_REL - DICT_REL - 1) as usize, + ), + (EXOTIC_SHAPE_ID_BASE, 2, 0), + (SHAPE_ID_END - 1, 2, (END_REL - EXOTIC_REL - 1) as usize), + ]; + for (id, band, index) in cases { + assert_eq!(locate(id), (band, index), "{id:#x}"); + } + for id in [0, 1, SHAPE_ID_BASE - 1, SHAPE_ID_END, 0xF000_0000, u32::MAX] { + assert_eq!(locate(id).0, 3, "{id:#x}"); + } +} + +/// The agent directory ([`AGENT_SHAPE_DIR`]) is the agent slab's, entry +/// for entry, in every band, and per agent; an id that names no record — +/// never minted, retired, past every page, in no band, or read before the +/// agent has any runtime state — reads the shared empty record: not +/// present, every lane `Any`, position bound 0. Runs on threads of its +/// own, each its own agent, so the records it installs are seen by +/// nothing else. +#[test] +fn the_agent_directory_is_the_agent_slab_and_an_absent_id_reads_empty() { + use super::super::{ + alloc_dictionary_shape_id, alloc_exotic_shape_id, alloc_shape_id, shape_record_by_id, + shape_rep_by_id, SHAPE_ID_END, + }; + fn absent(id: u32) { + // SAFETY: `agent_record` never returns null. + let r = unsafe { &*ShapeSlab::agent_record(id) }; + assert!(!r.present(), "{id:#x} reads a present record"); + assert_eq!(r.rep, 0, "{id:#x}: absent rep"); + assert_eq!(r.position_bound(), 0, "{id:#x}: absent position bound"); + assert!(shape_record_by_id(id).is_none(), "{id:#x}"); + assert_eq!(shape_rep_by_id(id), 0, "{id:#x}"); + } + // Ids no counter reaches in a test process, in and around every band. + const NEVER: [u32; 10] = [ + 0, + 1, + 0x7FFF_FF00, + SHAPE_ID_BASE - 1, + DICTIONARY_SHAPE_ID_BASE - 1, + EXOTIC_SHAPE_ID_BASE - 1, + SHAPE_ID_END - 1, + SHAPE_ID_END, + 0xF000_0000, + u32::MAX, + ]; + std::thread::spawn(|| { + // No runtime state yet: the const directory answers. + for id in NEVER { + absent(id); + } + let table = &crate::state::state().shapes; + let ids = [ + alloc_shape_id().unwrap(), + alloc_dictionary_shape_id().unwrap(), + alloc_exotic_shape_id().unwrap(), + ]; + let reps = [0b01u64, 0b01 << 2, 0b10 << 4]; + for (&id, &rep) in ids.iter().zip(&reps) { + // Keyless: nothing here ever dereferences a keys word. + let record = ShapeRecord::new(0, 0, 3, 0, ShapeObjectKind::Ordinary, 0).with_rep(rep); + // SAFETY: no slab reference is held. + unsafe { table.slab_mut().insert(id, record) }; + } + let check = |ids: &[u32], reps: &[u64]| { + for (&id, &rep) in ids.iter().zip(reps) { + assert_eq!( + table.slab().record_ptr(id), + Some(ShapeSlab::agent_record(id)) + ); + assert_eq!(shape_rep_by_id(id), rep, "{id:#x}"); + assert_eq!( + shape_record_by_id(id).map(|r| r.live_inline_slot_count()), + Some(3) + ); + // The id's chunk neighbour is allocated and absent. + absent(id ^ 1); + } + }; + check(&ids, &reps); + for id in NEVER { + absent(id); + } + // Growing a band's page vector moves its buffer: the directory + // follows, and the records already there are still found. + let far = DICTIONARY_SHAPE_ID_BASE - 2; + let record = ShapeRecord::new(0, 0, 3, 0, ShapeObjectKind::Ordinary, 0).with_rep(0b01); + // SAFETY: no slab reference is held. + unsafe { table.slab_mut().insert(far, record) }; + check(&ids, &reps); + check(&[far], &[0b01]); + // Another agent sees none of this agent's records, before and + // after it builds its own state. + std::thread::spawn(move || { + for id in ids { + absent(id); + } + let _ = crate::state::state(); + for id in ids.into_iter().chain([far]) { + absent(id); + } + }) + .join() + .unwrap(); + // Retired, and its chunk and page released: absent again, and the + // directory shrinks with the slab. + for id in ids.into_iter().chain([far]) { + // SAFETY: no slab reference is held. + assert!(unsafe { table.slab_mut().remove(id) }.is_some()); + absent(id); + } + // SAFETY: no slab reference is held. + unsafe { table.slab_mut().release_empty_chunks() }; + for id in ids.into_iter().chain([far]).chain(NEVER) { + absent(id); + } + }) + .join() + .unwrap(); +} + +#[test] +fn slab_records_are_addressed_by_id_and_keep_their_address() { + let mut slab = ShapeSlab::new(); + let id_a = SHAPE_ID_BASE + 5; + let id_b = SHAPE_ID_BASE + 5 + (CHUNK_LEN * PAGE_LEN) as u32 * 3; + assert_eq!(slab.get(id_a), None); + assert_eq!( + slab.insert( + id_a, + ShapeRecord::new(0x1000, 1, 1, 0, ShapeObjectKind::Ordinary, 0) + ) + .map(|r| r.keys), + None + ); + let a_ptr = slab.record_ptr(id_a).expect("present"); + // A later insert into another chunk must not move the first record. + slab.insert( + id_b, + ShapeRecord::new(0x2000, 2, 2, 7, ShapeObjectKind::Class, 1), + ); + assert_eq!(slab.record_ptr(id_a), Some(a_ptr)); + assert_eq!(slab.len(), 2); + assert_eq!(slab.chunk_count(), 2); + let b = slab.get(id_b).unwrap(); + assert_eq!(b.object_kind(), ShapeObjectKind::Class); + assert_eq!(b.semantic_generation, 7); + assert_eq!(b.hole_count, 1); + assert!(b.facts_match(0x2000, 2, 2, 7, ShapeObjectKind::Class, 1)); + assert!(!b.facts_match(0x2000, 2, 2, 7, ShapeObjectKind::Ordinary, 1)); + // Ids outside the range and never-minted ids resolve to nothing. + assert_eq!(slab.get(0), None); + assert_eq!(slab.get(SHAPE_ID_BASE + 6), None); + assert_eq!(slab.get(super::super::SHAPE_ID_END - 1), None); + assert_eq!(slab.ids(), vec![id_a, id_b]); + // Removal clears the record and, once a chunk is empty, the chunk. + assert_eq!(slab.remove(id_a).map(|r| r.keys), Some(0x1000)); + assert_eq!(slab.remove(id_a), None); + assert_eq!(slab.len(), 1); + slab.release_empty_chunks(); + assert_eq!(slab.chunk_count(), 1); + assert_eq!(slab.get(id_b).map(|r| r.keys), Some(0x2000)); + assert_eq!(slab.remove(id_b).map(|r| r.keys), Some(0x2000)); + slab.release_empty_chunks(); + assert_eq!(slab.chunk_count(), 0); + assert_eq!(slab.estimated_bytes(), 0); +} + +#[test] +fn lifted_descriptor_mirrors_the_record_and_names_its_address() { + let mut slab = ShapeSlab::new(); + let id = SHAPE_ID_BASE + 42; + let mut record = ShapeRecord::new(0x3000, 4, 6, 9, ShapeObjectKind::Ordinary, 2); + record.set(RECORD_FLAG_OLD_CARRIER, true); + record.set(RECORD_FLAG_CACHE_CARRIER, true); + record.set(RECORD_FLAG_FACTS_INDEXED, false); + slab.insert(id, record); + let ptr = slab.record_ptr(id).unwrap(); + let lifted = slab.lift(id).unwrap(); + assert_eq!(lifted.record, ptr as usize); + assert_eq!(lifted.keys, 0x3000); + assert_eq!(lifted.logical_key_count, 4); + assert_eq!(lifted.live_inline_slot_count, 6); + assert_eq!(lifted.semantic_generation, 9); + assert_eq!(lifted.hole_count, 2); + assert!(lifted.old_carrier); + assert!(lifted.cache_carrier); + assert!(!slab.get(id).unwrap().has(RECORD_FLAG_FACTS_INDEXED)); + assert_eq!(lifted.keys_slot(), Some(ptr as *mut u64)); + // Writing through the slot is what an evacuating visitor does. + unsafe { *lifted.keys_slot().unwrap() = 0x4000 }; + assert_eq!(slab.get(id).unwrap().keys, 0x4000); +} + +/// The geometry that makes the object kind FREE, and the O(1) property of +/// the probe path, asserted together on purpose: the kind fits only +/// because it lives in bytes that were already padding, so a future field +/// that grows the record silently takes that away. Fail here rather than +/// discovering it as RSS. ConstFn's owned extension is the first explicit +/// growth since these earlier packed facts. +/// +/// 32 -> 40 bytes is deliberate: [[Prototype]] is a shape fact +/// (`proto_id`), and a 64-bit prototype identity does not fit the padding. +/// 40 -> 48 is deliberate too: the per-slot field representation (charter +/// step 5, `rep`) is a shape fact with no free bits left to live in. +/// 48 -> 56 is deliberate: POSBOUND (`position_bound`, offset 40) is the +/// one-compare position fact the megamorphic read confirm needs; `rep` +/// moves to offset 48 behind it. +/// 56 -> 64 is the record-owned ConstFn extension pointer; no side table +/// or pointer to a heap closure participates in shape identity. +#[test] +fn the_record_geometry_is_free_and_facts_key_is_o1() { + assert_eq!(std::mem::size_of::(), 64, "record grew"); + assert_eq!(std::mem::align_of::(), 8, "record realigned"); + + // `facts_key` folds the keys ADDRESS; it must never dereference it. + // A wild, unmapped address must be folded, not read. If the probe + // path is ever changed to walk key strings (an O(N) content fold), + // this reads garbage and the test dies -- which is the assertion. + let wild: u64 = 0xDEAD_BEEF_DEAD_BEEF; + let a = facts_key(wild, 3, 4, 7, ShapeObjectKind::Ordinary, 0); + let b = facts_key(wild, 3, 4, 7, ShapeObjectKind::Ordinary, 0); + assert_eq!(a, b, "facts_key must be a pure fold of its arguments"); +} + +/// Every kind must reach the fold distinctly. The predecessor of this +/// test folded `object_kind == Class` as a BOOL, which gave Ordinary and +/// Dictionary the same contribution; `facts_match` re-checked the full +/// enum so it was never a wrong answer, but the two kinds differ in every +/// consumer and must not share a hash slot by construction. +#[test] +fn every_object_kind_reaches_the_facts_fold() { + let w: u64 = 0x1234_5678; + let o = facts_key(w, 3, 4, 7, ShapeObjectKind::Ordinary, 0); + let c = facts_key(w, 3, 4, 7, ShapeObjectKind::Class, 0); + let d = facts_key(w, 3, 4, 7, ShapeObjectKind::Dictionary, 0); + assert_ne!(o, c, "Ordinary and Class collide"); + assert_ne!( + o, d, + "Ordinary and Dictionary collide -- the bool fold is back" + ); + assert_ne!(c, d, "Class and Dictionary collide"); +} + +/// A record must report the kind it was built with. Storing the kind in a +/// single flag bit could represent only two, so a Dictionary record read +/// back as Ordinary -- and because `facts_match` compares the full enum, +/// that is a WRONG IDENTITY MATCH, not a hash collision. +#[test] +fn a_record_round_trips_all_three_kinds_beside_its_flags() { + for kind in [ + ShapeObjectKind::Ordinary, + ShapeObjectKind::Class, + ShapeObjectKind::Dictionary, + ] { + let mut r = ShapeRecord::new(0x4000, 2, 2, 11, kind, 1); + assert_eq!(r.object_kind(), kind, "kind did not round-trip"); + assert!(r.has(RECORD_FLAG_PRESENT)); + assert!(r.has(RECORD_FLAG_FACTS_INDEXED)); + // flags and kind share one word: moving a flag must not move the + // kind, and vice versa. + r.set(RECORD_FLAG_OLD_CARRIER, true); + assert_eq!(r.object_kind(), kind, "setting a flag moved the kind"); + r.set(RECORD_FLAG_OLD_CARRIER, false); + r.set(RECORD_FLAG_CACHE_CARRIER, true); + assert_eq!(r.object_kind(), kind, "clearing a flag moved the kind"); + assert!( + !r.has(RECORD_FLAG_OLD_CARRIER), + "clear leaked into another flag" + ); + } +} + +#[test] +fn special_body_identity_uses_record_owned_extension() { + let old = ShapeRecord::new(0x40, 1, 1, 0, ShapeObjectKind::Ordinary, 0); + assert_eq!(old.special_constfn_mask(), 0); + let a = [ConstFnSlotInfo { + slot: 0, + info: 0x1000, + }]; + let b = [ConstFnSlotInfo { + slot: 0, + info: 0x2000, + }]; + let rep = crate::object::field_rep::with_slot_rep(0, 0, crate::object::field_rep::REP_SPECIAL); + let special = old.with_special_facts(rep, &a); + assert_eq!(special.special_constfn_mask(), 1); + assert_eq!(special.constfn_infos(), &a); + assert_eq!(special.position_bound_raw(), old.position_bound_raw()); + assert_eq!(std::mem::size_of::(), 64); + assert!(special.facts_match_proto_with_special( + 0x40, + 1, + 1, + 0, + ShapeObjectKind::Ordinary, + 0, + 0, + 0, + rep, + &a + )); + assert!(!special.facts_match_proto_with_special( + 0x40, + 1, + 1, + 0, + ShapeObjectKind::Ordinary, + 0, + 0, + 0, + rep, + &b + )); + let hash_a = + facts_key_proto_with_special(0x40, 1, 1, 0, ShapeObjectKind::Ordinary, 0, 0, 0, rep, &a); + let hash_b = + facts_key_proto_with_special(0x40, 1, 1, 0, ShapeObjectKind::Ordinary, 0, 0, 0, rep, &b); + assert_ne!(hash_a, hash_b); + // SAFETY: this synthetic record is the unique owner of its box. + unsafe { special.release_extras() }; +} + +/// Charter step 5, P1 is inert: an all-`Any` record's facts key is +/// EXACTLY the fold it had before the `rep` word existed (recomputed here +/// without it), so no existing shape changes bucket. +#[test] +fn an_all_any_rep_keeps_the_pre_rep_facts_key() { + const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; + const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; + let fold = |acc: u64, word: u64| (acc ^ word).wrapping_mul(FNV_PRIME); + for kind in [ShapeObjectKind::Ordinary, ShapeObjectKind::Class] { + let mut h = fold(FNV_OFFSET_BASIS, 0x1111_2222_3333_4444); + for word in [7, 3, 9, 2, kind.code(), 0x77] { + h = fold(h, word); + } + let pre_rep = h ^ (h >> 32); + let key = facts_key_proto(0x1111_2222_3333_4444, 7, 3, 9, kind, 2, 0x77, 0, 0); + assert_eq!(key, pre_rep, "an all-Any shape must keep its key"); + } +} + +/// `rep` is compared on every bucket hit, not only hashed: a 64-bit fold +/// collision must never hand an F64 shape to an all-`Any` request. +#[test] +fn facts_match_compares_the_rep_identity() { + use crate::object::field_rep::{with_slot_rep, REP_F64, REP_F64_DEPRECATED}; + let f64_at_0 = with_slot_rep(0, 0, REP_F64); + let record = ShapeRecord::new(0x40, 1, 1, 0, ShapeObjectKind::Ordinary, 0).with_rep(f64_at_0); + let facts = + |rep| record.facts_match_proto(0x40, 1, 1, 0, ShapeObjectKind::Ordinary, 0, 0, 0, rep); + assert!(facts(f64_at_0)); + assert!(!facts(0), "same facts, all-Any rep: not this shape"); + assert!( + facts(with_slot_rep(0, 0, REP_F64_DEPRECATED)), + "deprecated is not identity" + ); +} + +/// Varying any ONE fact must change the key: a fold that dropped a field +/// would send two different shapes to one bucket for every value of it. +#[test] +fn facts_key_folds_every_field() { + let base = facts_key(0x1111_2222_3333_4444, 7, 3, 9, ShapeObjectKind::Ordinary, 0); + let variants = [ + ( + "keys", + facts_key(0x5555_6666_7777_8888, 7, 3, 9, ShapeObjectKind::Ordinary, 0), + ), + ( + "logical", + facts_key(0x1111_2222_3333_4444, 8, 3, 9, ShapeObjectKind::Ordinary, 0), + ), + ( + "live", + facts_key(0x1111_2222_3333_4444, 7, 4, 9, ShapeObjectKind::Ordinary, 0), + ), + ( + "generation", + facts_key( + 0x1111_2222_3333_4444, + 7, + 3, + 10, + ShapeObjectKind::Ordinary, + 0, + ), + ), + ( + "kind", + facts_key(0x1111_2222_3333_4444, 7, 3, 9, ShapeObjectKind::Class, 0), + ), + ( + "holes", + facts_key(0x1111_2222_3333_4444, 7, 3, 9, ShapeObjectKind::Ordinary, 1), + ), + ]; + for (field, key) in variants { + assert_ne!( + key, base, + "changing `{field}` alone must change the facts key" + ); + } + let rep = facts_key_proto( + 0x1111_2222_3333_4444, + 7, + 3, + 9, + ShapeObjectKind::Ordinary, + 0, + 0, + 0, + crate::object::field_rep::REP_F64, + ); + assert_ne!(rep, base, "changing `rep` alone must change the facts key"); + let record = ShapeRecord::new(0x1111_2222_3333_4444, 7, 3, 9, ShapeObjectKind::Ordinary, 0); + assert_eq!(record.facts_key_with_keys(0x1111_2222_3333_4444), base); + assert_eq!( + record.facts_key_with_keys(0x5555_6666_7777_8888), + variants[0].1 + ); +} + +#[test] +fn id_list_keeps_order_across_the_inline_to_spill_boundary() { + let mut list = IdList::default(); + assert!(list.is_empty()); + list.push_back(2); + list.push_back(3); + list.push_front(1); + list.push_back(2); // duplicate ignored + assert_eq!(list.as_slice(), &[1, 2, 3]); + assert!(matches!(list, IdList::Inline { .. })); + list.push_back(4); + assert!(matches!(list, IdList::Spill(_))); + assert_eq!(list.as_slice(), &[1, 2, 3, 4]); + list.push_front(0); + assert_eq!(list.as_slice(), &[0, 1, 2, 3, 4]); + // The ORDERED removal keeps this list's order, which is what + // `by_facts` depends on. + assert!(list.remove_ordered(2)); + assert!(!list.remove_ordered(2)); + assert_eq!(list.as_slice(), &[0, 1, 3, 4]); + assert!(list.replace(3, 30)); + assert!(!list.replace(3, 300)); + assert_eq!(list.as_slice(), &[0, 1, 30, 4]); + assert!(list.heap_bytes() >= 4 * 4); + + let mut inline = IdList::default(); + inline.push_back(7); + inline.push_back(8); + inline.push_back(9); + assert!(inline.remove_ordered(8)); + assert_eq!(inline.as_slice(), &[7, 9]); + assert!(inline.replace(9, 10)); + assert_eq!(inline.as_slice(), &[7, 10]); + assert!(inline.remove_ordered(7)); + assert!(inline.remove_ordered(10)); + assert!(inline.is_empty()); + assert_eq!(inline.heap_bytes(), 0); +} + +/// THE GUARD for this change, and it is an asymmetric one: the unordered +/// removal must move O(1) elements per call, and the ordered one is +/// allowed to move O(n) because that is what preserving the order costs. +/// +/// Front removal is the measured shape of the defect — removals sit at +/// position ~0.31 of a list up to 514,030 long — so the test removes from +/// the front, which is the worst case for `Vec::remove` and the best case +/// for nothing. +/// +/// **Sabotage: point `remove_unordered` at `remove_ordered`.** The bound +/// below is `4 * N`; the O(n) path moves `N * (N - 1) / 2` = 1,999,000 +/// elements for N = 2,000, i.e. 250x the bound, and this fails. A bound +/// expressed as a MULTIPLE of N rather than an absolute is what makes the +/// assertion about the complexity class instead of about one N. +#[test] +fn unordered_removal_moves_o1_elements_and_scans_o1_entries() { + const N: u32 = 2_000; + + let baseline = ID_LIST_OP_STATS.with(std::cell::Cell::get); + let mut list = IdList::default(); + for id in 1..=N { + // The interning sites' entry point: no membership probe. + list.append_unchecked(id); + } + assert_eq!(list.len(), N as usize); + assert!(matches!(list, IdList::Spill(_))); + + // Remove every id from the FRONT of the list, in insertion order. + for id in 1..=N { + assert!(list.remove_unordered(id), "id {id} was not present"); + } + assert!(list.is_empty()); + + let after = ID_LIST_OP_STATS.with(std::cell::Cell::get); + let moved = after.elems_moved - baseline.elems_moved; + let scanned = after.positions_scanned - baseline.positions_scanned; + let removals = after.removals - baseline.removals; + assert_eq!(removals, u64::from(N)); + + // O(1) per removal, with room for the swap itself. + assert!( + moved <= 4 * u64::from(N), + "unordered removal moved {moved} elements for {N} removals — that \ + is the O(n) tail shift this structure exists to remove \ + (the ordered path would move {})", + u64::from(N) * (u64::from(N) - 1) / 2 + ); + // The index answers `position`, so no linear scan may be charged for + // a list this long. Sabotage: raise SPILL_INDEX_MIN above N and this + // fails with ~N*N/2 scanned entries. + assert!( + scanned <= 4 * u64::from(N), + "unordered removal scanned {scanned} entries for {N} removals — \ + the spill index is not answering `position`" + ); +} + +/// The index must agree with the vector after every operation, including +/// the swap that moves a third element nobody named. Checked exhaustively +/// against a plain `Vec` oracle, because an index that drifts is a wrong +/// ANSWER (a descriptor that cannot be found, or one found under the wrong +/// id), not a slow one. +/// +/// Sabotage: drop the `self.pos.insert(self.ids[pos], pos as u32)` fixup +/// in `remove_unordered` — the element the swap relocated keeps a stale +/// index and the `contains` check below fails. +#[test] +fn the_spill_index_agrees_with_the_vector_after_every_operation() { + let mut list = IdList::default(); + let mut oracle: Vec = Vec::new(); + for id in 1..=200u32 { + list.append_unchecked(id); + oracle.push(id); + } + // Remove a scattered third of them, front, middle and back. + for &id in &[1u32, 2, 3, 100, 101, 199, 200, 50, 150, 7] { + assert!(list.remove_unordered(id)); + oracle.retain(|&x| x != id); + } + // Same SET, whatever the order. + let mut got = list.as_slice().to_vec(); + got.sort_unstable(); + let mut want = oracle.clone(); + want.sort_unstable(); + assert_eq!(got, want); + // And every survivor is still findable through the index. + for &id in &want { + assert!(list.contains(id), "id {id} lost its index entry"); + } + for &id in &[1u32, 2, 3, 100, 101, 199, 200, 50, 150, 7] { + assert!(!list.contains(id), "removed id {id} is still findable"); + } + // `replace` must keep the index coherent too. + let survivor = want[0]; + assert!(list.replace(survivor, 9_999)); + assert!(!list.contains(survivor)); + assert!(list.contains(9_999)); +} + +/// A list that never reaches `SPILL_INDEX_MIN` must not allocate an index +/// — the map is the structure's memory cost and it is only worth paying +/// where the scan hurts. `by_facts` lists, measured at length 1 on cc, +/// live entirely in this regime. +#[test] +fn a_short_spilled_list_builds_no_index() { + let mut list = IdList::default(); + for id in 1..=8u32 { + list.append_unchecked(id); + } + assert!(matches!(list, IdList::Spill(_))); + match &list { + IdList::Spill(v) => assert!( + !v.indexed(), + "a list of 8 built an index; SPILL_INDEX_MIN is {SPILL_INDEX_MIN}" + ), + IdList::Inline { .. } => unreachable!(), + } + // Still correct without one. + assert!(list.remove_unordered(4)); + assert!(!list.contains(4)); + assert!(list.contains(8)); +} + +/// A dictionary-band id lives in its own directory: inserting one must not +/// grow the ordinary directory to the band's offset (~24,577 page slots), +/// which moved the GC arena's pages and cost tsc +2.3% instructions. +#[test] +fn a_dictionary_band_id_does_not_grow_the_ordinary_directory() { + let mut slab = ShapeSlab::new(); + let ordinary = super::super::SHAPE_ID_BASE + 3; + let dict = super::super::DICTIONARY_SHAPE_ID_BASE + 5; + slab.insert(ordinary, ShapeRecord::EMPTY); + slab.insert(dict, ShapeRecord::EMPTY); + assert_eq!( + slab.pages.len(), + 1, + "one ordinary page slot, not the band offset" + ); + assert_eq!(slab.dict_pages.len(), 1); + assert!(slab.record_ptr(ordinary).is_some() && slab.record_ptr(dict).is_some()); + assert_eq!(slab.ids(), vec![ordinary, dict]); + assert!(slab.remove(dict).is_some() && slab.record_ptr(dict).is_none()); +} diff --git a/crates/perry-runtime/src/object/shapes_tests.rs b/crates/perry-runtime/src/object/shapes_tests.rs index 917d6e0ee4..f6c83b5194 100644 --- a/crates/perry-runtime/src/object/shapes_tests.rs +++ b/crates/perry-runtime/src/object/shapes_tests.rs @@ -1561,8 +1561,8 @@ mod field_rep_identity_tests { } #[test] - fn a_reserved_lane_is_refused() { - let reserved = with_slot_rep(0, 2, crate::object::field_rep::REP_RESERVED); + fn an_unbound_special_lane_is_refused() { + let reserved = with_slot_rep(0, 2, crate::object::field_rep::REP_SPECIAL); assert!(shape_descriptor_ensure_with_rep( std::ptr::null(), 0, diff --git a/crates/perry-runtime/src/object/shapes_worker_seed.rs b/crates/perry-runtime/src/object/shapes_worker_seed.rs index 45ccdd70a6..d3adfa0e56 100644 --- a/crates/perry-runtime/src/object/shapes_worker_seed.rs +++ b/crates/perry-runtime/src/object/shapes_worker_seed.rs @@ -13,14 +13,16 @@ use super::shapes_store::RECORD_FLAG_EXTERNAL_CARRIER; use super::ShapeObjectKind; -#[derive(Clone, Copy)] +#[derive(Clone)] struct SeedRecord { id: u32, - keys: u64, + names: Vec>, logical_key_count: u32, live_inline_slot_count: u32, proto_id: u64, rep: u64, + infos: Vec, + to_any: u32, } /// The spawner agent's external-carrier ShapeIds, captured on the spawning @@ -39,14 +41,42 @@ pub(crate) fn worker_shape_seed() -> WorkerShapeSeed { if r.has(RECORD_FLAG_EXTERNAL_CARRIER) && r.hole_count == 0 && r.object_kind() == ShapeObjectKind::Ordinary + && r.semantic_generation == 0 + && r.summary() == 0 { + // Copy key BYTES while the source agent owns the record. A worker + // builds its own canonical keys, so no moving source key pointer + // is held in the Rust-owned seed snapshot. + let mut names = Vec::new(); + let (slots, len) = unsafe { + crate::object::keys_array_dense_slots( + r.keys as usize as *const crate::array::ArrayHeader, + ) + }; + if slots.is_null() || len < r.logical_key_count as usize { + return; + } + for slot in 0..r.logical_key_count as usize { + let mut short = [0; crate::value::SHORT_STRING_MAX_LEN]; + let Some(bytes) = (unsafe { + crate::string::js_string_key_bytes( + crate::JSValue::from_bits((*slots.add(slot)).to_bits()), + &mut short, + ) + }) else { + return; + }; + names.push(bytes.to_vec()); + } seed.push(SeedRecord { id, - keys: r.keys, + names, logical_key_count: r.logical_key_count, live_inline_slot_count: r.live_inline_slot_count, proto_id: r.proto_id, rep: r.rep, + infos: r.constfn_infos().to_vec(), + to_any: r.deprecation_targets().1, }); } }); @@ -59,14 +89,18 @@ pub(crate) fn worker_shape_seed() -> WorkerShapeSeed { /// outlined allocator its exact-descriptor fallback. pub(crate) fn install_worker_shape_seed(seed: &WorkerShapeSeed) { for r in seed.0.iter() { - let _ = super::shapes_slot_list::install_external_shape_id( + let names: Vec<&[u8]> = r.names.iter().map(Vec::as_slice).collect(); + let keys = unsafe { crate::object::static_shapes::canonical_keys_for_names(&names) }; + let _ = super::shapes_slot_list::install_external_shape_id_with_constfn( r.id, - r.keys as usize as *const crate::array::ArrayHeader, + keys.arr(), r.logical_key_count, r.live_inline_slot_count, r.proto_id, ShapeObjectKind::Ordinary, r.rep, + &r.infos, + r.to_any, ); } } @@ -121,4 +155,73 @@ mod tests { "the worker must hold id {id} with its compiled rep" ); } + + /// The outlined compiled allocator receives the spawning image's keys + /// global even though the worker rebuilt the same ShapeId with its own + /// canonical array. It must allocate from the local descriptor, without + /// evaluating the producer module or reading the foreign array's header. + #[test] + fn outlined_worker_birth_uses_the_seeded_local_keys_instead_of_the_spawners_global() { + const WORKER_CID: u32 = 0x5119; + std::thread::spawn(|| { + crate::object::class_image::enter_current_thread_image(); + let rep = REP_F64; + let source_keys = + crate::object::js_build_class_keys_array(WORKER_CID, 2, b"x\0m\0".as_ptr(), 4, rep) + as usize; + let id = js_object_shape_id_for_class_keys(source_keys as u64, 2, WORKER_CID, rep); + let image = crate::object::class_image::current_image_handle(); + let seed = super::worker_shape_seed(); + std::thread::spawn(move || { + crate::object::class_image::adopt_image(image); + let worker_agent = crate::agent::enter_worker_agent(); + crate::gc::ensure_gc_initialized(); + super::install_worker_shape_seed(&seed); + let descriptor = shape_descriptor_by_id(id).expect("seeded birth shape"); + assert_ne!(descriptor.keys as usize, source_keys); + assert!( + unsafe { crate::value::addr_class::try_read_tracked_gc_header(source_keys) } + .is_none(), + "the source array must belong to another arena" + ); + let handles = crate::gc::RuntimeHandleScope::new(); + let key = crate::string::js_string_from_bytes(b"x".as_ptr(), 1); + let key = handles.root_raw_mut_ptr(key); + let object = crate::object::js_object_alloc_class_inline_keys_stamped( + WORKER_CID, + 0, + 2, + source_keys as *mut crate::array::ArrayHeader, + id, + rep, + ); + let object = handles.root_raw_mut_ptr(object); + assert_eq!( + unsafe { crate::object::shapes::object_shape_stamp(object.get_raw_mut_ptr()) }, + id, + "the first outlined birth must retain the seeded id" + ); + crate::object::js_object_set_field_by_name( + object.get_raw_mut_ptr(), + key.get_raw_const_ptr(), + 4.0, + ); + assert_eq!( + crate::object::js_object_get_field_by_name( + object.get_raw_const_ptr(), + key.get_raw_const_ptr(), + ) + .as_number(), + 4.0 + ); + assert_eq!(shape_descriptor_by_id(id).expect("birth shape").rep, rep); + drop(handles); + crate::agent::retire_agent(worker_agent); + }) + .join() + .expect("worker panicked"); + }) + .join() + .expect("spawner panicked"); + } } diff --git a/crates/perry-runtime/src/object/static_shapes.rs b/crates/perry-runtime/src/object/static_shapes.rs index 1bb2156564..a45d7c0912 100644 --- a/crates/perry-runtime/src/object/static_shapes.rs +++ b/crates/perry-runtime/src/object/static_shapes.rs @@ -104,15 +104,355 @@ pub extern "C" fn js_shape_seed_plain( id } +/// LLVM's `{ i32, ptr }` entry in the generated static seed unit. The +/// pointer names the defining body's one image-owned `JsFunctionInfo`, not a +/// closure object; fresh factory closures with the same body share it. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ConstFnStaticEntry { + pub slot: u32, + pub info: *const crate::closure::JsFunctionInfo, +} + +/// Seed final shape facts before user code. This never allocates or stamps an +/// object; only the post-construction finalizer may publish these facts on a +/// receiver. All entries must name a permanent executable image. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "C" fn js_shape_seed_plain_constfn( + requested: u32, + packed: *const u8, + packed_len: u32, + count: u32, + live: u32, + rep: u64, + entries: *const ConstFnStaticEntry, + entry_count: u32, +) -> u32 { + if requested == 0 || count == 0 || packed.is_null() || packed_len == 0 { + invalid_constfn_static_seed(); + } + // SAFETY: compiler-owned static data of `packed_len` bytes. + let bytes = unsafe { std::slice::from_raw_parts(packed, packed_len as usize) }; + let names: Vec<&[u8]> = bytes.split(|&b| b == 0).filter(|s| !s.is_empty()).collect(); + if names.len() != count as usize { + invalid_constfn_static_seed(); + } + let infos = checked_constfn_static_entries(entries, entry_count); + let keys = unsafe { canonical_keys_for_names(&names) }; + let id = shapes::publish_shape_result(shapes::final_shape_ensure_constfn( + keys.arr(), + keys.count(), + live, + 0, + rep, + &infos, + Some(requested), + )); + // SAFETY: `id` was resolved from this agent's live slab record above. + unsafe { shapes::note_external_shape_carrier(shapes::shape_descriptor_by_id(id)) }; + note_static_request("seed-constfn", requested, id); + id +} + +/// Mint final class/literal facts without stamping an object. Class +/// registration seeds these facts separately from its Any/F64 allocation +/// shape, after all prototype registrations; `requested == 0` requests a dynamic id. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "C" fn js_object_final_shape_id_for_class_keys_static_constfn( + keys: u64, + key_count: u32, + live: u32, + class_id: u32, + requested: u32, + rep: u64, + entries: *const ConstFnStaticEntry, + entry_count: u32, +) -> u32 { + let infos = checked_constfn_static_entries(entries, entry_count); + let id = shapes::publish_shape_result(shapes::final_shape_ensure_constfn( + keys as usize as *const ArrayHeader, + key_count, + live, + class_id, + rep, + &infos, + Some(requested).filter(|&id| id != 0), + )); + // SAFETY: `id` was resolved from this agent's live slab record above. + unsafe { shapes::note_external_shape_carrier(shapes::shape_descriptor_by_id(id)) }; + note_static_request("class-constfn", requested, id); + id +} + +/// Promote a completed ordinary object, never an allocation, to a static +/// ConstFn shape. Refusals leave the receiver untouched. Return the refreshed +/// object handle because minting/setup may move it. Keys, body infos and the +/// requested id are compiler-owned permanent-image facts. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "C" fn js_object_finalize_constfn_static( + object: u64, + requested: u32, + packed: *const u8, + packed_len: u32, + count: u32, + live: u32, + class_id: u32, + rep: u64, + entries: *const ConstFnStaticEntry, + entry_count: u32, +) -> u64 { + finalize_constfn_static( + object, + requested, + packed, + packed_len, + count, + live, + class_id, + rep, + entries, + entry_count, + false, + ) +} + +/// Thread reconstruction has completed all stores before this call. Unlike a +/// compiled allocation, its ordinary base rep may be Any; every F64 lane is +/// therefore validated from the current receiver before publishing final facts. +#[allow(clippy::too_many_arguments)] +pub(crate) fn finalize_constfn_static( + object: u64, + requested: u32, + packed: *const u8, + packed_len: u32, + count: u32, + live: u32, + class_id: u32, + rep: u64, + entries: *const ConstFnStaticEntry, + entry_count: u32, + rebuilt: bool, +) -> u64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let root = scope.root_raw_mut_ptr(object as usize as *mut super::ObjectHeader); + let Some(infos) = parse_constfn_static_entries(entries, entry_count) else { + return root.get_raw_mut_ptr::() as usize as u64; + }; + if packed.is_null() || packed_len == 0 || count == 0 || live < count { + return root.get_raw_mut_ptr::() as usize as u64; + } + // SAFETY: compiler-owned bytes for this call, containing exact key names. + let packed = unsafe { std::slice::from_raw_parts(packed, packed_len as usize) }; + let obj = root.get_raw_mut_ptr::(); + let Some(current) = (unsafe { + finalized_constfn_facts(obj, packed, count, live, class_id, rep, &infos, rebuilt) + }) else { + return obj as usize as u64; + }; + // This mint does not canonicalize/allocate GC keys or enter JS. Its + // summary/hash/slab path allocates only Rust-owned Box/Vec storage and + // contains no safepoint or deferred-GC lock drop. The raw keys argument + // is therefore consumed without collection; the rooted object owns its + // keys throughout. A collecting interner must root/reload the argument + // inside the mint, not rely on the validation below. + let minted = shapes::final_shape_ensure_constfn( + current.keys as usize as *const ArrayHeader, + count, + live, + class_id, + rep, + &infos, + Some(requested).filter(|&id| id != 0), + ); + let obj = root.get_raw_mut_ptr::(); + // Reload and revalidate after the mint; no closure address spans it. + if let Some(current) = + unsafe { finalized_constfn_facts(obj, packed, count, live, class_id, rep, &infos, rebuilt) } + { + if let Ok(id) = minted { + // The interner compares every fact on its by-facts hit and aborts + // a conflicting requested-id adoption. Still check the complete + // record here, including learned deprecation, before publication. + if shapes::shape_descriptor_by_id(id).is_some_and(|d| { + d.keys == current.keys + && d.logical_key_count == count + && d.live_inline_slot_count == live + && d.proto_id == current.proto_id + && d.object_kind == current.object_kind + && d.semantic_generation == 0 + && d.hole_count == 0 + && d.summary == 0 + && d.rep == rep + && d.deprecation_targets() == (0, 0) + && d.special_constfn_mask + == infos.iter().fold(0, |mask, i| mask | (1 << i.slot)) + && d.constfn_infos() == infos + }) { + unsafe { shapes::stamp_object_shape_id_with_carrier_note(obj, id) }; + note_static_request("finalized-constfn", requested, id); + } + } + } + obj as usize as u64 +} + +/// No getter, proxy, or user code runs during this validation. All slots are +/// read from the current rooted receiver and must be ordinary inline data. +unsafe fn finalized_constfn_facts( + obj: *mut super::ObjectHeader, + packed: &[u8], + count: u32, + live: u32, + class_id: u32, + rep: u64, + infos: &[shapes::ConstFnSlotInfo], + rebuilt: bool, +) -> Option { + if obj.is_null() || !shapes::shape_word_is_writable(obj) || !(*obj).meta.is_null() { + return None; + } + let current = shapes::object_shape_descriptor(obj)?; + if !current.object_kind.is_ordinary_layout() + || current.logical_key_count != count + || current.live_inline_slot_count != live + || current.summary != 0 + || current.hole_count != 0 + || current.semantic_generation != 0 + || current.proto_id != shapes::class_proto_id(class_id) + || (!rebuilt + && current.rep + != infos.iter().fold(rep, |r, i| { + super::field_rep::with_slot_rep(r, i.slot as u32, super::field_rep::REP_ANY) + })) + || current.special_constfn_mask != 0 + { + return None; + } + let names: Vec<&[u8]> = packed.strip_suffix(&[0])?.split(|&b| b == 0).collect(); + if names.len() != count as usize || names.iter().any(|n| n.is_empty()) { + return None; + } + let (keys, len) = super::keys_array_dense_slots(current.keys as usize as *const ArrayHeader); + if keys.is_null() || len < count as usize { + return None; + } + for (slot, name) in names.iter().enumerate() { + let mut short = [0; crate::value::SHORT_STRING_MAX_LEN]; + if crate::string::js_string_key_bytes( + crate::JSValue::from_bits((*keys.add(slot)).to_bits()), + &mut short, + )? != *name + { + return None; + } + } + let fields = (obj as *const u8).add(std::mem::size_of::()) as *const u64; + if rebuilt { + if super::field_rep::has_deprecated(rep) { + return None; + } + for slot in 0..count.min(super::field_rep::REP_SLOTS) { + if super::field_rep::slot_rep(rep, slot) == super::field_rep::REP_F64 { + let bits = *fields.add(slot as usize); + if super::field_rep::f64_slot_bits(bits) != Some(bits) { + return None; + } + } + } + } + for entry in infos { + if entry.slot as u32 >= count + || super::field_rep::slot_rep(rep, entry.slot as u32) != super::field_rep::REP_SPECIAL + { + return None; + } + let bits = *fields.add(entry.slot as usize); + if super::field_rep_store::constfn_store_info(bits)? != entry.info { + return None; + } + let closure = + (bits & crate::value::POINTER_MASK) as usize as *const crate::closure::ClosureHeader; + let info = &*(*closure).info; + // Arity is checked at method-site prime time. Every closure-specific + // direct-call exclusion is checked now before making the shape claim. + if info.code.is_null() + || !matches!( + crate::closure::resolve_strategy(info).kind(), + crate::closure::DispatchKind::Arity(_) + ) + || super::class_registry::is_class_object_value(f64::from_bits(bits)) + || super::global_this::is_function_prototype_object_value(f64::from_bits(bits)) + || super::native_module::bound_native_callable_module_and_method(f64::from_bits(bits)) + .is_some() + || info.code == super::global_this::global_this_builtin_noop_thunk as *const u8 + || info.code == super::global_this::global_this_array_thunk as *const u8 + { + return None; + } + } + Some(current) +} + +fn checked_constfn_static_entries( + entries: *const ConstFnStaticEntry, + entry_count: u32, +) -> Vec { + parse_constfn_static_entries(entries, entry_count) + .unwrap_or_else(|| invalid_constfn_static_seed()) +} + +fn parse_constfn_static_entries( + entries: *const ConstFnStaticEntry, + entry_count: u32, +) -> Option> { + if entries.is_null() || entry_count == 0 || entry_count > super::field_rep::REP_SLOTS { + return None; + } + // SAFETY: compiler-owned static data of `entry_count` ABI entries. + let entries = unsafe { std::slice::from_raw_parts(entries, entry_count as usize) }; + let mut infos = Vec::with_capacity(entries.len()); + let mut previous = None; + for entry in entries { + if entry.slot >= super::field_rep::REP_SLOTS + || previous.is_some_and(|slot| entry.slot <= slot) + || entry.info.is_null() + { + return None; + } + // SAFETY: codegen names a `JsFunctionInfo` in its permanent image. + let info = unsafe { &*entry.info }; + if info.flags & crate::codegen_abi::FN_PERMANENT_IMAGE == 0 { + return None; + } + infos.push(shapes::ConstFnSlotInfo { + slot: entry.slot as u8, + info: entry.info as usize as u64, + }); + previous = Some(entry.slot); + } + Some(infos) +} + +#[cold] +fn invalid_constfn_static_seed() -> ! { + eprintln!("Perry internal error: invalid ConstFn static seed facts"); + std::process::abort(); +} + /// The static hit census (`PERRY_STATIC_SHAPE_CENSUS=1`): one stderr line per /// static-id request — which mint (`seed`, `class`, `typed`), the id /// requested, the id the mint returned. `requested != got` is a static MISS: /// the facts were already minted under another id, so births of this content /// stamp `got` while the guards compare the immediate `requested`. Requests -/// happen only at seeds and module init, so the gate costs one load there. +/// happen at seeds, module init and completed ConstFn reconstruction. A +/// reconstruction requests 0 (lookup by facts), so it must still report the +/// actual published id; other unassigned requests remain outside the census. pub(crate) fn note_static_request(path: &str, requested: u32, got: u32) { static CENSUS_ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); - if requested != 0 + if (requested != 0 || path == "finalized-constfn") && *CENSUS_ENABLED.get_or_init(|| std::env::var_os("PERRY_STATIC_SHAPE_CENSUS").is_some()) { let verdict = if requested == got { "hit" } else { "miss" }; @@ -186,6 +526,32 @@ static KEEP_JS_OBJECT_SHAPE_ID_FOR_CLASS_KEYS_STATIC: extern "C" fn( static KEEP_JS_SHAPE_SEED_PLAIN: extern "C" fn(u32, *const u8, u32, u32, u32, u64) -> u32 = js_shape_seed_plain; +#[cfg(feature = "keepalive-anchors")] +#[used(compiler)] +static KEEP_JS_SHAPE_SEED_PLAIN_CONSTFN: extern "C" fn( + u32, + *const u8, + u32, + u32, + u32, + u64, + *const ConstFnStaticEntry, + u32, +) -> u32 = js_shape_seed_plain_constfn; + +#[cfg(feature = "keepalive-anchors")] +#[used(compiler)] +static KEEP_JS_OBJECT_FINAL_SHAPE_ID_FOR_CLASS_KEYS_STATIC_CONSTFN: extern "C" fn( + u64, + u32, + u32, + u32, + u32, + u64, + *const ConstFnStaticEntry, + u32, +) -> u32 = js_object_final_shape_id_for_class_keys_static_constfn; + #[cfg(feature = "keepalive-anchors")] #[used(compiler)] static KEEP_JS_SHAPE_REGISTER_STATIC_SEED: extern "C" fn(extern "C" fn()) = @@ -198,3 +564,22 @@ static KEEP_JS_SHAPE_RUN_STATIC_SEED: extern "C" fn() = js_shape_run_static_seed #[cfg(test)] #[path = "static_shapes_tests.rs"] mod tests; + +#[cfg(feature = "keepalive-anchors")] +#[used(compiler)] +static KEEP_JS_OBJECT_FINALIZE_CONSTFN_STATIC: extern "C" fn( + u64, + u32, + *const u8, + u32, + u32, + u32, + u32, + u64, + *const ConstFnStaticEntry, + u32, +) -> u64 = js_object_finalize_constfn_static; + +#[cfg(all(test, target_os = "linux"))] +#[path = "constfn_unload_tests.rs"] +mod unload_tests; diff --git a/crates/perry-runtime/src/object/static_shapes_tests.rs b/crates/perry-runtime/src/object/static_shapes_tests.rs index adf026d852..29ce622e5a 100644 --- a/crates/perry-runtime/src/object/static_shapes_tests.rs +++ b/crates/perry-runtime/src/object/static_shapes_tests.rs @@ -23,6 +23,131 @@ fn seed_with_rep(requested: u32, names: &[&str], rep: u64) -> u32 { ) } +// Literal fixtures use the same premarking allocation entry as production +// plain records. The legacy shape-cache allocator alone is classless but +// OrdinaryUnmarked, so it cannot establish a ConstFn promotion premise. +fn alloc_constfn_plain_fixture(names: &[&[u8]]) -> *mut crate::object::ObjectHeader { + let keys = unsafe { canonical_keys_for_names(names) }; + let obj = crate::object::alloc_plain::alloc_plain_record_with_keys(names.len() as u32, keys); + let id = unsafe { shapes::object_shape_stamp(obj) }; + let birth = shapes::shape_descriptor_by_id(id).expect("plain fixture birth descriptor"); + assert_eq!(birth.object_kind, shapes::ShapeObjectKind::Ordinary); + assert_eq!(birth.proto_id, 0); + assert_eq!(birth.logical_key_count, names.len() as u32); + assert_eq!(birth.live_inline_slot_count, names.len() as u32); + assert_eq!(birth.rep, crate::object::field_rep::REP_ANY); + assert_eq!(birth.semantic_generation, 0); + assert_eq!(birth.hole_count, 0); + assert_eq!(birth.summary, 0); + assert_eq!(birth.special_constfn_mask, 0); + obj +} + +extern "C" fn seeded_constfn_body_a( + _closure: *const crate::closure::ClosureHeader, + _this: crate::closure::JsThis, +) -> f64 { + 11.0 +} + +extern "C" fn seeded_constfn_body_b( + _closure: *const crate::closure::ClosureHeader, + _this: crate::closure::JsThis, +) -> f64 { + 22.0 +} + +/// The seed and module-init class mint must use exactly the same body-aware +/// interner. Production literal finalization uses the same final facts. +#[test] +fn constfn_static_seed_and_module_init_mint_have_identical_facts() { + use crate::object::field_rep::{with_slot_rep, REP_SPECIAL}; + let _lock = crate::gc::global_side_table_test_lock(); + let info_a = crate::fn_info!(seeded_constfn_body_a, 0; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)); + let info_b = crate::fn_info!(seeded_constfn_body_b, 0; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)); + let a = [ConstFnStaticEntry { + slot: 0, + info: info_a, + }]; + let b = [ConstFnStaticEntry { + slot: 0, + info: info_b, + }]; + let rep = with_slot_rep(0, 0, REP_SPECIAL); + let packed = b"lt5cf_method\0"; + let requested = SHAPE_ID_BASE + 0x7860; + let seeded = js_shape_seed_plain_constfn( + requested, + packed.as_ptr(), + packed.len() as u32, + 1, + 1, + rep, + a.as_ptr(), + 1, + ); + assert_eq!(seeded, requested); + let keys = unsafe { canonical_keys_for_names(&[b"lt5cf_method"]) }; + assert_eq!( + js_object_final_shape_id_for_class_keys_static_constfn( + keys.arr() as usize as u64, + 1, + 1, + 0, + requested, + rep, + a.as_ptr(), + 1, + ), + seeded, + "literal seed and module init must find one shape" + ); + let d = shapes::shape_descriptor_by_id(seeded).expect("seeded ConstFn shape"); + assert_eq!(d.constfn_infos()[0].info, info_a as usize as u64); + assert_eq!(d.special_constfn_mask, 1); + assert!(is_carrier(seeded)); + let other = js_object_final_shape_id_for_class_keys_static_constfn( + keys.arr() as usize as u64, + 1, + 1, + 0, + SHAPE_ID_BASE + 0x7861, + rep, + b.as_ptr(), + 1, + ); + assert_ne!(other, seeded, "another body is another shape identity"); +} + +#[test] +fn constfn_seed_entry_parser_rejects_transient_duplicate_and_unsorted_bodies() { + let permanent = crate::fn_info!(seeded_constfn_body_a, 0; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)); + let transient = crate::fn_info!(seeded_constfn_body_b, 0); + let good = [ConstFnStaticEntry { + slot: 0, + info: permanent, + }]; + assert!(parse_constfn_static_entries(good.as_ptr(), 1).is_some()); + let bad = [ConstFnStaticEntry { + slot: 0, + info: transient, + }]; + assert!(parse_constfn_static_entries(bad.as_ptr(), 1).is_none()); + let duplicate = [good[0], good[0]]; + assert!(parse_constfn_static_entries(duplicate.as_ptr(), 2).is_none()); + let unsorted = [ + ConstFnStaticEntry { + slot: 1, + info: permanent, + }, + good[0], + ]; + let sorted = [good[0], unsorted[0]]; + assert!(parse_constfn_static_entries(sorted.as_ptr(), 2).is_some()); + assert!(parse_constfn_static_entries(unsorted.as_ptr(), 2).is_none()); + assert!(parse_constfn_static_entries(std::ptr::null(), 0).is_none()); +} + fn is_carrier(id: u32) -> bool { shapes::test_shape_record_is_carrier(id) } @@ -343,3 +468,319 @@ fn a_class_registered_before_the_pools_answers_the_megamorphic_confirm() { ); assert!(!unsafe { confirmed(id, x, 1) }); } + +/// The finalizer sees a partly built object on Any, refuses an unwritten +/// method, and only promotes after every store. Fresh captured closures share +/// the seeded shape while preserving the receiver's current closure slot. +#[test] +fn constfn_finalizer_waits_for_stores_and_preserves_fresh_closures() { + use crate::object::shapes::object_shape_stamp; + let _lock = crate::gc::global_side_table_test_lock(); + let info = crate::fn_info!(seeded_constfn_body_a, 0; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)); + let entries = [ConstFnStaticEntry { slot: 0, info }]; + let packed = b"ltcf_final_m\0ltcf_final_x\0"; + let requested = SHAPE_ID_BASE + 0x7870; + let final_id = js_shape_seed_plain_constfn( + requested, + packed.as_ptr(), + packed.len() as u32, + 2, + 2, + 3, + entries.as_ptr(), + 1, + ); + assert_eq!(final_id, requested); + let scope = crate::gc::RuntimeHandleScope::new(); + let finalize = |obj: u64| { + js_object_finalize_constfn_static( + obj, + requested, + packed.as_ptr(), + packed.len() as u32, + 2, + 2, + 0, + 3, + entries.as_ptr(), + 1, + ) + }; + let mut objects = Vec::new(); + let mut closures = Vec::new(); + for capture in [11.0f64, 22.0] { + let raw = alloc_constfn_plain_fixture(&[b"ltcf_final_m", b"ltcf_final_x"]); + let object = scope.root_raw_mut_ptr(raw as usize as *mut crate::object::ObjectHeader); + let plain = + unsafe { object_shape_stamp(object.get_raw_mut_ptr::()) }; + assert_ne!(plain, requested, "allocation must not carry SPECIAL"); + assert_eq!(shapes::shape_descriptor_by_id(plain).unwrap().rep, 0); + assert_eq!(finalize(raw as usize as u64), raw as usize as u64); + assert_eq!( + unsafe { object_shape_stamp(object.get_raw_mut_ptr::()) }, + plain, + "unwritten method must refuse" + ); + let closure = crate::closure::js_closure_alloc(info, 1); + let closure = scope.root_raw_mut_ptr(closure); + unsafe { + crate::closure::js_closure_set_capture_bits( + closure.get_raw_mut_ptr::(), + 0, + capture.to_bits(), + ); + let obj = object.get_raw_mut_ptr::(); + crate::object::store_object_field_slot( + obj, + 0, + crate::JSValue::object_ptr( + closure.get_raw_mut_ptr::() as *mut u8, + ) + .bits(), + ); + crate::object::store_object_field_slot(obj, 1, 7.0f64.to_bits()); + } + let obj = finalize(object.get_raw_mut_ptr::() as usize as u64); + assert_eq!( + unsafe { object_shape_stamp(obj as usize as *mut _) }, + requested + ); + objects.push(object); + closures.push(closure); + } + assert_ne!( + closures[0].get_raw_mut_ptr::(), + closures[1].get_raw_mut_ptr::() + ); + for ((object, closure), capture) in objects.iter().zip(&closures).zip([11.0f64, 22.0]) { + let obj = object.get_raw_mut_ptr::(); + let slot = crate::object::js_object_get_field(obj, 0); + assert_eq!( + slot.bits() & crate::value::POINTER_MASK, + closure.get_raw_mut_ptr::() as usize as u64 + ); + assert_eq!( + crate::closure::js_closure_get_capture_bits( + (slot.bits() & crate::value::POINTER_MASK) as usize as *const _, + 0 + ), + capture.to_bits(), + "current receiver closure must preserve its own capture" + ); + } +} + +#[test] +fn constfn_finalizer_refuses_wrong_body_layout_and_rebindable_this() { + let _lock = crate::gc::global_side_table_test_lock(); + let info = crate::fn_info!(seeded_constfn_body_a, 0; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)); + let other = crate::fn_info!(seeded_constfn_body_b, 0; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)); + let entries = [ConstFnStaticEntry { slot: 0, info }]; + let packed = b"ltcf_refuse_m\0"; + let scope = crate::gc::RuntimeHandleScope::new(); + // Establish that these exact birth facts promote with the supported body. + // Otherwise every refusal below could be an unrelated kind/layout miss. + let control = scope.root_raw_mut_ptr(alloc_constfn_plain_fixture(&[b"ltcf_refuse_m"])); + let birth = unsafe { shapes::object_shape_stamp(control.get_raw_mut_ptr()) }; + let closure = scope.root_raw_mut_ptr(crate::closure::js_closure_alloc(info, 0)); + unsafe { + crate::object::store_object_field_slot( + control.get_raw_mut_ptr(), + 0, + crate::JSValue::object_ptr(closure.get_raw_mut_ptr::()).bits(), + ); + } + let promoted = js_object_finalize_constfn_static( + control.get_raw_mut_ptr::() as usize as u64, + SHAPE_ID_BASE + 0x7871, + packed.as_ptr(), + packed.len() as u32, + 1, + 1, + 0, + 3, + entries.as_ptr(), + 1, + ); + assert_eq!( + unsafe { shapes::object_shape_stamp(promoted as usize as *mut _) }, + SHAPE_ID_BASE + 0x7871, + "the control must actually finalize before testing refusals" + ); + assert_ne!(birth, SHAPE_ID_BASE + 0x7871); + for (case, body, caps, count, live, class_id, rep) in [ + ("wrong body", other, 0, 1, 1, 0, 3), + ( + "rebindable this", + info, + crate::closure::CAPTURES_THIS_FLAG | 1, + 1, + 1, + 0, + 3, + ), + ("wrong key count", info, 0, 2, 2, 0, 3), + ("wrong live bound", info, 0, 1, 2, 0, 3), + ("wrong prototype", info, 0, 1, 1, 0x7844, 3), + ("wrong representation", info, 0, 1, 1, 0, 7), + ] { + let raw = alloc_constfn_plain_fixture(&[b"ltcf_refuse_m"]); + let obj = scope.root_raw_mut_ptr(raw as usize as *mut crate::object::ObjectHeader); + let closure = crate::closure::js_closure_alloc(body, caps); + unsafe { + crate::object::store_object_field_slot( + obj.get_raw_mut_ptr::(), + 0, + crate::JSValue::object_ptr(closure as *mut u8).bits(), + ); + } + let before = unsafe { + shapes::object_shape_stamp(obj.get_raw_mut_ptr::()) + }; + assert_eq!( + before, birth, + "{case}: refusal must begin with the admitted control birth" + ); + let raw = js_object_finalize_constfn_static( + obj.get_raw_mut_ptr::() as usize as u64, + SHAPE_ID_BASE + 0x7871, + packed.as_ptr(), + packed.len() as u32, + count, + live, + class_id, + rep, + entries.as_ptr(), + 1, + ); + assert_eq!( + unsafe { shapes::object_shape_stamp(raw as usize as *mut _) }, + before, + "{case}: refusal must leave receiver unchanged" + ); + } +} + +extern "C" fn seeded_constfn_capture_body( + closure: *const crate::closure::ClosureHeader, + _this: crate::closure::JsThis, +) -> f64 { + f64::from_bits(crate::closure::js_closure_get_capture_bits(closure, 0)) +} + +#[test] +fn declared_class_final_mint_keeps_birth_ordinary_and_uses_each_current_closure() { + let _lock = crate::gc::global_side_table_test_lock(); + let scope = crate::gc::RuntimeHandleScope::new(); + let cid = 0x16cfa011; + unsafe { + crate::object::js_register_class_id(cid); + } + let packed = b"ltcf_class_m\0ltcf_class_x\0"; + let info = crate::fn_info!(seeded_constfn_capture_body, 0; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)); + let entries = [ConstFnStaticEntry { slot: 0, info }]; + let keys = + crate::object::js_build_class_keys_array(cid, 2, packed.as_ptr(), packed.len() as u32, 0); + let keys = scope.root_raw_mut_ptr(keys as usize as *mut crate::array::ArrayHeader); + let ordinary = js_object_shape_id_for_class_keys_static( + keys.get_raw_mut_ptr::() as usize as u64, + 2, + 2, + cid, + SHAPE_ID_BASE + 0x7880, + 0, + ); + let final_id = js_object_final_shape_id_for_class_keys_static_constfn( + keys.get_raw_mut_ptr::() as usize as u64, + 2, + 2, + cid, + SHAPE_ID_BASE + 0x7881, + 3, + entries.as_ptr(), + 1, + ); + assert_ne!(ordinary, final_id); + assert_eq!(shapes::shape_descriptor_by_id(ordinary).unwrap().rep, 0); + assert_eq!(shapes::shape_descriptor_by_id(final_id).unwrap().rep, 3); + let mut closure_roots = Vec::new(); + for capture in [31.0f64, 47.0] { + let obj = crate::object::js_object_alloc_class_inline_keys_stamped( + cid, + 0, + 2, + keys.get_raw_mut_ptr::(), + ordinary, + 0, + ); + let object = scope.root_raw_mut_ptr(obj); + assert_eq!( + unsafe { shapes::object_shape_stamp(object.get_raw_mut_ptr()) }, + ordinary + ); + let closure = scope.root_raw_mut_ptr(crate::closure::js_closure_alloc(info, 1)); + unsafe { + crate::closure::js_closure_set_capture_bits( + closure.get_raw_mut_ptr(), + 0, + capture.to_bits(), + ); + let object = object.get_raw_mut_ptr::(); + crate::object::store_object_field_slot( + object, + 0, + crate::JSValue::object_ptr(closure.get_raw_mut_ptr::()).bits(), + ); + crate::object::store_object_field_slot(object, 1, capture.to_bits()); + } + let premise = shapes::shape_descriptor_by_id(ordinary).unwrap(); + assert_eq!(premise.object_kind, shapes::ShapeObjectKind::Ordinary); + assert_eq!(premise.proto_id, shapes::class_proto_id(cid)); + let wrong_proto = js_object_finalize_constfn_static( + object.get_raw_mut_ptr::() as usize as u64, + final_id, + packed.as_ptr(), + packed.len() as u32, + 2, + 2, + cid + 1, + 3, + entries.as_ptr(), + 1, + ); + assert_eq!( + unsafe { shapes::object_shape_stamp(wrong_proto as usize as *mut _) }, + ordinary, + "a different class prototype must refuse without stamping" + ); + let obj = js_object_finalize_constfn_static( + object.get_raw_mut_ptr::() as usize as u64, + final_id, + packed.as_ptr(), + packed.len() as u32, + 2, + 2, + cid, + 3, + entries.as_ptr(), + 1, + ); + assert_eq!( + unsafe { shapes::object_shape_stamp(obj as usize as *mut _) }, + final_id + ); + let current = crate::object::js_object_get_field(obj as usize as *mut _, 0); + assert_eq!( + crate::closure::js_closure_call0( + (current.bits() & crate::value::POINTER_MASK) as usize as *const _, + crate::closure::JsThis::UNDEFINED, + ), + capture + ); + closure_roots.push(closure); + } + assert_ne!( + closure_roots[0].get_raw_mut_ptr::(), + closure_roots[1].get_raw_mut_ptr::() + ); +} diff --git a/crates/perry-runtime/src/proxy/put_value.rs b/crates/perry-runtime/src/proxy/put_value.rs index 326cc3d00f..914ad7a562 100644 --- a/crates/perry-runtime/src/proxy/put_value.rs +++ b/crates/perry-runtime/src/proxy/put_value.rs @@ -1264,6 +1264,14 @@ pub extern "C" fn js_put_value_set_dyn_ic_miss( let Some(idx) = own_idx else { return result; }; + // Generated own-slot hits write raw bits. A ConstFn target must + // keep using the checked store funnel so an incompatible overwrite + // invalidates its body fact before changing the slot. + if crate::object::field_rep::slot_rep(shape.rep, idx) + == crate::object::field_rep::REP_SPECIAL + { + return result; + } // The descriptor above already proves this stamp is live, so the // token comes from the header word rather than from a second full // lookup-and-copy of the same id (see `dyn_ic_try_store`). @@ -1625,6 +1633,7 @@ fn object_array_numeric_write_slots( unsafe { validated_object(first_bits) }, "first receiver is not an eligible regular shared-shape object", )?; + let shared_rep = crate::object::field_rep_store::shape_rep(shared_shape_id); let mut slots = [0u16; 4]; for index in 0..keys.len() { // `find_slot` caps the shared keys array at 4096 entries, so every @@ -1633,6 +1642,15 @@ fn object_array_numeric_write_slots( unsafe { find_slot(shared_keys, shared_key_count, decoded_keys[index]) }, "target key is absent from the shared shape", )?; + // A finite Number preserves Any/F64, but contradicts a ConstFn body + // fact. The clone writes without the checked slot funnel, so SPECIAL + // targets must take the ordinary loop before any slot is published. + if crate::object::field_rep::slot_rep(shared_rep, slot) + == crate::object::field_rep::REP_SPECIAL + { + trace_object_array_numeric_write_rejection("target slot carries a SPECIAL fact"); + return None; + } slots[index] = trace_object_array_numeric_write_stage( u16::try_from(slot).ok(), "target slot cannot be encoded", @@ -1892,3 +1910,7 @@ pub extern "C" fn js_object_array_numeric_write2_guard( }; (u64::from(slots[1]) + 1) << 32 | (u64::from(slots[0]) + 1) } + +#[cfg(test)] +#[path = "put_value/numeric_write_constfn_tests.rs"] +mod numeric_write_constfn_tests; diff --git a/crates/perry-runtime/src/proxy/put_value/cached_constfn_tests.rs b/crates/perry-runtime/src/proxy/put_value/cached_constfn_tests.rs new file mode 100644 index 0000000000..46c9758795 --- /dev/null +++ b/crates/perry-runtime/src/proxy/put_value/cached_constfn_tests.rs @@ -0,0 +1,251 @@ +//! Raw emitted stores may cache other slots of a completed ConstFn shape, +//! but a write to its SPECIAL slot must retain the checked store funnel. +use super::*; +use crate::object::shapes::{object_shape_stamp, shape_descriptor_by_id, ShapeObjectKind}; + +extern "C" fn body_a( + _closure: *const crate::closure::ClosureHeader, + _this: crate::closure::JsThis, +) -> f64 { + 11.0 +} + +extern "C" fn body_b( + _closure: *const crate::closure::ClosureHeader, + _this: crate::closure::JsThis, +) -> f64 { + 22.0 +} + +fn key(name: &[u8]) -> *const crate::StringHeader { + let hash = name.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, b| { + (h ^ u64::from(*b)).wrapping_mul(0x0000_0100_0000_01b3) + }); + let s = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + crate::string::js_string_intern(s, hash) +} + +fn birth() -> f64 { + // The plain-record birth entry with spare inline slots lets the fixture + // carry Any, ConstFn and F64 lanes together without forging a shape. + let keys = + unsafe { crate::object::static_shapes::canonical_keys_for_names(&[b"cached_cf_seed"]) }; + let obj = crate::object::alloc_plain::alloc_plain_record_with_keys(4, keys); + crate::object::js_object_set_field_by_name(obj, key(b"cached_cf_seed"), 1.0); + let value = crate::value::js_nanbox_pointer(obj as i64); + let d = shape_descriptor_by_id(stamp(value)).expect("plain birth descriptor"); + assert_eq!(d.object_kind, ShapeObjectKind::Ordinary); + assert_eq!(d.logical_key_count, 1); + assert!(d.live_inline_slot_count >= 4); + assert_eq!(d.rep, crate::object::field_rep::REP_ANY); + value +} + +fn object(value: f64) -> *mut crate::ObjectHeader { + (value.to_bits() & POINTER_MASK) as *mut crate::ObjectHeader +} + +fn stamp(value: f64) -> u32 { + unsafe { object_shape_stamp(object(value)) } +} + +fn closure(other: bool) -> f64 { + let info = if other { + crate::fn_info!(body_b, 0; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)) + } else { + crate::fn_info!(body_a, 0; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)) + }; + crate::value::js_nanbox_pointer(crate::closure::js_closure_alloc(info, 0) as i64) +} + +fn completed(method: *const crate::StringHeader, value: f64) -> f64 { + let receiver = birth(); + crate::object::js_object_set_field_by_name(object(receiver), key(b"cached_cf_scalar"), 2.5); + crate::object::js_object_set_field_by_name(object(receiver), method, value); + let d = shape_descriptor_by_id(stamp(receiver)).expect("completed descriptor"); + assert_eq!(d.object_kind, ShapeObjectKind::Ordinary); + assert_eq!(d.special_constfn_mask, 1 << 2, "fixture minted ConstFn"); + assert!(crate::object::field_rep_store::shape_slot_is_f64( + stamp(receiver), + 1 + )); + assert_eq!(d.constfn_infos().len(), 1); + receiver +} + +fn assert_stored(receiver: f64, method: *const crate::StringHeader, value: f64) { + assert_eq!( + crate::object::js_object_get_field_by_name_f64(object(receiver), method).to_bits(), + value.to_bits(), + "the slot keeps this receiver's current closure" + ); +} + +#[test] +fn packed_set_refuses_special_but_keeps_other_slots_of_the_completed_shape() { + let _lock = crate::gc::global_side_table_test_lock(); + let _no_gc = crate::gc::GcSuppressScope::new(); + let method = key(b"cached_cf_packed_method"); + let a = closure(false); + let b = closure(false); + assert_ne!(a.to_bits(), b.to_bits(), "distinct current closures"); + let first = completed(method, a); + let second = completed(method, b); + let final_id = stamp(first); + assert_eq!(stamp(second), final_id, "fresh receivers share final id"); + let site: &'static PackedSetSite = Box::leak(Box::new(PackedSetSite::empty())); + let mut ways = packed_set_cache_empty(); + let mut slot: PackedSetWaysSlot = &mut ways; + unsafe { prime_packed_set(first, method, &mut slot, &site.set) }; + assert_eq!(site.set.load(Ordering::Relaxed), PACKED_SET_EMPTY); + assert!(ways[..PACKED_SET_WAYS] + .iter() + .all(|w| *w == PACKED_SET_EMPTY)); + + let same = closure(false); + js_put_value_set_packed_miss(first, method, same, 0, &mut slot, &site.set); + assert_eq!( + stamp(first), + final_id, + "generic same-body store preserves fact" + ); + assert_stored(first, method, same); + assert_eq!(site.set.load(Ordering::Relaxed), PACKED_SET_EMPTY); + + let scalar = key(b"cached_cf_scalar"); + crate::object::js_object_set_field_by_name(object(first), scalar, 2.5); + let mixed = stamp(first); + assert_eq!( + shape_descriptor_by_id(mixed).unwrap().special_constfn_mask, + 1 << 2 + ); + unsafe { prime_packed_set(first, scalar, &mut slot, &site.set) }; + let word = site.set.load(Ordering::Relaxed); + assert_eq!(word as u32, mixed, "F64 beside ConstFn still primes"); + assert_ne!(word & PACKED_SET_F64_SLOT, 0); + unsafe { prime_packed_set(first, key(b"cached_cf_seed"), &mut slot, &site.set) }; + assert_eq!(site.set.load(Ordering::Relaxed) as u32, mixed); + assert_eq!(site.set.load(Ordering::Relaxed) & PACKED_SET_F64_SLOT, 0); + + let replacement = closure(true); + js_put_value_set_packed_miss(second, method, replacement, 0, &mut slot, &site.set); + assert_ne!( + stamp(second), + final_id, + "different body cannot retain stale final id" + ); + assert_eq!( + shape_descriptor_by_id(stamp(second)) + .unwrap() + .special_constfn_mask, + 0 + ); + assert_stored(second, method, replacement); +} + +#[test] +fn packed_add_refuses_special_successor_before_any_memo_publication() { + let _lock = crate::gc::global_side_table_test_lock(); + let _no_gc = crate::gc::GcSuppressScope::new(); + let method = key(b"cached_cf_append_method"); + let first = birth(); + let second = birth(); + let pre = stamp(first); + assert_eq!(stamp(second), pre); + let site: &'static PackedSetSite = Box::leak(Box::new(PackedSetSite::empty())); + let mut slot: PackedSetWaysSlot = std::ptr::null_mut(); + let a = closure(false); + js_put_value_set_packed_miss(first, method, a, 0, &mut slot, &site.set); + let final_id = stamp(first); + assert_eq!( + shape_descriptor_by_id(final_id) + .unwrap() + .special_constfn_mask, + 1 << 1 + ); + assert_eq!(site.add_shapes.load(Ordering::Relaxed), PACKED_SET_EMPTY); + assert_eq!(site.add_guard.load(Ordering::Relaxed), 0); + assert_eq!(site.add_ways.load(Ordering::Relaxed), 0); + assert_eq!( + unsafe { super::super::packed_add::packed_add_try(site, second, a) }, + None + ); + assert_eq!(stamp(second), pre, "declining add changes nothing"); + + let b = closure(false); + js_put_value_set_packed_miss(second, method, b, 0, &mut slot, &site.set); + assert_eq!( + stamp(second), + final_id, + "same-body generic append shares final id" + ); + assert_stored(second, method, b); + let replacement = closure(true); + js_put_value_set_packed_miss(second, method, replacement, 0, &mut slot, &site.set); + assert_ne!(stamp(second), final_id); + assert_eq!( + shape_descriptor_by_id(stamp(second)) + .unwrap() + .special_constfn_mask, + 0 + ); + assert_stored(second, method, replacement); +} + +#[test] +fn dynamic_own_slot_primer_refuses_special_and_preserves_mixed_slots() { + let _lock = crate::gc::global_side_table_test_lock(); + let _no_gc = crate::gc::GcSuppressScope::new(); + let method = key(b"cached_cf_dynamic_method"); + let first = completed(method, closure(false)); + let current = closure(false); + let second = completed(method, current); + let final_id = stamp(first); + assert_eq!(stamp(second), final_id); + let mut cache: super::super::WritePicCache = [0; super::super::WRITE_PIC_WORDS]; + let mut slot: super::super::WritePicCacheSlot = &mut cache; + let store = |target, + key: *const crate::StringHeader, + value, + slot: *mut super::super::WritePicCacheSlot| { + super::super::js_put_value_set_dyn_ic_miss( + slot, + target, + f64::from_bits(crate::value::js_nanbox_string(key as i64).to_bits()), + value, + 0, + ) + }; + let same = closure(false); + store(first, method, same, &mut slot); + assert_eq!(stamp(first), final_id); + assert_eq!(cache[0], 0, "SPECIAL must not publish an own-slot token"); + assert_stored(first, method, same); + + let scalar = key(b"cached_cf_scalar"); + let mixed = stamp(first); + store(first, scalar, 4.5, &mut slot); + assert_eq!( + shape_descriptor_by_id(mixed).unwrap().special_constfn_mask, + 1 << 2 + ); + assert_eq!( + cache[0] as u64, + crate::object::shapes::PIC_ID_TOKEN_BIT | u64::from(mixed), + "ordinary F64 slot beside ConstFn still primes" + ); + store(first, key(b"cached_cf_seed"), 5.5, &mut slot); + assert_eq!(stamp(first), mixed, "unrelated stores keep the method fact"); + assert_stored(first, method, same); + + let replacement = closure(true); + store(second, method, replacement, &mut slot); + assert_ne!(stamp(second), final_id); + assert_eq!( + shape_descriptor_by_id(stamp(second)) + .unwrap() + .special_constfn_mask, + 0 + ); + assert_stored(second, method, replacement); +} diff --git a/crates/perry-runtime/src/proxy/put_value/numeric_write_constfn_tests.rs b/crates/perry-runtime/src/proxy/put_value/numeric_write_constfn_tests.rs new file mode 100644 index 0000000000..98c87dd51a --- /dev/null +++ b/crates/perry-runtime/src/proxy/put_value/numeric_write_constfn_tests.rs @@ -0,0 +1,144 @@ +//! The whole-loop guard licenses bare numeric stores, so every targeted +//! SPECIAL lane must decline while unrelated Any/F64 lanes remain usable. +use super::*; +use crate::object::field_rep::{with_slot_rep, REP_ANY, REP_F64, REP_SPECIAL}; +use crate::object::shapes; + +extern "C" fn body( + _closure: *const crate::closure::ClosureHeader, + _this: crate::closure::JsThis, +) -> f64 { + 17.0 +} + +fn boxed(ptr: *const u8) -> f64 { + f64::from_bits(POINTER_TAG | (ptr as u64 & POINTER_MASK)) +} + +/// Assert every exported ABI that draws its raw-store authority from the +/// same supplier. A refusal must leave the keytable's output buffer untouched. +fn assert_family(array: f64, keys: [f64; 2], lanes: u64) { + assert_eq!( + js_object_array_numeric_write_guard(array, keys[0], keys[1], 0.0, 0.0, 2, 2), + lanes + ); + assert_eq!( + js_object_array_numeric_write_range_guard(array, keys[0], keys[1], 0.0, 0.0, 2, 0, 2), + lanes + ); + let wide_lanes = (lanes & 0xffff) | (((lanes >> 16) & 0xffff) << 32); + assert_eq!( + js_object_array_numeric_write2_guard(array, keys[0], keys[1], 2), + wide_lanes + ); + let table = crate::array::js_array_from_f64(keys.as_ptr(), 2); + let mut output = [-7i64; 2]; + assert_eq!( + js_object_array_keytable_write_guard(array, boxed(table.cast()), 2, 2, output.as_mut_ptr()), + i32::from(lanes != 0) + ); + if lanes == 0 { + assert_eq!(output, [-7; 2]); + } else { + assert_eq!( + output, + [(lanes & 0xffff) as i64, ((lanes >> 16) & 0xffff) as i64] + ); + } +} + +#[test] +fn numeric_write_guards_refuse_constfn_targets_and_preserve_mixed_shapes() { + let _lock = crate::gc::global_side_table_test_lock(); + let _no_gc = crate::gc::GcSuppressScope::new(); + crate::object::descriptor_state::test_reset_class_field_inline_guard(); + const CLASS: u32 = 0x5c68_09; + let names = b"cf_numeric_method\0cf_numeric_any\0cf_numeric_f64\0"; + let keys = + crate::object::js_build_class_keys_array(CLASS, 3, names.as_ptr(), names.len() as u32, 0); + let base_rep = with_slot_rep(REP_ANY, 2, REP_F64); + let birth = shapes::class_birth_shape_ensure(keys, 3, 3, CLASS, base_rep, None).unwrap(); + let info = crate::fn_info!(body, 0; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)); + let final_rep = with_slot_rep(base_rep, 0, REP_SPECIAL); + let completed = shapes::final_shape_ensure_constfn( + keys, + 3, + 3, + CLASS, + final_rep, + &[shapes::ConstFnSlotInfo { + slot: 0, + info: info as usize as u64, + }], + None, + ) + .unwrap(); + let mut objects = Vec::new(); + let mut values = Vec::new(); + let mut method_bits = Vec::new(); + for number in [1.0f64, 2.0] { + let object = crate::object::js_object_alloc_class_inline_keys(CLASS, 0, 3, keys); + let closure = crate::closure::js_closure_alloc(info, 0); + let method = boxed(closure.cast()).to_bits(); + unsafe { + crate::object::store_object_field_slot(object, 0, method); + crate::object::store_object_field_slot(object, 1, number.to_bits()); + crate::object::store_object_field_slot(object, 2, number.to_bits()); + shapes::stamp_object_shape_id_with_carrier_note(object, birth); + } + objects.push(object); + values.push(boxed(object.cast())); + method_bits.push(method); + } + let array = crate::array::js_array_from_f64(values.as_ptr(), values.len() as u32); + let array_box = boxed(array.cast()); + let key = |index| { + let raw = crate::array::js_array_get(keys, index).as_string_ptr(); + f64::from_bits(crate::value::STRING_TAG | (raw as u64 & POINTER_MASK)) + }; + let method = key(0); + let any = key(1); + let f64_key = key(2); + assert_family(array_box, [method, any], 1 | (2 << 16)); + assert_family(array_box, [any, f64_key], 2 | (3 << 16)); + for object in &objects { + unsafe { shapes::stamp_object_shape_id_with_carrier_note(*object, completed) }; + } + let facts = shapes::shape_descriptor_by_id(completed).unwrap(); + assert_eq!(facts.object_kind, shapes::ShapeObjectKind::Ordinary); + assert_eq!( + facts.special_constfn_mask, 1, + "the refusal must exercise a live ConstFn shape" + ); + assert!( + crate::object::field_rep_store::final_shape_matches_birth(completed, birth), + "the completed shape remains compatible for read-only field access" + ); + assert_family(array_box, [method, any], 0); + assert_family(array_box, [any, method], 0); + assert_family(array_box, [any, f64_key], 2 | (3 << 16)); + for (index, object) in objects.iter().enumerate() { + assert_eq!(unsafe { shapes::object_shape_stamp(*object) }, completed); + let slot = unsafe { + (*object as *const u8).add(std::mem::size_of::()) as *const u64 + }; + assert_eq!( + unsafe { *slot }, + method_bits[index], + "preflight cannot change the closure" + ); + crate::object::js_object_set_field_by_name( + *object, + (method.to_bits() & POINTER_MASK) as *mut _, + 99.0, + ); + let now = unsafe { shapes::object_shape_stamp(*object) }; + assert_ne!( + now, completed, + "ordinary replacement must retire the body fact first" + ); + assert!(crate::object::field_rep_store::shape_slot_is_any(now, 0)); + assert_eq!(unsafe { *slot }, 99.0f64.to_bits()); + } + assert_family(array_box, [method, any], 1 | (2 << 16)); +} diff --git a/crates/perry-runtime/src/proxy/put_value/packed_add.rs b/crates/perry-runtime/src/proxy/put_value/packed_add.rs index f1fa41c35d..7629ce8538 100644 --- a/crates/perry-runtime/src/proxy/put_value/packed_add.rs +++ b/crates/perry-runtime/src/proxy/put_value/packed_add.rs @@ -337,7 +337,7 @@ const CENSUS_NAMES: [&str; 48] = [ "emit.elem.store.append_inline", "emit.elem.store.guard_miss", "emit.elem.store.fallback_call", - "emit.41", + "emit.elem.read.versioned_indexed", "emit.elem.store.f64_cold", "emit.43", "emit.44", @@ -653,6 +653,13 @@ pub(crate) unsafe fn packed_add_prime( census(C_PRIME_UNVERIFIED); return; } + // The emitted add hit stamps the successor before its raw slot store. + // A SPECIAL append needs the checked slow path to prewrite the current + // closure under Any before publishing its body-specific shape. + if crate::object::field_rep::slot_rep(post_d.rep, n) == crate::object::field_rep::REP_SPECIAL { + census(C_PRIME_UNVERIFIED); + return; + } // A marked prototype or exotic read receiver is on a private shape lineage // (`proto_validity::ensure_meta_for_mark`); never learn one of its shapes, // so the emitted hit's pre-shape compare alone proves the receiver is @@ -714,7 +721,7 @@ pub(crate) unsafe fn packed_add_prime( } let pre_word = if inline { pre } else { pre ^ SPILL_FLIP }; let shapes = u64::from(pre_word) | (u64::from(post) << 32); - let f64_slot = if inline && !crate::object::field_rep_store::shape_slot_is_any(post, n) { + let f64_slot = if inline && crate::object::field_rep_store::shape_slot_is_f64(post, n) { ADD_F64_SLOT } else { 0 diff --git a/crates/perry-runtime/src/proxy/put_value/packed_set.rs b/crates/perry-runtime/src/proxy/put_value/packed_set.rs index eeb5e9e75c..543d136bf5 100644 --- a/crates/perry-runtime/src/proxy/put_value/packed_set.rs +++ b/crates/perry-runtime/src/proxy/put_value/packed_set.rs @@ -443,6 +443,12 @@ unsafe fn prime_packed_set( let Some(idx) = own_idx else { return; }; + // The emitted hit stores raw bits and cannot invalidate a ConstFn body + // fact. Keep only this SPECIAL slot on the checked miss path; other + // Any/F64 slots in the same completed shape remain cacheable. + if crate::object::field_rep::slot_rep(shape.rep, idx) == crate::object::field_rep::REP_SPECIAL { + return; + } let inline = idx < shape.live_inline_slot_count; if !inline && !(idx < key_count && idx < IC_SLOT_OVERFLOW_BIT) { return; @@ -463,7 +469,7 @@ unsafe fn prime_packed_set( } else { (stamp ^ SPILL_FLIP, idx) }; - let f64_slot = if inline && !crate::object::field_rep_store::shape_slot_is_any(stamp, idx) { + let f64_slot = if inline && crate::object::field_rep_store::shape_slot_is_f64(stamp, idx) { PACKED_SET_F64_SLOT } else { 0 @@ -507,3 +513,7 @@ static KEEP_JS_PUT_VALUE_SET_PACKED_MISS: extern "C" fn( #[cfg(test)] #[path = "packed_set_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "cached_constfn_tests.rs"] +mod constfn_tests; diff --git a/crates/perry-runtime/src/string/concat_site.rs b/crates/perry-runtime/src/string/concat_site.rs index 1679d6b9a6..d8a5ae1222 100644 --- a/crates/perry-runtime/src/string/concat_site.rs +++ b/crates/perry-runtime/src/string/concat_site.rs @@ -26,8 +26,9 @@ //! immutable; in-place append (`s += x`) is only taken on values codegen //! proves uniquely owned, which a handle read back from a table never is. //! -//! Like module-global roots, a table is process-global while `GLOBAL_ROOTS` -//! is per-thread; compiled module code runs on the thread that registers it. +//! Codegen emits tables in TLS when the program can start workers. Each +//! agent fills its own cells and registers their addresses with its own +//! `GLOBAL_ROOTS`; retiring one worker cannot publish its heap to another. use super::concat::js_string_concat_value_box; use crate::string::StringHeader; @@ -68,8 +69,9 @@ fn concat_site_slot(value: f64) -> Option { /// /// # Safety /// `table` must point at `CONCAT_SITE_SLOTS` writable `u64` words that live -/// for the rest of the process — codegen emits a private global for each -/// site, and a filled slot's address is handed to the GC as a root. +/// for the current agent's lifetime — codegen emits a private TLS global +/// for each site in worker programs (a private global for single-agent +/// programs), and a filled slot's address is handed to that agent's GC. #[no_mangle] pub extern "C" fn js_string_concat_site_value( table: *mut u64, diff --git a/crates/perry-runtime/src/thread.rs b/crates/perry-runtime/src/thread.rs index 629144fb02..cae9b1dba8 100644 --- a/crates/perry-runtime/src/thread.rs +++ b/crates/perry-runtime/src/thread.rs @@ -236,6 +236,8 @@ pub enum SerializedValue { /// An object: (class_id, parent_class_id, fields, optional keys). /// Keys are present only for plain objects (not class instances). Object { + /// Optional immutable body/rep facts; never a source ShapeId or closure. + final_constfn: Option, class_id: u32, parent_class_id: u32, fields: Vec, @@ -663,6 +665,7 @@ unsafe fn serialize_array(arr: *const crate::array::ArrayHeader) -> SerializedVa unsafe fn serialize_object(obj: *const crate::object::ObjectHeader) -> SerializedValue { if obj.is_null() || (obj as usize) < 0x1000 { return SerializedValue::Object { + final_constfn: None, class_id: 0, parent_class_id: 0, fields: Vec::new(), @@ -753,20 +756,11 @@ unsafe fn serialize_object(obj: *const crate::object::ObjectHeader) -> Serialize // Paired with the `hole_at` skip in the fields loop above. continue; } - let key_tag = key_bits & TAG_MASK; - if key_tag == STRING_TAG { - let str_ptr = (key_bits & POINTER_MASK) as *const crate::string::StringHeader; - if !str_ptr.is_null() && (str_ptr as usize) >= 0x1000 { - let len = (*str_ptr).byte_len as usize; - let data = (str_ptr as *const u8) - .add(std::mem::size_of::()); - key_strings.push(std::slice::from_raw_parts(data, len).to_vec()); - } else { - key_strings.push(Vec::new()); - } - } else { - key_strings.push(Vec::new()); - } + let mut short = [0; crate::value::SHORT_STRING_MAX_LEN]; + key_strings.push( + crate::string::js_string_key_bytes(JSValue::from_bits(key_bits), &mut short) + .map_or_else(Vec::new, <[u8]>::to_vec), + ); } Some(key_strings) } else { @@ -774,6 +768,7 @@ unsafe fn serialize_object(obj: *const crate::object::ObjectHeader) -> Serialize }; SerializedValue::Object { + final_constfn: constfn_transfer::snapshot(obj, keys.as_deref(), fields.len()), class_id, parent_class_id, fields, @@ -884,6 +879,7 @@ pub unsafe fn deserialize_nanbox_on_current_thread(sv: &SerializedValue) -> u64 } SerializedValue::Object { + final_constfn, class_id, parent_class_id, fields, @@ -940,6 +936,11 @@ pub unsafe fn deserialize_nanbox_on_current_thread(sv: &SerializedValue) -> u64 } let obj = obj_handle.get_raw_mut_ptr::(); + let obj = if let (Some(facts), Some(names)) = (final_constfn, keys) { + constfn_transfer::restore(obj, *class_id, fields.len(), names, facts) + } else { + obj + }; JSValue::pointer(obj as *const u8).bits() } @@ -1056,6 +1057,16 @@ pub(crate) unsafe fn test_deserialize_bigint_limbs(limbs: [u64; BIGINT_LIMBS]) - deserialize_nanbox_on_current_thread(&SerializedValue::BigInt(limbs)) } +/// Run image-local, string-only initialization on the current worker. This +/// callback has no user code and no heap captures; earlier strings are already +/// registered as roots before the next allocation in preparation can collect. +unsafe fn prepare_worker_literals(code: i64) { + if code != 0 { + let prepare: unsafe extern "C" fn() = std::mem::transmute(code as usize); + prepare(); + } +} + // ============================================================================ // parallelMap — data-parallel array processing // ============================================================================ @@ -1098,12 +1109,23 @@ type ClosureCallFn = crate::closure::body_call::js_body_fn_ty!(argument); /// Returns a POINTER_TAG'd ArrayHeader pointer to the result array. #[no_mangle] pub extern "C" fn js_thread_parallel_map(array_val: f64, closure_val: f64) -> f64 { - let result_ptr = unsafe { parallel_map_impl(array_val, closure_val) }; + let result_ptr = unsafe { parallel_map_impl(array_val, closure_val, 0) }; // NaN-box the result array pointer with POINTER_TAG f64::from_bits(POINTER_TAG | (result_ptr as u64 & POINTER_MASK)) } -unsafe fn parallel_map_impl(array_val: f64, closure_val: f64) -> i64 { +/// Compiler-only launch ABI: preparation is a code address in the spawning image. +#[no_mangle] +pub extern "C" fn js_thread_parallel_map_with_literals( + array_val: f64, + closure_val: f64, + literal_prepare: i64, +) -> f64 { + let result_ptr = unsafe { parallel_map_impl(array_val, closure_val, literal_prepare) }; + f64::from_bits(POINTER_TAG | (result_ptr as u64 & POINTER_MASK)) +} + +unsafe fn parallel_map_impl(array_val: f64, closure_val: f64, literal_prepare: i64) -> i64 { // ── 1. Extract closure pointer and code, and root the closure ─ // The closure is validated and rooted BEFORE `clean_arr_ptr`: resolving // the array can force-materialize a lazy array — a GC point — and a @@ -1249,6 +1271,7 @@ unsafe fn parallel_map_impl(array_val: f64, closure_val: f64) -> i64 { // and sweeps everything it just deserialized. crate::gc::ensure_gc_initialized(); crate::object::shapes::install_worker_shape_seed(&shape_seed); + unsafe { prepare_worker_literals(literal_prepare) }; let mut results = Vec::with_capacity(chunk.len()); // Reconstruct closure on this thread's arena, rooted for the @@ -1392,11 +1415,22 @@ unsafe fn single_thread_map( /// the predicate returned a truthy value. #[no_mangle] pub extern "C" fn js_thread_parallel_filter(array_val: f64, closure_val: f64) -> f64 { - let result_ptr = unsafe { parallel_filter_impl(array_val, closure_val) }; + let result_ptr = unsafe { parallel_filter_impl(array_val, closure_val, 0) }; f64::from_bits(POINTER_TAG | (result_ptr as u64 & POINTER_MASK)) } -unsafe fn parallel_filter_impl(array_val: f64, closure_val: f64) -> i64 { +/// Compiler-only launch ABI: preparation is a code address in the spawning image. +#[no_mangle] +pub extern "C" fn js_thread_parallel_filter_with_literals( + array_val: f64, + closure_val: f64, + literal_prepare: i64, +) -> f64 { + let result_ptr = unsafe { parallel_filter_impl(array_val, closure_val, literal_prepare) }; + f64::from_bits(POINTER_TAG | (result_ptr as u64 & POINTER_MASK)) +} + +unsafe fn parallel_filter_impl(array_val: f64, closure_val: f64, literal_prepare: i64) -> i64 { // Closure validated and rooted BEFORE `clean_arr_ptr` — same GC-point // ordering as `parallel_map_impl` above (#6521 review follow-up). let closure_bits = closure_val.to_bits(); @@ -1511,6 +1545,7 @@ unsafe fn parallel_filter_impl(array_val: f64, closure_val: f64) -> i64 { let worker_agent = crate::agent::enter_worker_agent(); crate::gc::ensure_gc_initialized(); crate::object::shapes::install_worker_shape_seed(&shape_seed); + unsafe { prepare_worker_literals(literal_prepare) }; let mut kept = Vec::new(); let gc_scope = crate::gc::RuntimeHandleScope::new(); @@ -1652,13 +1687,20 @@ type ClosureCall0Fn = crate::closure::body_call::js_body_fn_ty!(); /// Returns a NaN-boxed f64 Promise pointer (POINTER_TAG). #[no_mangle] pub extern "C" fn js_thread_spawn(closure_val: f64) -> f64 { - let promise = unsafe { spawn_impl(closure_val) }; + let promise = unsafe { spawn_impl(closure_val, 0) }; // NaN-box the promise pointer with POINTER_TAG f64::from_bits(POINTER_TAG | (promise as u64 & POINTER_MASK)) } +/// Compiler-only launch ABI: preparation is a code address in the spawning image. +#[no_mangle] +pub extern "C" fn js_thread_spawn_with_literals(closure_val: f64, literal_prepare: i64) -> f64 { + let promise = unsafe { spawn_impl(closure_val, literal_prepare) }; + f64::from_bits(POINTER_TAG | (promise as u64 & POINTER_MASK)) +} + #[cfg_attr(target_os = "wasi", allow(unreachable_code, unused_variables))] -unsafe fn spawn_impl(closure_val: f64) -> *mut crate::promise::Promise { +unsafe fn spawn_impl(closure_val: f64, literal_prepare: i64) -> *mut crate::promise::Promise { // WASI preview 2 has no threads (#11377). Running the worker body inline // is not faithful — it claims and retires its own agent — so until a // main-thread `spawn` lands with the WASI event loop, reject clearly @@ -1738,6 +1780,7 @@ unsafe fn spawn_impl(closure_val: f64) -> *mut crate::promise::Promise { // cross a GC trigger (see the parallel_map worker for rationale). crate::gc::ensure_gc_initialized(); crate::object::shapes::install_worker_shape_seed(&shape_seed); + unsafe { prepare_worker_literals(literal_prepare) }; // Reconstruct closure in this thread's arena, rooted across the // capture-deserialization allocations. let gc_scope = crate::gc::RuntimeHandleScope::new(); @@ -1822,3 +1865,14 @@ mod static_shape_replay_tests; #[cfg(test)] #[path = "thread_transfer_guard_tests.rs"] mod transfer_guard_tests; + +#[path = "thread_constfn_transfer.rs"] +mod constfn_transfer; + +#[cfg(test)] +#[path = "thread_constfn_transfer_tests.rs"] +mod constfn_transfer_tests; + +#[cfg(all(test, not(target_os = "wasi")))] +#[path = "thread_literal_launch_tests.rs"] +mod literal_launch_tests; diff --git a/crates/perry-runtime/src/thread_constfn_transfer.rs b/crates/perry-runtime/src/thread_constfn_transfer.rs new file mode 100644 index 0000000000..79ec2b54a0 --- /dev/null +++ b/crates/perry-runtime/src/thread_constfn_transfer.rs @@ -0,0 +1,73 @@ +//! Reconstruct final ConstFn facts in the receiver's own shape table. +//! The wire owns scalar facts and permanent image-info addresses, as closure +//! serialization already does. It carries neither heap edges nor ShapeIds. +use crate::object::{field_rep, shapes, static_shapes, ObjectHeader}; + +#[derive(Debug)] +pub struct ConstFnTransferFacts { + rep: u64, + infos: Vec, +} + +pub(super) unsafe fn snapshot( + obj: *const ObjectHeader, + names: Option<&[Vec]>, + field_count: usize, +) -> Option { + let d = shapes::object_shape_descriptor(obj)?; + if d.object_kind != shapes::ShapeObjectKind::Ordinary + || d.semantic_generation != 0 + || d.hole_count != 0 + || d.summary != 0 + || d.proto_id != shapes::class_proto_id((*obj).class_id) + || !(*obj).meta.is_null() + || d.logical_key_count as usize != names?.len() + || d.live_inline_slot_count as usize != field_count + || d.special_constfn_mask == 0 + || field_rep::has_deprecated(d.rep) + || d.deprecation_targets() != (0, 0) + { + return None; + } + Some(ConstFnTransferFacts { + rep: d.rep, + infos: d.constfn_infos().to_vec(), + }) +} + +pub(super) unsafe fn restore( + obj: *mut ObjectHeader, + class_id: u32, + live: usize, + names: &[Vec], + facts: &ConstFnTransferFacts, +) -> *mut ObjectHeader { + // Wire key names came from the source's ordinary key slots. No authority + // comes from those bytes: the shared finalizer compares them and every + // closure/F64 lane with the receiving object's current keys and values. + let packed: Vec = names + .iter() + .flat_map(|n| n.iter().copied().chain([0])) + .collect(); + let entries: Vec = facts + .infos + .iter() + .map(|i| static_shapes::ConstFnStaticEntry { + slot: i.slot as u32, + info: i.info as usize as *const crate::closure::JsFunctionInfo, + }) + .collect(); + static_shapes::finalize_constfn_static( + obj as usize as u64, + 0, + packed.as_ptr(), + packed.len() as u32, + names.len() as u32, + live as u32, + class_id, + facts.rep, + entries.as_ptr(), + entries.len() as u32, + true, + ) as usize as *mut ObjectHeader +} diff --git a/crates/perry-runtime/src/thread_constfn_transfer_tests.rs b/crates/perry-runtime/src/thread_constfn_transfer_tests.rs new file mode 100644 index 0000000000..242e8a8963 --- /dev/null +++ b/crates/perry-runtime/src/thread_constfn_transfer_tests.rs @@ -0,0 +1,744 @@ +//! Real serializer/rebuilder gates, not synthetic seed-id equality. +use super::*; +use crate::object::{field_rep, shapes, static_shapes}; + +const PACKED: &[u8] = b"cfwire_method\0cfwire_number\0"; +const FINAL: u32 = shapes::SHAPE_ID_BASE + 0x7971; +const BASE: u32 = shapes::SHAPE_ID_BASE + 0x7972; +const REP: u64 = field_rep::REP_SPECIAL | (field_rep::REP_F64 << 2); + +extern "C" fn capture_body(c: *const ClosureHeader, _this: closure::JsThis) -> f64 { + f64::from_bits(closure::js_closure_get_capture_bits(c, 0)) +} +fn info() -> *const closure::JsFunctionInfo { + crate::fn_info!(capture_body, 0; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)) +} +fn seed_final() { + let entries = [static_shapes::ConstFnStaticEntry { + slot: 0, + info: info(), + }]; + assert_eq!( + static_shapes::js_shape_seed_plain_constfn( + FINAL, + PACKED.as_ptr(), + PACKED.len() as u32, + 2, + 2, + REP, + entries.as_ptr(), + 1 + ), + FINAL + ); +} +unsafe fn wire() -> SerializedValue { + let scope = gc::RuntimeHandleScope::new(); + seed_final(); + assert_eq!( + static_shapes::js_shape_seed_plain( + BASE, + PACKED.as_ptr(), + PACKED.len() as u32, + 2, + 2, + field_rep::REP_F64 << 2 + ), + BASE + ); + let keys = static_shapes::canonical_keys_for_names(&[b"cfwire_method", b"cfwire_number"]); + let keys = scope.root_raw_mut_ptr(keys.arr() as *mut crate::array::ArrayHeader); + let mut objects = Vec::new(); + for n in [17.0f64, 29.0] { + let object = crate::object::alloc_plain::alloc_plain_record_inline_keys_stamped( + 2, + keys.get_raw_mut_ptr(), + BASE, + ); + let object = scope.root_raw_mut_ptr(object); + let birth = shapes::object_shape_descriptor(object.get_raw_mut_ptr()).unwrap(); + assert_eq!( + shapes::object_shape_stamp(object.get_raw_mut_ptr()), + BASE, + "plain birth must use the installed carrier" + ); + assert_eq!(birth.object_kind, shapes::ShapeObjectKind::Ordinary); + assert_eq!(birth.rep, field_rep::REP_F64 << 2); + assert_eq!( + birth.special_constfn_mask, 0, + "allocation must stay Any/F64" + ); + let c = scope.root_raw_mut_ptr(closure::js_closure_alloc(info(), 1)); + closure::js_closure_set_capture_bits(c.get_raw_mut_ptr(), 0, n.to_bits()); + crate::object::store_object_field_slot( + object.get_raw_mut_ptr(), + 0, + JSValue::object_ptr(c.get_raw_mut_ptr::()).bits(), + ); + crate::object::store_object_field_slot(object.get_raw_mut_ptr(), 1, n.to_bits()); + let entries = [static_shapes::ConstFnStaticEntry { + slot: 0, + info: info(), + }]; + let obj = static_shapes::js_object_finalize_constfn_static( + object.get_raw_mut_ptr::() as usize as u64, + FINAL, + PACKED.as_ptr(), + PACKED.len() as u32, + 2, + 2, + 0, + REP, + entries.as_ptr(), + 1, + ) as usize as *mut crate::object::ObjectHeader; + assert_eq!( + shapes::object_shape_stamp(obj), + FINAL, + "source must carry the real final shape" + ); + let serialized = serialize_nanbox_for_thread(JSValue::object_ptr(obj.cast()).bits()); + assert!( + matches!( + &serialized, + SerializedValue::Object { + final_constfn: Some(_), + parent_class_id: 0, + .. + } + ), + "wire must carry scalar body facts and no ShapeId" + ); + objects.push(serialized); + } + SerializedValue::Array(objects) +} +fn replay(seed_first: bool, production_seed: bool) { + let payload = unsafe { wire() }; + let seed = shapes::worker_shape_seed(); + std::thread::spawn(move || unsafe { + if seed_first { + seed_final(); + } + if production_seed { + shapes::install_worker_shape_seed(&seed); + } + let bits = deserialize_nanbox_on_current_thread(&payload); + let array = JSValue::from_bits(bits).as_pointer::(); + let scope = gc::RuntimeHandleScope::new(); + let array = scope.root_raw_mut_ptr(array as *mut crate::array::ArrayHeader); + let mut ids = Vec::new(); + let mut cells = Vec::new(); + for (slot, expected) in [17.0, 29.0].into_iter().enumerate() { + let object = JSValue::from_bits( + crate::array::js_array_get_f64(array.get_raw_mut_ptr(), slot as u32).to_bits(), + ) + .as_pointer::(); + let id = shapes::object_shape_stamp(object); + let d = shapes::shape_descriptor_by_id(id).unwrap(); + assert_eq!(d.rep, REP, "F64 and CF must both survive reconstruction"); + assert_eq!(d.constfn_infos()[0].info, info() as usize as u64); + let fields = (object as *const u8) + .add(std::mem::size_of::()) + as *const u64; + let c = JSValue::from_bits(*fields).as_pointer::(); + assert_eq!(capture_body(c, closure::JsThis::UNDEFINED), expected); + assert_eq!(f64::from_bits(*fields.add(1)), expected); + ids.push(id); + cells.push(c as usize); + } + assert_eq!( + ids[0], ids[1], + "same static body shares facts across fresh closures" + ); + assert_ne!( + cells[0], cells[1], + "rebuilt closures must keep distinct captures" + ); + if seed_first || production_seed { + assert_eq!(ids[0], FINAL); + } else { + assert!(!shapes::is_static_shape_id(ids[0])); + assert!(shapes::shape_descriptor_by_id(FINAL).is_none()); + // Object-first must keep its old carriers valid when the external + // static id arrives, and subsequent reconstructions use that id. + shapes::install_worker_shape_seed(&seed); + let bits = deserialize_nanbox_on_current_thread(&payload); + let array = JSValue::from_bits(bits).as_pointer::(); + let obj = JSValue::from_bits(crate::array::js_array_get_f64(array, 0).to_bits()) + .as_pointer::(); + assert_eq!(shapes::object_shape_stamp(obj), FINAL); + assert!(shapes::shape_descriptor_by_id(ids[0]).is_some()); + } + }) + .join() + .expect("receiver agent"); +} +#[test] +fn constfn_transfer_seed_first_keeps_bodies_captures_and_numeric_rep() { + let _lock = gc::global_side_table_test_lock(); + replay(true, false); +} +#[test] +fn constfn_transfer_object_first_mints_locally_then_installs_external_facts() { + let _lock = gc::global_side_table_test_lock(); + replay(false, false); +} +#[test] +fn constfn_transfer_production_worker_seed_preserves_body_metadata() { + let _lock = gc::global_side_table_test_lock(); + replay(false, true); +} + +#[test] +fn constfn_transfer_refuses_rebuilt_numeric_contradiction() { + let _lock = gc::global_side_table_test_lock(); + let mut payload = unsafe { wire() }; + let SerializedValue::Array(objects) = &mut payload else { + unreachable!() + }; + let SerializedValue::Object { fields, .. } = &mut objects[0] else { + unreachable!() + }; + fields[1] = SerializedValue::Inline(TAG_TRUE); + std::thread::spawn(move || unsafe { + seed_final(); + let bits = deserialize_nanbox_on_current_thread(&payload); + let array = JSValue::from_bits(bits).as_pointer::(); + let object = JSValue::from_bits(crate::array::js_array_get_f64(array, 0).to_bits()) + .as_pointer::(); + let d = shapes::object_shape_descriptor(object).unwrap(); + assert_ne!(shapes::object_shape_stamp(object), FINAL); + assert_eq!( + d.special_constfn_mask, 0, + "a wire fact cannot override a current field value" + ); + }) + .join() + .expect("receiver agent"); +} + +#[test] +fn constfn_transfer_final_slots_rewrite_the_actual_closures_on_moving_gc() { + let _guard = gc::CopyingNurseryTestGuard::new(0); + let _triggers = gc::GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _forced = gc::knob_overrides::ForcedEvacuationTestGuard::on(); + gc::register_runtime_handle_root_scanner_for_tests(); + gc::gc_register_mutable_root_scanner(crate::object::scan_object_cache_roots_mut); + gc::gc_register_mutable_root_scanner(crate::object::scan_shape_cache_roots_mut); + gc::gc_register_mutable_root_scanner(crate::object::scan_transition_cache_roots_mut); + gc::gc_register_mutable_root_scanner(shapes::scan_shape_table_rekey_mut); + let previous = + gc::set_conservative_stack_scan_override(Some(gc::ConservativeStackScanMode::Disabled)); + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + gc::set_conservative_stack_scan_override(self.0); + } + } + let _restore = Restore(previous); + let scope = gc::RuntimeHandleScope::new(); + unsafe { + let payload = wire(); + let bits = deserialize_nanbox_on_current_thread(&payload); + let array = scope.root_raw_mut_ptr( + JSValue::from_bits(bits).as_pointer::() + as *mut crate::array::ArrayHeader, + ); + let snapshot = || { + (0..2) + .map(|i| { + let obj = JSValue::from_bits( + crate::array::js_array_get_f64(array.get_raw_mut_ptr(), i).to_bits(), + ) + .as_pointer::(); + let method = crate::object::js_object_get_field(obj as *mut _, 0).bits(); + (obj as usize, (method & POINTER_MASK) as usize) + }) + .collect::>() + }; + let before = snapshot(); + assert!( + before + .iter() + .all(|(o, c)| crate::arena::pointer_in_nursery(*o) + && crate::arena::pointer_in_nursery(*c)), + "premise: exact receiver and closure cells are movable" + ); + gc::gc_collect_minor(); + let after = snapshot(); + for (i, (old, new)) in before.iter().zip(&after).enumerate() { + assert_ne!(old.0, new.0, "actual receiver {i} did not relocate"); + assert_ne!(old.1, new.1, "current closure slot {i} did not rewrite"); + let obj = new.0 as *const crate::object::ObjectHeader; + assert_eq!(shapes::object_shape_stamp(obj), FINAL); + assert_eq!( + capture_body(new.1 as *const ClosureHeader, closure::JsThis::UNDEFINED), + [17.0, 29.0][i] + ); + } + } +} + +#[test] +fn constfn_transfer_does_not_resurrect_a_deprecated_final_body_fact() { + let _lock = gc::global_side_table_test_lock(); + std::thread::spawn(move || unsafe { + let payload = wire(); + let scope = gc::RuntimeHandleScope::new(); + let bits = deserialize_nanbox_on_current_thread(&payload); + let arr = scope.root_raw_mut_ptr( + JSValue::from_bits(bits).as_pointer::() + as *mut crate::array::ArrayHeader, + ); + let obj = + JSValue::from_bits(crate::array::js_array_get_f64(arr.get_raw_mut_ptr(), 0).to_bits()) + .as_pointer::() + as *mut crate::object::ObjectHeader; + assert_eq!(shapes::object_shape_stamp(obj), FINAL); + crate::object::store_object_field_slot(obj, 0, TAG_TRUE); + assert_eq!( + shapes::shape_descriptor_by_id(FINAL) + .unwrap() + .deprecation_targets() + .1, + 1 + ); + let bits = deserialize_nanbox_on_current_thread(&payload); + let arr = JSValue::from_bits(bits).as_pointer::(); + let obj = JSValue::from_bits(crate::array::js_array_get_f64(arr, 0).to_bits()) + .as_pointer::(); + assert_ne!( + shapes::object_shape_stamp(obj), + FINAL, + "a learned final record cannot be republished on a new receiver" + ); + assert_eq!( + shapes::object_shape_descriptor(obj) + .unwrap() + .special_constfn_mask, + 0 + ); + }) + .join() + .expect("isolated deprecation agent"); +} + +/// The seed outlives a copying collection in the source agent. Startup plain +/// seeds use immortal canonical keys, so this fixture deliberately passes +/// nursery keys through the real external class-final mint instead. It tests +/// the worker seed's ownership contract, not movement of immortal startup keys. +#[test] +fn constfn_worker_seed_owns_names_across_source_key_relocation() { + const LONG: &[u8] = b"cfseed_lifetime_method"; + const SHORT: &[u8] = b"n"; + const NAMES: &[u8] = b"cfseed_lifetime_method\0n\0"; + const CLASS: u32 = 0x7991; + const BASE_ID: u32 = shapes::SHAPE_ID_BASE + 0x7973; + const FINAL_ID: u32 = shapes::SHAPE_ID_BASE + 0x7974; + const NUMBER_REP: u64 = field_rep::REP_F64 << 2; + + unsafe fn names_at(keys: u64) -> Vec> { + let (slots, len) = crate::object::keys_array_dense_slots( + keys as usize as *const crate::array::ArrayHeader, + ); + assert!(!slots.is_null()); + assert_eq!(len, 2); + (0..2) + .map(|slot| { + let mut scratch = [0; crate::value::SHORT_STRING_MAX_LEN]; + crate::string::js_string_key_bytes( + JSValue::from_bits((*slots.add(slot)).to_bits()), + &mut scratch, + ) + .expect("each source/worker key must decode") + .to_vec() + }) + .collect() + } + unsafe fn heap_key_at(keys: u64) -> usize { + let (slots, len) = crate::object::keys_array_dense_slots( + keys as usize as *const crate::array::ArrayHeader, + ); + assert!(!slots.is_null()); + assert_eq!(len, 2); + let bits = (*slots).to_bits(); + assert_eq!(bits & TAG_MASK, STRING_TAG, "long key must be heap encoded"); + assert!(JSValue::from_bits((*slots.add(1)).to_bits()).is_short_string()); + (bits & POINTER_MASK) as usize + } + + let _guard = gc::CopyingNurseryTestGuard::new(0); + let _triggers = gc::GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _forced = gc::knob_overrides::ForcedEvacuationTestGuard::on(); + gc::register_runtime_handle_root_scanner_for_tests(); + gc::gc_register_mutable_root_scanner(crate::object::scan_object_cache_roots_mut); + gc::gc_register_mutable_root_scanner(crate::object::scan_shape_cache_roots_mut); + gc::gc_register_mutable_root_scanner(crate::object::scan_transition_cache_roots_mut); + gc::gc_register_mutable_root_scanner(shapes::scan_shape_table_rekey_mut); + let previous = + gc::set_conservative_stack_scan_override(Some(gc::ConservativeStackScanMode::Disabled)); + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + gc::set_conservative_stack_scan_override(self.0); + } + } + let _restore = Restore(previous); + let scope = gc::RuntimeHandleScope::new(); + unsafe { + let keys = scope.root_raw_mut_ptr(crate::array::js_array_alloc_key_list(2, true)); + let long = crate::string::js_string_from_bytes(LONG.as_ptr(), LONG.len() as u32); + store_thread_array_slot(keys.get_raw_mut_ptr(), 0, JSValue::string_ptr(long).bits()); + store_thread_array_slot( + keys.get_raw_mut_ptr(), + 1, + JSValue::try_short_string(SHORT) + .expect("short-key premise") + .bits(), + ); + assert_eq!( + static_shapes::js_object_shape_id_for_class_keys_static( + keys.get_raw_mut_ptr::() as usize as u64, + 2, + 2, + CLASS, + BASE_ID, + NUMBER_REP, + ), + BASE_ID + ); + let object = + scope.root_raw_mut_ptr(crate::object::js_object_alloc_class_inline_keys_stamped( + CLASS, + 0, + 2, + keys.get_raw_mut_ptr(), + BASE_ID, + NUMBER_REP, + )); + let closure = scope.root_raw_mut_ptr(closure::js_closure_alloc(info(), 1)); + closure::js_closure_set_capture_bits(closure.get_raw_mut_ptr(), 0, 37.0f64.to_bits()); + crate::object::store_object_field_slot( + object.get_raw_mut_ptr(), + 0, + JSValue::object_ptr(closure.get_raw_mut_ptr::()).bits(), + ); + crate::object::store_object_field_slot(object.get_raw_mut_ptr(), 1, 29.0f64.to_bits()); + let entries = [static_shapes::ConstFnStaticEntry { + slot: 0, + info: info(), + }]; + assert_eq!( + static_shapes::js_object_final_shape_id_for_class_keys_static_constfn( + keys.get_raw_mut_ptr::() as usize as u64, + 2, + 2, + CLASS, + FINAL_ID, + REP, + entries.as_ptr(), + 1, + ), + FINAL_ID, + "production mint must publish the real external final carrier" + ); + let finalized = static_shapes::js_object_finalize_constfn_static( + object.get_raw_mut_ptr::() as usize as u64, + FINAL_ID, + NAMES.as_ptr(), + NAMES.len() as u32, + 2, + 2, + CLASS, + REP, + entries.as_ptr(), + 1, + ) as usize as *const crate::object::ObjectHeader; + assert_eq!(shapes::object_shape_stamp(finalized), FINAL_ID); + let before = shapes::shape_descriptor_by_id(FINAL_ID).unwrap(); + let before_heap_key = heap_key_at(before.keys); + assert!( + crate::arena::pointer_in_nursery(before.keys as usize), + "premise: external record's source key list must be nursery movable" + ); + assert!( + crate::arena::pointer_in_nursery(before_heap_key), + "premise: the long key bytes must be nursery movable" + ); + assert_eq!(names_at(before.keys), vec![LONG.to_vec(), SHORT.to_vec()]); + assert_eq!(before.rep, REP); + assert_eq!( + before.constfn_infos(), + &[shapes::ConstFnSlotInfo { + slot: 0, + info: info() as usize as u64 + }] + ); + + let seed = shapes::worker_shape_seed(); + let payload = serialize_nanbox_for_thread(JSValue::pointer(finalized.cast()).bits()); + assert!(matches!( + &payload, + SerializedValue::Object { + final_constfn: Some(_), + .. + } + )); + // This is the lifetime window: the Rust-owned seed already exists, but + // no receiving worker has installed it. Neither saved old address is + // dereferenced after collection. + gc::gc_collect_minor(); + let after = shapes::shape_descriptor_by_id(FINAL_ID).unwrap(); + assert_ne!( + after.keys, before.keys, + "source key list did not actually relocate" + ); + assert_ne!( + heap_key_at(after.keys), + before_heap_key, + "heap key bytes did not actually relocate" + ); + assert_eq!( + after.keys, + keys.get_raw_mut_ptr::() as usize as u64 + ); + assert_eq!(names_at(after.keys), vec![LONG.to_vec(), SHORT.to_vec()]); + assert_eq!( + shapes::final_shape_ensure_constfn( + after.keys as usize as *const crate::array::ArrayHeader, + 2, + 2, + CLASS, + REP, + after.constfn_infos(), + None, + ) + .unwrap(), + FINAL_ID, + "source facts accelerator must be rekeyed, not mint a second descriptor" + ); + let relocated_source_keys = after.keys; + std::thread::spawn(move || { + let _agent = crate::agent::enter_worker_agent(); + gc::ensure_gc_initialized(); + assert!( + shapes::shape_descriptor_by_id(FINAL_ID).is_none(), + "fresh worker premise" + ); + shapes::install_worker_shape_seed(&seed); + let installed = shapes::shape_descriptor_by_id(FINAL_ID) + .expect("saved seed must install before object reconstruction can mask a failure"); + assert_ne!( + installed.keys, relocated_source_keys, + "worker must own its key storage" + ); + assert_eq!( + names_at(installed.keys), + vec![LONG.to_vec(), SHORT.to_vec()] + ); + assert_eq!(installed.logical_key_count, 2); + assert_eq!(installed.live_inline_slot_count, 2); + assert_eq!(installed.proto_id, shapes::class_proto_id(CLASS)); + assert_eq!(installed.rep, REP); + assert_eq!(installed.special_constfn_mask, 1); + assert_eq!(installed.deprecation_targets(), (0, 0)); + assert_eq!( + installed.constfn_infos(), + &[shapes::ConstFnSlotInfo { + slot: 0, + info: info() as usize as u64 + }] + ); + let bits = deserialize_nanbox_on_current_thread(&payload); + let object = JSValue::from_bits(bits).as_pointer::(); + assert_eq!(shapes::object_shape_stamp(object), FINAL_ID); + let fields = (object as *const u8) + .add(std::mem::size_of::()) + as *const u64; + let current_closure = JSValue::from_bits(*fields).as_pointer::(); + assert_eq!( + capture_body(current_closure, closure::JsThis::UNDEFINED), + 37.0 + ); + assert_eq!(*fields.add(1), 29.0f64.to_bits()); + }) + .join() + .expect("receiving agent after source-key movement"); + } +} + +/// Replays worker-producer.ts's compiler wire: class header2 is a registered +/// closed-literal shape, x is F64 slot0, m is ConstFn slot1, and the finalizer +/// is passed plain class0. Sharing the image must precede shape-seed install. +#[test] +fn constfn_transfer_compiled_anon_header_preserves_actual_worker_wire() { + const ANON: u32 = 2; + const ANON_BASE: u32 = shapes::SHAPE_ID_BASE + 0x3c; + const ANON_FINAL: u32 = shapes::SHAPE_ID_BASE + 0x46; + const NAMES: &[u8] = b"x\0m\0"; + const ANON_REP: u64 = field_rep::REP_F64 | (field_rep::REP_SPECIAL << 2); + fn arrow_info() -> *const closure::JsFunctionInfo { + crate::fn_info!(capture_body, 0; with_declared(0), with_length(0), + with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE + | closure::FN_ARROW | closure::FN_STRICT)) + } + let _lock = gc::global_side_table_test_lock(); + std::thread::spawn(move || unsafe { + crate::object::class_image::enter_current_thread_image(); + crate::object::js_register_anon_shape_class_id(ANON); + assert!(crate::object::is_anon_shape_class_id(ANON)); + assert_eq!(shapes::class_proto_id(ANON), shapes::class_proto_id(0)); + assert_eq!((*arrow_info()).flags, 6200, "actual producer info flags"); + assert_eq!( + static_shapes::js_shape_seed_plain( + ANON_BASE, + NAMES.as_ptr(), + NAMES.len() as u32, + 2, + 2, + field_rep::REP_F64, + ), + ANON_BASE, + ); + let entries = [static_shapes::ConstFnStaticEntry { + slot: 1, + info: arrow_info(), + }]; + assert_eq!( + static_shapes::js_shape_seed_plain_constfn( + ANON_FINAL, + NAMES.as_ptr(), + NAMES.len() as u32, + 2, + 2, + ANON_REP, + entries.as_ptr(), + 1, + ), + ANON_FINAL, + ); + let scope = gc::RuntimeHandleScope::new(); + let keys = crate::object::js_build_class_keys_array( + ANON, + 2, + NAMES.as_ptr(), + NAMES.len() as u32, + field_rep::REP_F64, + ) as usize as *mut crate::array::ArrayHeader; + let keys = scope.root_raw_mut_ptr(keys); + let mut objects = Vec::new(); + for number in [17.0f64, 29.0] { + let object = + scope.root_raw_mut_ptr(crate::object::js_object_alloc_class_inline_keys_stamped( + ANON, + 0, + 2, + keys.get_raw_mut_ptr(), + ANON_BASE, + field_rep::REP_F64, + )); + let cell = scope.root_raw_mut_ptr(closure::js_closure_alloc(arrow_info(), 1)); + closure::js_closure_set_capture_bits(cell.get_raw_mut_ptr(), 0, number.to_bits()); + crate::object::store_object_field_slot(object.get_raw_mut_ptr(), 0, number.to_bits()); + crate::object::store_object_field_slot( + object.get_raw_mut_ptr(), + 1, + JSValue::pointer(cell.get_raw_mut_ptr::()).bits(), + ); + let object = static_shapes::js_object_finalize_constfn_static( + object.get_raw_mut_ptr::() as usize as u64, + ANON_FINAL, + NAMES.as_ptr(), + NAMES.len() as u32, + 2, + 2, + 0, + ANON_REP, + entries.as_ptr(), + 1, + ) as usize as *mut crate::object::ObjectHeader; + assert_eq!( + (*object).class_id, + ANON, + "real producer header must survive" + ); + assert_eq!(shapes::object_shape_stamp(object), ANON_FINAL); + let payload = serialize_nanbox_for_thread(JSValue::pointer(object.cast()).bits()); + assert!( + matches!(&payload, SerializedValue::Object { + class_id: ANON, parent_class_id: 0, final_constfn: Some(_), keys: Some(names), .. + } if names == &vec![b"x".to_vec(), b"m".to_vec()]), + "actual anonymous producer must publish final wire facts" + ); + objects.push(payload); + } + let image = crate::object::class_image::current_image_handle(); + let seed = shapes::worker_shape_seed(); + std::thread::spawn(move || { + crate::object::class_image::adopt_image(image); + let agent = crate::agent::enter_worker_agent(); + gc::ensure_gc_initialized(); + shapes::install_worker_shape_seed(&seed); + assert_eq!( + shapes::shape_descriptor_by_id(ANON_FINAL).unwrap().rep, + ANON_REP + ); + assert_eq!(shapes::class_proto_id(ANON), shapes::class_proto_id(0)); + let handles = gc::RuntimeHandleScope::new(); + let mut cells = Vec::new(); + for (payload, expected) in objects.iter().zip([17.0, 29.0]) { + let bits = deserialize_nanbox_on_current_thread(payload); + let object = handles.root_raw_mut_ptr( + JSValue::from_bits(bits).as_pointer::() + as *mut crate::object::ObjectHeader, + ); + let object = object.get_raw_mut_ptr::(); + assert_eq!((*object).class_id, ANON); + assert_eq!( + shapes::object_shape_stamp(object), + ANON_FINAL, + "worker must republish actual final facts after validating current slots" + ); + let d = shapes::object_shape_descriptor(object).unwrap(); + assert_eq!(d.rep, ANON_REP); + assert_eq!(d.special_constfn_mask, 2); + assert_eq!( + d.constfn_infos(), + &[shapes::ConstFnSlotInfo { + slot: 1, + info: arrow_info() as usize as u64, + }] + ); + let fields = (object as *const u8) + .add(std::mem::size_of::()) + as *const u64; + assert_eq!(f64::from_bits(*fields), expected); + let cell = JSValue::from_bits(*fields.add(1)).as_pointer::(); + assert_eq!(capture_body(cell, closure::JsThis::UNDEFINED), expected); + cells.push(cell as usize); + } + assert_ne!(cells[0], cells[1], "same body keeps distinct captures"); + let mut contradiction = objects.remove(0); + let SerializedValue::Object { fields, .. } = &mut contradiction else { + unreachable!() + }; + fields[0] = SerializedValue::Inline(TAG_TRUE); + let bits = deserialize_nanbox_on_current_thread(&contradiction); + let object = JSValue::from_bits(bits).as_pointer::(); + assert_eq!( + shapes::object_shape_descriptor(object) + .unwrap() + .special_constfn_mask, + 0, + "wire cannot override a contradictory current numeric lane" + ); + drop(handles); + crate::agent::retire_agent(agent); + }) + .join() + .expect("actual anonymous receiving worker"); + }) + .join() + .expect("isolated compiled producer image"); +} diff --git a/crates/perry-runtime/src/thread_literal_launch_tests.rs b/crates/perry-runtime/src/thread_literal_launch_tests.rs new file mode 100644 index 0000000000..4d1ecf8905 --- /dev/null +++ b/crates/perry-runtime/src/thread_literal_launch_tests.rs @@ -0,0 +1,232 @@ +//! The launch ABI must prepare in each actual OS worker before its body runs. +//! Run serially (RUST_TEST_THREADS=1), like the rest of perry-runtime's tests. +//! Map/filter require at least two available CPUs; a caller fallback cannot pass. +use super::*; +use std::cell::Cell; +use std::sync::atomic::{AtomicBool, AtomicU64}; +use std::time::{Duration, Instant}; + +// Primitive observations only: no heap pointers or runtime registry. +crate::perry_thread_local! { + static IS_LAUNCHER: Cell = const { Cell::new(false) }; + static PREPARED_AGENT: Cell> = const { Cell::new(None) }; + static BODY_STARTED: Cell = const { Cell::new(false) }; +} +static LAUNCHER_AGENT: AtomicU64 = AtomicU64::new(0); +static EXPECT_PREPARED: AtomicBool = AtomicBool::new(false); +static PREPARE_CALLS: AtomicUsize = AtomicUsize::new(0); +static BODY_CALLS: AtomicUsize = AtomicUsize::new(0); +static VIOLATIONS: AtomicUsize = AtomicUsize::new(0); + +fn note_worker() { + // The launcher's marker is thread-local: seeing it means the same OS thread. + if IS_LAUNCHER.with(Cell::get) { + VIOLATIONS.fetch_or(1, Ordering::SeqCst); + } + let agent = crate::agent::current_agent(); + if agent == crate::agent::PRIMARY_AGENT || agent == LAUNCHER_AGENT.load(Ordering::SeqCst) { + VIOLATIONS.fetch_or(2, Ordering::SeqCst); + } +} + +extern "C" fn prepare() { + // Never assert/panic through an extern-C callback; report on the test thread. + note_worker(); + if PREPARED_AGENT.with(Cell::get).is_some() { + VIOLATIONS.fetch_or(4, Ordering::SeqCst); + } + if BODY_STARTED.with(Cell::get) { + VIOLATIONS.fetch_or(8, Ordering::SeqCst); + } + PREPARED_AGENT.with(|slot| slot.set(Some(crate::agent::current_agent()))); + PREPARE_CALLS.fetch_add(1, Ordering::SeqCst); +} + +fn observe_body(closure: *const ClosureHeader) -> f64 { + note_worker(); + BODY_STARTED.with(|slot| slot.set(true)); + let prepared = PREPARED_AGENT.with(Cell::get); + if EXPECT_PREPARED.load(Ordering::SeqCst) { + if prepared != Some(crate::agent::current_agent()) { + VIOLATIONS.fetch_or(16, Ordering::SeqCst); + } + } else if prepared.is_some() { + VIOLATIONS.fetch_or(32, Ordering::SeqCst); + } + BODY_CALLS.fetch_add(1, Ordering::SeqCst); + if closure.is_null() { + VIOLATIONS.fetch_or(64, Ordering::SeqCst); + return 0.0; + } + let capture = f64::from_bits(closure::js_closure_get_capture_bits(closure, 0)); + if capture != 7.0 { + VIOLATIONS.fetch_or(64, Ordering::SeqCst); + } + capture +} + +extern "C" fn spawn_body(closure: *const ClosureHeader, _this: closure::JsThis) -> f64 { + observe_body(closure) + 12.0 +} + +extern "C" fn map_body(closure: *const ClosureHeader, _this: closure::JsThis, value: f64) -> f64 { + observe_body(closure) + value +} + +extern "C" fn filter_body( + closure: *const ClosureHeader, + _this: closure::JsThis, + value: f64, +) -> f64 { + let capture = observe_body(closure); + f64::from_bits(if value == capture + 1.0 { + TAG_TRUE + } else { + TAG_FALSE + }) +} + +fn begin(with_literals: bool) { + crate::gc::ensure_gc_initialized(); + IS_LAUNCHER.with(|slot| slot.set(true)); + PREPARED_AGENT.with(|slot| slot.set(None)); + BODY_STARTED.with(|slot| slot.set(false)); + LAUNCHER_AGENT.store(crate::agent::current_agent(), Ordering::SeqCst); + EXPECT_PREPARED.store(with_literals, Ordering::SeqCst); + PREPARE_CALLS.store(0, Ordering::SeqCst); + BODY_CALLS.store(0, Ordering::SeqCst); + VIOLATIONS.store(0, Ordering::SeqCst); +} + +fn finish(with_literals: bool, workers: usize) { + assert_eq!( + VIOLATIONS.load(Ordering::SeqCst), + 0, + "worker/order violation bitmask" + ); + assert_eq!( + BODY_CALLS.load(Ordering::SeqCst), + workers, + "body must actually run" + ); + assert_eq!( + PREPARE_CALLS.load(Ordering::SeqCst), + if with_literals { workers } else { 0 }, + "one preparation per worker; legacy wrappers supply zero callbacks" + ); + assert_eq!( + PREPARED_AGENT.with(Cell::get), + None, + "caller must not prepare" + ); + IS_LAUNCHER.with(|slot| slot.set(false)); +} + +fn run_spawn(with_literals: bool) { + let _lock = crate::gc::global_side_table_test_lock(); + begin(with_literals); + let scope = crate::gc::RuntimeHandleScope::new(); + let closure = scope.root_raw_mut_ptr(closure::js_closure_alloc( + crate::fn_info!(spawn_body, 0; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)), + 1, + )); + closure::js_closure_set_capture_f64(closure.get_raw_mut_ptr(), 0, 7.0); + let boxed = crate::value::js_nanbox_pointer(closure.get_raw_mut_ptr::() as i64); + let result = if with_literals { + js_thread_spawn_with_literals(boxed, prepare as *const () as usize as i64) + } else { + js_thread_spawn(boxed) + }; + let promise = + scope.root_raw_mut_ptr((result.to_bits() & POINTER_MASK) as *mut crate::promise::Promise); + let deadline = Instant::now() + Duration::from_secs(5); + while crate::promise::js_promise_state(promise.get_raw_mut_ptr()) == 0 { + js_thread_process_pending(); + assert!(Instant::now() < deadline, "worker promise did not settle"); + std::thread::sleep(Duration::from_millis(1)); + } + assert_eq!( + crate::promise::js_promise_state(promise.get_raw_mut_ptr()), + 1 + ); + assert_eq!( + crate::promise::js_promise_value(promise.get_raw_mut_ptr()), + 19.0 + ); + finish(with_literals, 1); +} + +fn run_parallel(with_literals: bool, filter: bool) { + let _lock = crate::gc::global_side_table_test_lock(); + assert!( + std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4) + >= 2, + "requires two available CPUs to exercise actual map/filter OS workers" + ); + begin(with_literals); + let scope = crate::gc::RuntimeHandleScope::new(); + let array = scope.root_raw_mut_ptr(crate::array::js_array_alloc_with_length(2)); + crate::array::js_array_set_f64(array.get_raw_mut_ptr(), 0, 8.0); + crate::array::js_array_set_f64(array.get_raw_mut_ptr(), 1, 9.0); + let info = if filter { + crate::fn_info!(filter_body, 1; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)) + } else { + crate::fn_info!(map_body, 1; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)) + }; + let closure = scope.root_raw_mut_ptr(closure::js_closure_alloc(info, 1)); + closure::js_closure_set_capture_f64(closure.get_raw_mut_ptr(), 0, 7.0); + let array = crate::value::js_nanbox_pointer( + array.get_raw_mut_ptr::() as i64, + ); + let closure = + crate::value::js_nanbox_pointer(closure.get_raw_mut_ptr::() as i64); + let callback = prepare as *const () as usize as i64; + let result = match (with_literals, filter) { + (true, false) => js_thread_parallel_map_with_literals(array, closure, callback), + (true, true) => js_thread_parallel_filter_with_literals(array, closure, callback), + (false, false) => js_thread_parallel_map(array, closure), + (false, true) => js_thread_parallel_filter(array, closure), + }; + let result = + scope.root_raw_mut_ptr((result.to_bits() & POINTER_MASK) as *mut crate::array::ArrayHeader); + let result = result.get_raw_mut_ptr::(); + assert_eq!( + crate::array::js_array_get_length(result as i64), + if filter { 1 } else { 2 } + ); + assert_eq!( + crate::array::js_array_get_f64(result, 0), + if filter { 8.0 } else { 15.0 } + ); + if !filter { + assert_eq!(crate::array::js_array_get_f64(result, 1), 16.0); + } + finish(with_literals, 2); +} + +#[test] +fn spawn_prepares_each_os_worker_before_body() { + run_spawn(true); +} +#[test] +fn map_prepares_each_os_worker_before_body() { + run_parallel(true, false); +} +#[test] +fn filter_prepares_each_os_worker_before_body() { + run_parallel(true, true); +} +#[test] +fn legacy_spawn_uses_zero_callback() { + run_spawn(false); +} +#[test] +fn legacy_map_uses_zero_callback() { + run_parallel(false, false); +} +#[test] +fn legacy_filter_uses_zero_callback() { + run_parallel(false, true); +} diff --git a/crates/perry-runtime/src/thread_static_shape_tests.rs b/crates/perry-runtime/src/thread_static_shape_tests.rs index 577766fddb..efe5e483bc 100644 --- a/crates/perry-runtime/src/thread_static_shape_tests.rs +++ b/crates/perry-runtime/src/thread_static_shape_tests.rs @@ -122,3 +122,44 @@ fn an_unseeded_worker_mints_a_replayed_object_by_facts_and_never_aliases_the_sta "the static id exists in an agent that never seeded it — a compare could hit wrong facts" ); } + +extern "C" fn worker_constfn_body( + _closure: *const crate::closure::ClosureHeader, + _this: crate::closure::JsThis, +) -> f64 { + 19.0 +} + +fn seed_constfn_here() -> (u32, u64) { + use crate::object::field_rep::{with_slot_rep, REP_SPECIAL}; + let info = + crate::fn_info!(worker_constfn_body, 0; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)); + let entries = [crate::object::static_shapes::ConstFnStaticEntry { slot: 0, info }]; + let packed = b"lt5w_method\0"; + let id = crate::object::static_shapes::js_shape_seed_plain_constfn( + SHAPE_ID_BASE + 0x7862, + packed.as_ptr(), + packed.len() as u32, + 1, + 1, + with_slot_rep(0, 0, REP_SPECIAL), + entries.as_ptr(), + 1, + ); + let record = shape_descriptor_by_id(id).expect("agent-local ConstFn seed record"); + (id, record.constfn_infos()[0].info) +} + +#[test] +fn constfn_static_seed_uses_same_image_body_in_each_agent() { + let _lock = crate::gc::global_side_table_test_lock(); + let main = seed_constfn_here(); + let worker = std::thread::spawn(seed_constfn_here) + .join() + .expect("worker ConstFn seed"); + assert_eq!(main.0, SHAPE_ID_BASE + 0x7862); + assert_eq!( + worker, main, + "static id and body info must agree across agents" + ); +} diff --git a/crates/perry-runtime/src/thread_transfer_guard_tests.rs b/crates/perry-runtime/src/thread_transfer_guard_tests.rs index 1301390fbc..81631cf5c1 100644 --- a/crates/perry-runtime/src/thread_transfer_guard_tests.rs +++ b/crates/perry-runtime/src/thread_transfer_guard_tests.rs @@ -48,6 +48,7 @@ fn first_unsupported_transfer_type_finds_nested_markers() { assert_eq!(first_unsupported_transfer_type(&arr), Some("Set")); // Inside an object field, nested in an array. let obj = SerializedValue::Object { + final_constfn: None, class_id: 0, parent_class_id: 0, fields: vec![ @@ -72,6 +73,7 @@ fn transferable_trees_report_no_unsupported() { SerializedValue::Inline(0x4045_0000_0000_0000), // a plain f64 SerializedValue::String(b"ok".to_vec()), SerializedValue::Object { + final_constfn: None, class_id: 3, parent_class_id: 0, fields: vec![ diff --git a/crates/perry-runtime/src/typed_feedback/guards.rs b/crates/perry-runtime/src/typed_feedback/guards.rs index 510c324eb3..3c2a24b540 100644 --- a/crates/perry-runtime/src/typed_feedback/guards.rs +++ b/crates/perry-runtime/src/typed_feedback/guards.rs @@ -244,8 +244,9 @@ fn descriptor_blocks_class_field_get(obj_addr: usize, class_id: u32, key_name: & /// the receiver carries `expected_shape_id` and that `field_index` is in bounds. /// /// The shape is the authority (charter step 5): a receiver stamped with -/// `expected_shape_id` holds a raw double in every slot whose lane in that -/// shape is not `Any`, and a store that would break that generalizes the lane +/// `expected_shape_id` holds a raw double only in an `F64` (or deprecated +/// `F64`) lane; SPECIAL ConstFn lanes are pointer-bearing. A store that would +/// break an F64 lane generalizes it /// and restamps the receiver first. So "slot K is raw-f64" is the lane of the /// expected shape at K; nothing per object is consulted. #[inline] @@ -255,7 +256,7 @@ fn class_field_raw_f64_layout_contract( require_raw_f64: bool, ) -> bool { !require_raw_f64 - || !crate::object::field_rep_store::shape_slot_is_any(expected_shape_id, field_index) + || crate::object::field_rep_store::shape_slot_is_f64(expected_shape_id, field_index) } fn class_field_get_contract( @@ -301,7 +302,10 @@ fn class_field_get_contract( let keys = descriptor.keys as usize as *const ArrayHeader; let valid = crate::object::object_is_regular(obj) && class_id == expected_class_id - && shape_id == expected_shape_id + && crate::object::field_rep_store::final_shape_matches_birth( + shape_id, + expected_shape_id, + ) && expected_field_index < descriptor.live_inline_slot_count && expected_field_index < descriptor.logical_key_count && plain_array_index_guard(keys, expected_field_index, true) @@ -344,16 +348,13 @@ fn class_field_fast_contract( let obj = object_addr as *const ObjectHeader; let descriptor = crate::object::shapes::object_shape_descriptor(obj); let shape_id = crate::object::shapes::object_shape_stamp(obj); - // The ShapeId compare is the whole proof, the lane included: the - // expected id is the class's birth shape, whose rep is part of its - // identity, so a receiver that carries it carries its lanes. A lane - // that is not `Any` there sends the store through the checked - // funnel (charter step 5). + // Birth or a compatible completed shape proves the offsets and + // numeric lanes. The setter separately requires the live boxed lane + // to be Any before skipping the checked store funnel. let shape_ok = (*obj).class_id == expected_class_id - && shape_id == expected_shape_id - && crate::object::field_rep_store::shape_slot_is_any( + && crate::object::field_rep_store::final_shape_matches_birth( + shape_id, expected_shape_id, - expected_field_index, ) && descriptor.is_some_and(|facts| { facts.object_kind.is_ordinary_layout() @@ -444,6 +445,13 @@ fn class_field_set_fast_contract( return false; } unsafe { + let live_shape = + crate::object::shapes::object_shape_stamp(object_addr as *const ObjectHeader); + if !require_raw_f64 + && !crate::object::field_rep_store::shape_slot_is_any(live_shape, expected_field_index) + { + return false; + } let Some(gc_header) = gc_header_for_user_addr(object_addr) else { return false; }; diff --git a/crates/perry-runtime/src/weakref.rs b/crates/perry-runtime/src/weakref.rs index 8874eb49fc..0dd778f378 100644 --- a/crates/perry-runtime/src/weakref.rs +++ b/crates/perry-runtime/src/weakref.rs @@ -31,6 +31,8 @@ pub use operations::{js_weakmap_delete, js_weakmap_get, js_weakmap_has, js_weakm pub(crate) mod sliced; #[cfg(test)] pub(crate) mod test_support; +#[cfg(test)] +mod trace_slot_tests; const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; const TAG_TRUE: u64 = 0x7FFC_0000_0000_0004; @@ -388,6 +390,7 @@ pub(crate) unsafe fn is_weak_holder_header(header: *mut crate::gc::GcHeader) -> ) } +#[inline] pub(crate) unsafe fn is_weak_target_trace_slot( header: *mut crate::gc::GcHeader, slot: *mut u64, @@ -403,6 +406,22 @@ pub(crate) unsafe fn is_weak_target_trace_slot( ) { return false; } + is_weak_branded_target_trace_slot(obj, class_id, slot) +} + +/// Bound and target-slot handling after the caller has checked the weak brand. +/// Keep this shape lookup out of the common per-slot GC callback body. +/// +/// # Safety +/// `obj` is the readable object payload of the checked GC header, and +/// `class_id` is its checked weak-holder class. This helper does not collect. +#[cold] +#[inline(never)] +unsafe fn is_weak_branded_target_trace_slot( + obj: *mut ObjectHeader, + class_id: u32, + slot: *mut u64, +) -> bool { // #8113: ONE bound lookup. This runs per traced slot, and the bound is a // shape-table probe now rather than a header word, so the three separate // reads the arms below used to make were three probes. diff --git a/crates/perry-runtime/src/weakref/trace_slot_tests.rs b/crates/perry-runtime/src/weakref/trace_slot_tests.rs new file mode 100644 index 0000000000..a178bdff1c --- /dev/null +++ b/crates/perry-runtime/src/weakref/trace_slot_tests.rs @@ -0,0 +1,98 @@ +//! Weak-slot admission must use the authoritative shape bound, including +//! allocated-but-non-live inline storage and both finalization weak fields. + +use super::*; + +#[test] +fn weak_trace_slot_respects_shape_bounds_and_brands() { + let _lock = crate::gc::global_side_table_test_lock(); + unsafe { + for class_id in [ + 0, + CLASS_ID_FINALIZATION_REGISTRY, + CLASS_ID_WEAKREF, + CLASS_ID_WEAK_ENTRY, + CLASS_ID_FINALIZATION_RECORD, + ] { + for live in [0, 1, 2, 4] { + let obj = crate::object::js_object_alloc(class_id, live); + let header = (obj as *mut u8) + .sub(crate::gc::GC_HEADER_SIZE) + .cast::(); + assert_eq!((*header).obj_type, crate::gc::GC_TYPE_OBJECT); + assert_eq!((*obj).class_id, class_id); + assert_eq!(crate::object::object_live_slot_count(obj), live); + let capacity = (live as usize).max(crate::object::INLINE_SLOT_FLOOR); + // Include the one-past-capacity address; never dereference slots. + for field in 0..=capacity { + let expected = (field as u32) < live + && (matches!(class_id, CLASS_ID_WEAKREF | CLASS_ID_WEAK_ENTRY) + && field == 0 + || class_id == CLASS_ID_FINALIZATION_RECORD && field < 2); + assert_eq!( + is_weak_target_trace_slot(header, object_field_slot(obj, field)), + expected, + "class {class_id:#x}, live {live}, field {field}" + ); + } + assert!(!is_weak_target_trace_slot(header, std::ptr::null_mut())); + let mut unrelated = 0u64; + assert!(!is_weak_target_trace_slot(header, &mut unrelated)); + } + } + } +} + +#[test] +fn weak_trace_slot_rejects_null_nonobject_and_absent_shape() { + let _lock = crate::gc::global_side_table_test_lock(); + unsafe { + assert!(!is_weak_target_trace_slot( + std::ptr::null_mut(), + std::ptr::null_mut() + )); + + let array = js_array_alloc(0); + let array_header = (array as *mut u8) + .sub(crate::gc::GC_HEADER_SIZE) + .cast::(); + assert_ne!((*array_header).obj_type, crate::gc::GC_TYPE_OBJECT); + assert!(!is_weak_target_trace_slot( + array_header, + std::ptr::null_mut() + )); + + for class_id in [ + CLASS_ID_WEAKREF, + CLASS_ID_WEAK_ENTRY, + CLASS_ID_FINALIZATION_RECORD, + ] { + let obj = crate::object::js_object_alloc(class_id, 2); + let header = (obj as *mut u8) + .sub(crate::gc::GC_HEADER_SIZE) + .cast::(); + let stamp = (*obj).parent_class_id; + assert_eq!(crate::object::object_live_slot_count(obj), 2); + assert!(is_weak_target_trace_slot(header, object_field_slot(obj, 0))); + if class_id == CLASS_ID_FINALIZATION_RECORD { + assert!(is_weak_target_trace_slot(header, object_field_slot(obj, 1))); + } + // Synthetic absent-descriptor fixture: physical storage stays live. + (*obj).parent_class_id = 0; + assert_eq!(crate::object::object_live_slot_count(obj), 0); + assert!(!is_weak_target_trace_slot( + header, + object_field_slot(obj, 0) + )); + assert!(!is_weak_target_trace_slot( + header, + object_field_slot(obj, 1) + )); + (*obj).parent_class_id = stamp; + assert!(is_weak_target_trace_slot(header, object_field_slot(obj, 0))); + if class_id == CLASS_ID_FINALIZATION_RECORD { + assert!(is_weak_target_trace_slot(header, object_field_slot(obj, 1))); + } + } + } +} diff --git a/crates/perry-transform/src/finally_inline.rs b/crates/perry-transform/src/finally_inline.rs index 307f80ad33..909d19f64c 100644 --- a/crates/perry-transform/src/finally_inline.rs +++ b/crates/perry-transform/src/finally_inline.rs @@ -55,13 +55,14 @@ //! it. Nested try-with-finally stack the inlined clones //! innermost→outermost. //! -//! Limitations: -//! - Code after the abrupt completion becomes dead — fine. -//! - If `Y_clone` itself throws, the throw routes to the same try's -//! catch (if any) instead of propagating directly. Per spec the -//! throw should override the pending abrupt completion without -//! going through the catch. Rare; matters only when the finally -//! throws AND there's a same-level catch. +//! Each try-with-finally owns a flag set BEFORE its finally runs. Clones +//! and the preserved finally both consult that flag. An exception raised by +//! a clone therefore cannot run the same finally again through the original +//! handler. Its catch also consults the flag so an inlined finally exception +//! bypasses the same-level catch, as it does when executing the normal finally. +//! Catch-only try scopes also own an exit flag. Their stack entry marks exit +//! before outer cleanup runs, without adding a finally or an EH handler. +//! Catches declared inside cleanup retain their own unexited scope. //! //! Issue #536: `@perryts/mysql`'s `Pool.query` uses //! `try { return await conn.query(...) } finally { this.release(conn) }` @@ -81,7 +82,7 @@ use perry_hir::{Expr, Module, Stmt}; // (exhaustive-walker-backed) implementation in `generator::id_scan` instead. use crate::generator::compute_max_local_id; -/// One open try-with-finally on the lowering stack. The cloned `body` +/// One open semantic try boundary on the lowering stack. The cloned `body` /// gets inlined ahead of any abrupt completion (return/break/continue) /// that would escape this try frame. `loop_depth_at_push` is the value /// of the surrounding `loop_depth` counter at the moment this try was @@ -92,6 +93,7 @@ use crate::generator::compute_max_local_id; struct EnclosingFinally { body: Vec, loop_depth_at_push: usize, + switch_depth_at_push: usize, } /// Run the pass over an entire HIR module. @@ -103,6 +105,7 @@ pub fn inline_finally_into_returns(module: &mut Module) { &mut func.body, &[], 0, + 0, &mut label_depths, &mut next_local_id, ); @@ -113,6 +116,7 @@ pub fn inline_finally_into_returns(module: &mut Module) { &mut method.body, &[], 0, + 0, &mut label_depths, &mut next_local_id, ); @@ -122,6 +126,7 @@ pub fn inline_finally_into_returns(module: &mut Module) { &mut static_method.body, &[], 0, + 0, &mut label_depths, &mut next_local_id, ); @@ -131,6 +136,7 @@ pub fn inline_finally_into_returns(module: &mut Module) { &mut ctor.body, &[], 0, + 0, &mut label_depths, &mut next_local_id, ); @@ -140,6 +146,7 @@ pub fn inline_finally_into_returns(module: &mut Module) { &mut getter.1.body, &[], 0, + 0, &mut label_depths, &mut next_local_id, ); @@ -149,6 +156,7 @@ pub fn inline_finally_into_returns(module: &mut Module) { &mut setter.1.body, &[], 0, + 0, &mut label_depths, &mut next_local_id, ); @@ -163,6 +171,7 @@ pub fn inline_finally_into_returns(module: &mut Module) { &mut member.function.body, &[], 0, + 0, &mut label_depths, &mut next_local_id, ); @@ -170,7 +179,14 @@ pub fn inline_finally_into_returns(module: &mut Module) { } for stmt in module.init.iter_mut() { let mut single = vec![std::mem::replace(stmt, Stmt::Break)]; - process_stmts(&mut single, &[], 0, &mut label_depths, &mut next_local_id); + process_stmts( + &mut single, + &[], + 0, + 0, + &mut label_depths, + &mut next_local_id, + ); if let Some(s) = single.into_iter().next() { *stmt = s; } @@ -197,6 +213,7 @@ fn process_stmts( stmts: &mut Vec, enclosing: &[EnclosingFinally], loop_depth: usize, + switch_depth: usize, label_depths: &mut HashMap, next_local_id: &mut LocalId, ) { @@ -241,6 +258,7 @@ fn process_stmts( enclosing, enclosing.len(), // run all of them loop_depth, + switch_depth, label_depths, next_local_id, ); @@ -251,6 +269,8 @@ fn process_stmts( &mut out, enclosing, loop_depth, + switch_depth, + false, label_depths, next_local_id, ); @@ -261,6 +281,8 @@ fn process_stmts( &mut out, enclosing, loop_depth, + switch_depth, + true, label_depths, next_local_id, ); @@ -271,6 +293,7 @@ fn process_stmts( &mut out, enclosing, &label, + switch_depth, label_depths, next_local_id, ); @@ -281,6 +304,7 @@ fn process_stmts( &mut out, enclosing, &label, + switch_depth, label_depths, next_local_id, ); @@ -289,19 +313,64 @@ fn process_stmts( Stmt::Try { mut body, mut catch, - finally, + mut finally, } => { + // Inlined outer finallies still sit inside inner try EH + // regions. Guard their preserved copies and catches so a + // clone exception crosses those regions without re-running + // cleanup or entering a catch it has already escaped. + let exit_id = (finally.is_some() || catch.is_some()).then(|| { + let id = *next_local_id; + *next_local_id += 1; + id + }); + if let Some(id) = exit_id { + body.insert( + 0, + Stmt::Let { + id, + name: format!("__finally_exited_{}", id), + ty: Type::Boolean, + mutable: true, + init: Some(Expr::Bool(false)), + }, + ); + if let Some(cleanup) = finally.take() { + let mut once = + vec![Stmt::Expr(Expr::LocalSet(id, Box::new(Expr::Bool(true))))]; + once.extend(cleanup); + finally = Some(vec![Stmt::If { + condition: Expr::Unary { + op: perry_hir::UnaryOp::Not, + operand: Box::new(Expr::LocalGet(id)), + }, + then_branch: once, + else_branch: None, + }]); + } + } let mut extended: Vec = enclosing.to_vec(); if let Some(f) = &finally { extended.push(EnclosingFinally { body: f.clone(), loop_depth_at_push: loop_depth, + switch_depth_at_push: switch_depth, + }); + } else if let Some(id) = exit_id { + // A catch-only Try owns no cleanup or additional handler. + // Mark its semantic exit before cloning an outer cleanup, + // so exceptions from that cleanup bypass this catch. + extended.push(EnclosingFinally { + body: vec![Stmt::Expr(Expr::LocalSet(id, Box::new(Expr::Bool(true))))], + loop_depth_at_push: loop_depth, + switch_depth_at_push: switch_depth, }); } process_stmts( &mut body, &extended, loop_depth, + switch_depth, label_depths, next_local_id, ); @@ -310,12 +379,36 @@ fn process_stmts( &mut c.body, &extended, loop_depth, + switch_depth, label_depths, next_local_id, ); + if let Some(id) = exit_id { + let err_id = match &c.param { + Some((err_id, _)) => *err_id, + None => { + let err_id = *next_local_id; + *next_local_id += 1; + c.param = Some((err_id, format!("__finally_error_{}", err_id))); + err_id + } + }; + c.body = vec![Stmt::If { + condition: Expr::LocalGet(id), + then_branch: vec![Stmt::Throw(Expr::LocalGet(err_id))], + else_branch: Some(std::mem::take(&mut c.body)), + }]; + } } let new_finally = finally.map(|mut f| { - process_stmts(&mut f, enclosing, loop_depth, label_depths, next_local_id); + process_stmts( + &mut f, + enclosing, + loop_depth, + switch_depth, + label_depths, + next_local_id, + ); f }); out.push(Stmt::Try { @@ -333,11 +426,19 @@ fn process_stmts( &mut then_branch, enclosing, loop_depth, + switch_depth, label_depths, next_local_id, ); if let Some(eb) = &mut else_branch { - process_stmts(eb, enclosing, loop_depth, label_depths, next_local_id); + process_stmts( + eb, + enclosing, + loop_depth, + switch_depth, + label_depths, + next_local_id, + ); } out.push(Stmt::If { condition, @@ -353,6 +454,7 @@ fn process_stmts( &mut body, enclosing, loop_depth + 1, + switch_depth, label_depths, next_local_id, ); @@ -366,6 +468,7 @@ fn process_stmts( &mut body, enclosing, loop_depth + 1, + switch_depth, label_depths, next_local_id, ); @@ -383,6 +486,7 @@ fn process_stmts( &mut single, enclosing, loop_depth, + switch_depth, label_depths, next_local_id, ); @@ -394,6 +498,7 @@ fn process_stmts( &mut body, enclosing, loop_depth + 1, + switch_depth, label_depths, next_local_id, ); @@ -409,8 +514,13 @@ fn process_stmts( // `break LABEL` filters finallies is the depth INSIDE // the loop (loop_depth + 1). If it's a non-loop // labeled block, the threshold is loop_depth itself. + // All labels in a chain target the same terminal loop. + let mut target = body.as_ref(); + while let Stmt::Labeled { body, .. } = target { + target = body.as_ref(); + } let is_loop = matches!( - body.as_ref(), + target, Stmt::For { .. } | Stmt::While { .. } | Stmt::DoWhile { .. } ); let label_threshold = if is_loop { loop_depth + 1 } else { loop_depth }; @@ -420,6 +530,7 @@ fn process_stmts( &mut single, enclosing, loop_depth, + switch_depth, label_depths, next_local_id, ); @@ -445,6 +556,7 @@ fn process_stmts( &mut case.body, enclosing, loop_depth, + switch_depth + 1, label_depths, next_local_id, ); @@ -468,6 +580,7 @@ fn inline_finallies( enclosing: &[EnclosingFinally], count: usize, loop_depth: usize, + switch_depth: usize, label_depths: &mut HashMap, next_local_id: &mut LocalId, ) { @@ -488,6 +601,7 @@ fn inline_finallies( &mut cloned, outer_slice, loop_depth, + switch_depth, label_depths, next_local_id, ); @@ -503,13 +617,20 @@ fn inline_finallies_for_break( out: &mut Vec, enclosing: &[EnclosingFinally], loop_depth: usize, + switch_depth: usize, + is_continue: bool, label_depths: &mut HashMap, next_local_id: &mut LocalId, ) { let count = enclosing .iter() .rev() - .take_while(|f| f.loop_depth_at_push >= loop_depth) + .take_while(|f| { + f.loop_depth_at_push >= loop_depth + && (is_continue + || f.loop_depth_at_push > loop_depth + || f.switch_depth_at_push >= switch_depth) + }) .count(); if count > 0 { inline_finallies( @@ -517,6 +638,7 @@ fn inline_finallies_for_break( enclosing, count, loop_depth, + switch_depth, label_depths, next_local_id, ); @@ -531,6 +653,7 @@ fn inline_finallies_for_labeled( out: &mut Vec, enclosing: &[EnclosingFinally], label: &str, + switch_depth: usize, label_depths: &mut HashMap, next_local_id: &mut LocalId, ) { @@ -567,6 +690,7 @@ fn inline_finallies_for_labeled( &mut cloned, outer_slice, inline_loop_depth, + switch_depth, label_depths, next_local_id, ); @@ -644,7 +768,7 @@ fn walk_stmt_exprs(stmt: &mut Stmt, f: &mut F) { fn process_expr_closure_bodies(expr: &mut Expr, next_local_id: &mut LocalId) { if let Expr::Closure { body, .. } = expr { let mut label_depths: HashMap = HashMap::new(); - process_stmts(body, &[], 0, &mut label_depths, next_local_id); + process_stmts(body, &[], 0, 0, &mut label_depths, next_local_id); } perry_hir::walker::walk_expr_children_mut(expr, &mut |e| { process_expr_closure_bodies(e, next_local_id) diff --git a/crates/perry-transform/src/inline/call_inliner.rs b/crates/perry-transform/src/inline/call_inliner.rs index 759d88e212..37eb94a19c 100644 --- a/crates/perry-transform/src/inline/call_inliner.rs +++ b/crates/perry-transform/src/inline/call_inliner.rs @@ -338,6 +338,9 @@ fn loop_invariant_seed_facts( .collect() } +// The preservation policy belongs to the current traversal. Numeric field +// loops enable it and recursive inlining carries it into newly spliced blocks. +// A kept call retains the normal effect boundary and region recheck. pub fn inline_calls_in_stmts( stmts: &mut Vec, func_candidates: &HashMap, @@ -348,6 +351,7 @@ pub fn inline_calls_in_stmts( next_local_id: &mut LocalId, enclosing_class: Option<&str>, class_field_types: &HashMap<(String, String), String>, + preserve_loop_closures: bool, ) { // Same cap as `inline_calls_in_expr`, sharing one thread-local depth // budget: the two functions are mutually recursive, and a stmts-side @@ -394,6 +398,7 @@ pub fn inline_calls_in_stmts( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, ) .and_then(|(inlined_stmts, _result_expr)| discard_inlined_returns(inlined_stmts)); if inlined.is_some() { @@ -408,6 +413,7 @@ pub fn inline_calls_in_stmts( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, ); if !hoisted.is_empty() { // Hoisted stmts from multi-stmt inlining inside expressions @@ -495,6 +501,7 @@ pub fn inline_calls_in_stmts( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, ) { let has_nested_return = inlined_stmts .iter() @@ -559,6 +566,7 @@ pub fn inline_calls_in_stmts( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, ), _ => Vec::new(), }; @@ -584,6 +592,7 @@ pub fn inline_calls_in_stmts( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, ); if !hoisted.is_empty() { let current = stmts.remove(i); @@ -612,6 +621,7 @@ pub fn inline_calls_in_stmts( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, ); if hoisted.is_empty() { *condition = condition_candidate; @@ -630,6 +640,7 @@ pub fn inline_calls_in_stmts( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, ); let mut else_facts = after_condition_facts; if let Some(else_b) = else_branch { @@ -643,6 +654,7 @@ pub fn inline_calls_in_stmts( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, ); } *exact_receiver_facts = intersect_exact_receiver_facts(&then_facts, &else_facts); @@ -660,12 +672,15 @@ pub fn inline_calls_in_stmts( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, ); if hoisted.is_empty() { *condition = condition_candidate; } let mut body_facts = loop_invariant_seed_facts(exact_receiver_facts, body, &[&*condition]); + let loop_preserve_closures = + preserve_loop_closures || super::numeric_loop::contains_field_arithmetic(body); inline_calls_in_stmts( body, func_candidates, @@ -676,6 +691,7 @@ pub fn inline_calls_in_stmts( next_local_id, enclosing_class, class_field_types, + loop_preserve_closures, ); exact_receiver_facts.clear(); exact_effect_handled = true; @@ -683,6 +699,8 @@ pub fn inline_calls_in_stmts( Stmt::DoWhile { body, condition } => { let mut body_facts = loop_invariant_seed_facts(exact_receiver_facts, body, &[&*condition]); + let loop_preserve_closures = + preserve_loop_closures || super::numeric_loop::contains_field_arithmetic(body); inline_calls_in_stmts( body, func_candidates, @@ -693,6 +711,7 @@ pub fn inline_calls_in_stmts( next_local_id, enclosing_class, class_field_types, + loop_preserve_closures, ); let mut empty_facts = ExactReceiverFacts::new(); let mut condition_candidate = condition.clone(); @@ -705,6 +724,7 @@ pub fn inline_calls_in_stmts( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, ); if hoisted.is_empty() { *condition = condition_candidate; @@ -731,6 +751,7 @@ pub fn inline_calls_in_stmts( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, ); if init_stmts.len() == 1 { **init_stmt = init_stmts.remove(0); @@ -748,6 +769,7 @@ pub fn inline_calls_in_stmts( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, ); if hoisted.is_empty() { *cond = condition_candidate; @@ -764,6 +786,7 @@ pub fn inline_calls_in_stmts( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, ); } let mut for_extra: Vec<&Expr> = Vec::new(); @@ -789,6 +812,8 @@ pub fn inline_calls_in_stmts( ); body_facts.retain(|id, _| !init_mutated.contains(id)); } + let loop_preserve_closures = + preserve_loop_closures || super::numeric_loop::contains_field_arithmetic(body); inline_calls_in_stmts( body, func_candidates, @@ -799,6 +824,7 @@ pub fn inline_calls_in_stmts( next_local_id, enclosing_class, class_field_types, + loop_preserve_closures, ); exact_receiver_facts.clear(); exact_effect_handled = true; @@ -833,6 +859,7 @@ pub fn inline_calls_in_stmts( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, ); stmts.remove(i); let inlined_len = inlined.len(); @@ -860,6 +887,7 @@ pub fn inline_calls_in_expr( next_local_id: &mut LocalId, enclosing_class: Option<&str>, class_field_types: &HashMap<(String, String), String>, + preserve_loop_closures: bool, ) -> Vec { let Some(_recursion_guard) = enter_inline_expr_recursion() else { // The inliner is an optimization pass. Very deeply nested generated @@ -881,6 +909,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, ) { apply_exact_receiver_stmt_effects(&stmts, exact_receiver_facts); let inner = inline_calls_in_expr( @@ -892,6 +921,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, ); *expr = result; let mut all = stmts; @@ -912,6 +942,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); hoisted.extend(inline_calls_in_expr( right, @@ -922,6 +953,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); } Expr::Logical { left, right, .. } => { @@ -934,6 +966,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); let after_left_facts = exact_receiver_facts.clone(); @@ -948,6 +981,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, ); if right_hoisted.is_empty() { **right = right_candidate; @@ -968,6 +1002,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); } Expr::Conditional { @@ -984,6 +1019,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); let after_condition_facts = exact_receiver_facts.clone(); @@ -999,6 +1035,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, ); if then_hoisted.is_empty() { **then_expr = then_candidate; @@ -1018,6 +1055,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, ); if else_hoisted.is_empty() { **else_expr = else_candidate; @@ -1039,6 +1077,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); for arg in args.iter_mut() { hoisted.extend(inline_calls_in_expr( @@ -1050,6 +1089,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); } exact_receiver_facts.clear(); @@ -1065,6 +1105,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); kill_referenced_exact_receivers(elem, exact_receiver_facts); } @@ -1080,6 +1121,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); kill_referenced_exact_receivers(v, exact_receiver_facts); } @@ -1095,6 +1137,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); kill_referenced_exact_receivers(v, exact_receiver_facts); } @@ -1112,6 +1155,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); kill_referenced_exact_receivers(e, exact_receiver_facts); } @@ -1129,6 +1173,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); for arg in args.iter_mut() { match arg { @@ -1142,6 +1187,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); } } @@ -1158,6 +1204,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); hoisted.extend(inline_calls_in_expr( index, @@ -1168,6 +1215,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); } Expr::IndexSet { @@ -1184,6 +1232,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); hoisted.extend(inline_calls_in_expr( index, @@ -1194,6 +1243,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); hoisted.extend(inline_calls_in_expr( value, @@ -1204,6 +1254,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); exact_receiver_facts.clear(); } @@ -1228,6 +1279,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); } exact_receiver_facts.clear(); @@ -1242,6 +1294,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); } Expr::PropertySet { object, value, .. } => { @@ -1254,6 +1307,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); hoisted.extend(inline_calls_in_expr( value, @@ -1264,6 +1318,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); exact_receiver_facts.clear(); } @@ -1277,6 +1332,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); exact_receiver_facts.remove(id); kill_referenced_exact_receivers(value.as_ref(), exact_receiver_facts); @@ -1292,6 +1348,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); } for arg in args.iter_mut() { @@ -1304,6 +1361,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); } exact_receiver_facts.clear(); @@ -1320,6 +1378,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); hoisted.extend(inline_calls_in_expr( index, @@ -1330,6 +1389,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); } Expr::Uint8ArraySet { @@ -1346,6 +1406,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); hoisted.extend(inline_calls_in_expr( index, @@ -1356,6 +1417,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); hoisted.extend(inline_calls_in_expr( value, @@ -1366,6 +1428,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); kill_referenced_exact_receivers(array.as_ref(), exact_receiver_facts); kill_referenced_exact_receivers(index.as_ref(), exact_receiver_facts); @@ -1381,6 +1444,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); } Expr::Uint8ArrayNew(Some(arg)) => { @@ -1393,6 +1457,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); } Expr::Sequence(exprs) => { @@ -1426,6 +1491,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, )); continue; } @@ -1441,6 +1507,7 @@ pub fn inline_calls_in_expr( next_local_id, enclosing_class, class_field_types, + preserve_loop_closures, ); if item_hoisted.is_empty() { *item = candidate; @@ -1495,6 +1562,7 @@ pub fn inline_calls_in_expr( next_local_id, closure_enclosing.as_deref(), class_field_types, + preserve_loop_closures, ); for id in captures.iter().chain(mutable_captures.iter()) { exact_receiver_facts.remove(id); @@ -1606,11 +1674,15 @@ pub fn try_inline_simple_call( next_local_id: &mut LocalId, _enclosing_class: Option<&str>, class_field_types: &HashMap<(String, String), String>, + preserve_loop_closures: bool, ) -> Option<(Vec, Expr)> { if let Expr::Call { callee, args, .. } = expr { // Check for regular function call if let Expr::FuncRef(func_id) = callee.as_ref() { if let Some(func) = func_candidates.get(func_id) { + if preserve_loop_closures && super::numeric_loop::introduces_closure(func) { + return None; + } // Loop-bearing candidates are admitted only for the dedicated // fixed-aggregate path in `try_inline_call`. Expression-level // inlining has nowhere to place their control flow. @@ -1763,6 +1835,11 @@ pub fn try_inline_simple_call( if let Some(method_candidate) = method_candidates.get(&(class_name, method_name.clone())) { + if preserve_loop_closures + && super::numeric_loop::introduces_closure(&method_candidate.func) + { + return None; + } // Preserve normal `obj.method` lookup and argument // evaluation. Direct substitution would bypass // own-property/accessor shadows and zip() would drop @@ -2052,11 +2129,15 @@ pub fn try_inline_call( next_local_id: &mut LocalId, _enclosing_class: Option<&str>, class_field_types: &HashMap<(String, String), String>, + preserve_loop_closures: bool, ) -> Option<(Vec, Option)> { if let Expr::Call { callee, args, .. } = expr { // Handle regular function calls if let Expr::FuncRef(func_id) = callee.as_ref() { if let Some(func) = func_candidates.get(func_id) { + if preserve_loop_closures && super::numeric_loop::introduces_closure(func) { + return None; + } let scalar_aggregate = if has_simple_control_flow(&func.body) { None } else { @@ -2197,6 +2278,11 @@ pub fn try_inline_call( if let Some(method_candidate) = method_candidates.get(&(class_name, method_name.clone())) { + if preserve_loop_closures + && super::numeric_loop::introduces_closure(&method_candidate.func) + { + return None; + } // Preserve normal `obj.method` lookup and argument // evaluation. Direct substitution would bypass // own-property/accessor shadows and zip() would drop @@ -2365,6 +2451,7 @@ mod tests { &mut next_local_id, None, &HashMap::new(), + false, ); assert!(hoisted.is_empty()); @@ -2424,6 +2511,7 @@ mod tests { &mut next_local_id, None, &HashMap::new(), + false, ); assert_eq!(stmts.len(), 1); diff --git a/crates/perry-transform/src/inline/discarded_result.rs b/crates/perry-transform/src/inline/discarded_result.rs index a54f13cf69..15230148c4 100644 --- a/crates/perry-transform/src/inline/discarded_result.rs +++ b/crates/perry-transform/src/inline/discarded_result.rs @@ -486,6 +486,7 @@ mod tests { &mut next_local_id, None, &HashMap::new(), + false, ) }; diff --git a/crates/perry-transform/src/inline/mod.rs b/crates/perry-transform/src/inline/mod.rs index 168f44ccdf..b0d5239ae2 100644 --- a/crates/perry-transform/src/inline/mod.rs +++ b/crates/perry-transform/src/inline/mod.rs @@ -17,6 +17,7 @@ mod exact_receivers; mod extern_imports; mod factory_specialize; mod imul; +mod numeric_loop; mod substitute; mod super_detect; @@ -703,6 +704,7 @@ fn inline_functions_inner( &mut next_local_id, None, &class_field_types, + false, ); } @@ -738,6 +740,7 @@ fn inline_functions_inner( &mut local_id, None, &class_field_types, + false, ); next_module_id = local_id; } @@ -787,6 +790,7 @@ fn inline_functions_inner( &mut local_id, Some(&class_name), &class_field_types, + false, ); next_module_id = local_id; } diff --git a/crates/perry-transform/src/inline/numeric_loop.rs b/crates/perry-transform/src/inline/numeric_loop.rs new file mode 100644 index 0000000000..65c8bef430 --- /dev/null +++ b/crates/perry-transform/src/inline/numeric_loop.rs @@ -0,0 +1,243 @@ +//! Keep numeric field loops available for guarded region versioning. +//! +//! Codegen cannot lower a closure creation twice under the same function ID. +//! Moving one into a loop by inlining therefore prevents the entire loop from +//! being versioned. Leave that callee behind a normal call instead: region +//! planning already treats the call as an effect boundary and rechecks its +//! receiver facts before subsequent field operations. + +#[cfg(test)] +use std::collections::HashMap; + +#[cfg(test)] +use perry_hir::types::FuncId; +use perry_hir::walker::{stmt_any_expr, walk_expr_children}; +use perry_hir::{Expr, Function, Stmt}; + +fn expr_any(expr: &Expr, predicate: fn(&Expr) -> bool) -> bool { + if predicate(expr) { + return true; + } + let mut found = false; + walk_expr_children(expr, &mut |child| found |= expr_any(child, predicate)); + found +} + +pub(super) fn contains_field_arithmetic(stmts: &[Stmt]) -> bool { + // Accumulate both facts in one traversal. Re-scanning each binary's + // children would make a long local-only arithmetic chain quadratic. + fn inspect(expr: &Expr) -> (bool, bool) { + let mut has_field = matches!(expr, Expr::PropertyGet { object, .. } + if matches!(object.as_ref(), Expr::LocalGet(_) | Expr::This)); + let mut has_arithmetic = false; + walk_expr_children(expr, &mut |child| { + let (field, arithmetic) = inspect(child); + has_field |= field; + has_arithmetic |= arithmetic; + }); + has_arithmetic |= has_field && matches!(expr, Expr::Binary { .. }); + (has_field, has_arithmetic) + } + stmts + .iter() + .any(|stmt| stmt_any_expr(stmt, &mut |expr| inspect(expr).1)) +} + +pub(super) fn introduces_closure(function: &Function) -> bool { + fn closure(expr: &Expr) -> bool { + matches!(expr, Expr::Closure { .. }) + } + function + .body + .iter() + .any(|stmt| stmt_any_expr(stmt, &mut |expr| expr_any(expr, closure))) + || function + .params + .iter() + .filter_map(|param| param.default.as_ref()) + .any(|expr| expr_any(expr, closure)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::inline::call_inliner::inline_calls_in_stmts; + use crate::inline::ExactReceiverFacts; + use perry_hir::types::Type; + use perry_hir::BinaryOp; + + fn function(id: FuncId, body: Vec) -> Function { + Function { + id, + name: format!("f{id}"), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Void, + body, + is_async: false, + is_generator: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + is_strict: true, + } + } + + fn closure() -> Expr { + Expr::Closure { + func_id: 99, + params: Vec::new(), + return_type: Type::Number, + body: vec![Stmt::Return(Some(Expr::Integer(3)))], + captures: Vec::new(), + mutable_captures: Vec::new(), + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: false, + is_async: false, + is_generator: false, + is_strict: true, + } + } + + fn call(id: FuncId) -> Stmt { + Stmt::Expr(Expr::Call { + callee: Box::new(Expr::FuncRef(id)), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + }) + } + + fn arithmetic(field: bool) -> Stmt { + Stmt::Expr(Expr::Binary { + op: BinaryOp::Mul, + left: Box::new(if field { + Expr::PropertyGet { + object: Box::new(Expr::LocalGet(1)), + property: "a".into(), + byte_offset: 0, + } + } else { + Expr::LocalGet(1) + }), + right: Box::new(Expr::Integer(1)), + }) + } + + fn run(stmts: &mut Vec) { + let functions = HashMap::from([ + // Closure nested in a dedicated HIR expression and an if branch. + ( + 1, + function( + 1, + vec![Stmt::If { + condition: Expr::Bool(true), + then_branch: vec![Stmt::Expr(Expr::ArrayMap { + array: Box::new(Expr::LocalGet(7)), + callback: Box::new(closure()), + })], + else_branch: None, + }], + ), + ), + ( + 2, + function( + 2, + vec![Stmt::Expr(Expr::LocalSet(8, Box::new(Expr::Integer(123))))], + ), + ), + (3, function(3, vec![call(1)])), + ]); + inline_calls_in_stmts( + stmts, + &functions, + &HashMap::new(), + &HashMap::new(), + &mut HashMap::new(), + &mut ExactReceiverFacts::new(), + &mut 100, + None, + &HashMap::new(), + false, + ); + } + + fn has_call(stmts: &[Stmt], id: FuncId) -> bool { + stmts.iter().any(|stmt| stmt_any_expr(stmt, &mut |expr| { + fn check(expr: &Expr, id: FuncId) -> bool { + if matches!(expr, Expr::Call { callee, .. } if matches!(callee.as_ref(), Expr::FuncRef(n) if *n == id)) { + return true; + } + let mut found = false; + walk_expr_children(expr, &mut |child| found |= check(child, id)); + found + } + check(expr, id) + })) + } + + #[test] + fn field_loop_retains_closure_call_but_inlines_tiny_calls() { + for kind in 0..3 { + let body = vec![arithmetic(true), call(1), call(2)]; + let stmt = match kind { + 0 => Stmt::For { + init: None, + condition: Some(Expr::Bool(true)), + update: None, + body, + }, + 1 => Stmt::While { + condition: Expr::Bool(true), + body, + }, + _ => Stmt::DoWhile { + condition: Expr::Bool(true), + body, + }, + }; + let mut stmts = vec![stmt]; + run(&mut stmts); + assert!(has_call(&stmts, 1)); + assert!(!has_call(&stmts, 2)); + assert!(!introduces_closure(&function(5, stmts))); + } + } + + #[test] + fn field_loop_preserves_helper_boundary_after_recursive_inline() { + let mut stmts = vec![Stmt::While { + condition: Expr::Bool(true), + body: vec![arithmetic(true), call(3)], + }]; + run(&mut stmts); + assert!(!has_call(&stmts, 3)); + assert!(has_call(&stmts, 1)); + assert!(!introduces_closure(&function(5, stmts))); + } + + #[test] + fn outside_loop_closure_call_still_inlines() { + let mut stmts = vec![arithmetic(true), call(1)]; + run(&mut stmts); + assert!(!has_call(&stmts, 1)); + assert!(introduces_closure(&function(5, stmts))); + } + + #[test] + fn loop_without_field_arithmetic_still_inlines_closure_call() { + let mut stmts = vec![Stmt::While { + condition: Expr::Bool(true), + body: vec![arithmetic(false), call(1)], + }]; + run(&mut stmts); + assert!(!has_call(&stmts, 1)); + assert!(introduces_closure(&function(5, stmts))); + } +} diff --git a/crates/perry/src/commands/compile.rs b/crates/perry/src/commands/compile.rs index 17e5c18ac3..6f0f7fb14a 100644 --- a/crates/perry/src/commands/compile.rs +++ b/crates/perry/src/commands/compile.rs @@ -191,7 +191,18 @@ pub fn run( // Retain parsed ASTs for the lifetime of this build so large source-first // graphs do not reread and reparse every module during that pass. let mut parse_cache = ParseCache::with_capacity(usize::MAX); - run_with_parse_cache(args, Some(&mut parse_cache), format, use_color, verbose) + let result = run_with_parse_cache(args, Some(&mut parse_cache), format, use_color, verbose)?; + // Batch builds retain a parse cache too. Report their object-cache + // statistics here, as dev does for its separate long-lived parse cache. + if std::env::var("PERRY_DEV_VERBOSE").ok().as_deref() == Some("1") { + if let Some((hits, misses, _, _)) = result.codegen_cache_stats { + let total = hits + misses; + if total > 0 { + eprintln!(" • codegen cache: {hits}/{total} hit ({misses} miss)"); + } + } + } + Ok(result) } #[cfg(test)] diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index 6075eace7f..d4ffa32568 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -137,6 +137,8 @@ const BUILD_CACHE_ENV_VARS: &[&str] = &[ // `PERRY_AGENT_PTR_ACCESS=call` forces the call-based agent block access; // both change the emitted IR. "PERRY_METHOD_SITE", + // Step 5C marks executable body infos for ConstFn shape admission. + "PERRY_CONSTFN_SHAPE", "PERRY_AGENT_PTR_ACCESS", // #8583: the relocation estimate above which a function spills its GC roots // to a shadow frame. It changes which functions carry statepoints, so it diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index f0cea28b77..0ee606c85f 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -325,6 +325,15 @@ fn compute_object_cache_key_with_env( "0" }, ); + // Both values change objects outside the module containing the launch. + h.field( + "thread_literal_tls", + if perry_codegen::program_has_thread_agents() { + "1" + } else { + "0" + }, + ); // Immutable-global transfer (perry-codegen codegen/global_transfer.rs) // changes every eligible binding's storage, reads and initializer in // modules that never launch a thread; versioned with its runtime ABI. @@ -336,6 +345,14 @@ fn compute_object_cache_key_with_env( "0" }, ); + // First prefix is the callback owner, so order changes the launch symbol + // and which module defines the graph body even with unchanged membership. + // Module identity is already in the HIR hash; no additional owner flag is + // needed. Hash the full ordered vector for owner and graph invalidation. + h.field( + "thread_literal_modules", + &format!("{:?}", opts.thread_literal_module_prefixes), + ); // Design step 4 (DESIGN 7.2): the static ShapeIds this module's code // embeds as immediates. A cached object is reused exactly when every id // it embeds is unchanged. @@ -1237,6 +1254,10 @@ fn compute_object_cache_key_with_env( "env_method_site", env_var("PERRY_METHOD_SITE").as_deref().unwrap_or(""), ); + h.field( + "env_constfn_shape", + env_var("PERRY_CONSTFN_SHAPE").as_deref().unwrap_or(""), + ); h.field( "env_agent_ptr_access", env_var("PERRY_AGENT_PTR_ACCESS").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 0a8f4c6b2d..9997f21b30 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 @@ -16,6 +16,7 @@ fn empty_opts() -> CompileOptions { target: Some("aarch64-apple-darwin".to_string()), is_entry_module: false, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), @@ -767,6 +768,7 @@ fn key_changes_with_codegen_env_vars() { "PERRY_FULL_OUTLINE_IC", "PERRY_FULL_OUTLINE_IC_MIN_FUNCS", "PERRY_OUTLINE_METHOD_DISPATCH", + "PERRY_CONSTFN_SHAPE", "PERRY_INLINE_NEW", "PERRY_INLINE_CTOR", "PERRY_STRING_INIT_CHUNK_SIZE", @@ -899,6 +901,7 @@ fn static_seeds_round_trip_and_an_entry_without_them_misses() { proto: perry_codegen::BirthProto::Literal, typed: None, rep: 0b0101, + constfn: Vec::new(), }, ); cache.store_static_seeds(key, &[line.as_str()]); @@ -911,6 +914,39 @@ fn static_seeds_round_trip_and_an_entry_without_them_misses() { ); } +/// A cache entry's stable body symbol must survive the cold write and warm +/// read byte for byte. This does not publish a ConstFn seed: emission remains +/// gated until the runtime's body-aware seed mint matches module init. +#[test] +fn constfn_body_sidecar_survives_a_warm_cache_hit_without_publishing_it() { + let dir = tempdir().unwrap(); + let cache = ObjectCache::new(dir.path(), "test-target", true); + let key = 0x1165_3002; + let shape = perry_codegen::BirthShape { + keys: b"method\0".to_vec(), + key_count: 1, + live: 1, + proto: perry_codegen::BirthProto::Literal, + typed: None, + rep: 0b11, + constfn: vec![perry_codegen::ConstFnBirth { + slot: 0, + symbol: "perry_closure_m__method$info".to_string(), + }], + }; + let line = perry_codegen::encode_static_seed(0x1000_0044, &shape); + cache.store_ffi_manifest(key, &[]); + cache.store_static_seeds(key, &[line.as_str()]); + cache.store(key, b"object bytes"); + let (_, _, warm_lines) = cache.lookup_path_with_ffi(key).expect("warm hit"); + assert_eq!(warm_lines, vec![line]); + assert_eq!( + perry_codegen::decode_static_seed(&warm_lines[0]), + Some((0x1000_0044, shape)) + ); + assert_eq!(perry_codegen::STATIC_SEED_FORMAT, "3"); +} + /// #6439 regression: an object written by a pre-manifest perry has no /// recoverable FFI provenance. Treating it as a hit would replay zero /// symbols and silently drop `perry-ext-ws` from the link line — the @@ -1231,3 +1267,32 @@ fn key_changes_with_defining_constructor_contract() { compute_object_cache_key(&opts, 123, "same-version") ); } + +#[test] +fn thread_literal_graph_owner_order_changes_stable_module_key() { + let mut a = empty_opts(); + a.thread_literal_module_prefixes = vec!["entry_ts".into(), "helper_ts".into()]; + let mut b = empty_opts(); + b.thread_literal_module_prefixes = vec!["helper_ts".into(), "entry_ts".into()]; + // Stable module HIR/entry role; only the graph callback's symbol owner moves. + assert_ne!( + compute_object_cache_key(&a, 1, "0.5.156"), + compute_object_cache_key(&b, 1, "0.5.156") + ); +} + +#[test] +fn thread_literal_graph_membership_changes_stable_module_key() { + let mut a = empty_opts(); + a.thread_literal_module_prefixes = vec!["entry_ts".into(), "helper_ts".into()]; + let mut b = empty_opts(); + b.thread_literal_module_prefixes = vec![ + "entry_ts".into(), + "helper_ts".into(), + "new_deferred_ts".into(), + ]; + assert_ne!( + compute_object_cache_key(&a, 1, "0.5.156"), + compute_object_cache_key(&b, 1, "0.5.156") + ); +} diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 29f8240f16..e42e9a3ce2 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -1128,6 +1128,28 @@ pub fn run_with_parse_cache( classify_eager_modules(&mut ctx, &entry_path); + let sanitize_name = |s: &str| -> String { + let mut out: String = s + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' { + c + } else { + '_' + } + }) + .collect(); + if out + .chars() + .next() + .map(|c| c.is_ascii_digit()) + .unwrap_or(false) + { + out.insert(0, '_'); + } + out + }; + // #10399: whole-program Worker detection, before ANY module codegen runs. // // A `worker_threads` worker is a real OS thread sharing this address @@ -1165,6 +1187,27 @@ pub fn run_with_parse_cache( found }); perry_codegen::set_program_has_thread_agents(program_has_thread_agents); + let thread_literal_module_prefixes: Vec = if program_has_thread_agents { + let mut prefixes: Vec<_> = ctx + .native_modules + .values() + .map(|module| sanitize_name(&module.name)) + .collect(); + // Match prepare_module's actual entry path and codegen's sanitized + // HIR identity. Entry need not sort first, and may have no launches. + let owner = ctx + .native_modules + .get(&entry_path) + .map(|module| sanitize_name(&module.name)) + .ok_or_else(|| anyhow!("thread literal callback entry is not a native module"))?; + prefixes.sort(); + prefixes.dedup(); + prefixes.retain(|prefix| prefix != &owner); + prefixes.insert(0, owner); + prefixes + } else { + Vec::new() + }; // #11394: every method name the program writes onto a builtin prototype; // codegen routes those calls through a lookup-first runtime entry. perry_codegen::set_program_patched_proto_methods(perry_hir::patched_prototype_methods( @@ -3365,27 +3408,7 @@ pub fn run_with_parse_cache( // identifiers cannot start with a digit, so prefix with // `_` if the first character would be one (handles module // names like `05_fibonacci.ts`). - let sanitize_name = |s: &str| -> String { - let mut out: String = s - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c == '_' { - c - } else { - '_' - } - }) - .collect(); - if out - .chars() - .next() - .map(|c| c.is_ascii_digit()) - .unwrap_or(false) - { - out.insert(0, '_'); - } - out - }; + // CRITICAL: iterate `non_entry_module_names` (topologically // sorted above) rather than `ctx.native_modules` — the latter // is a `BTreeMap` and iterates in alphabetical @@ -5538,6 +5561,7 @@ pub fn run_with_parse_cache( target: resolved_triple, is_entry_module: is_entry, non_entry_module_prefixes, + thread_literal_module_prefixes: thread_literal_module_prefixes.clone(), import_function_prefixes, import_function_ffi_aliases, import_function_origin_names, @@ -5696,7 +5720,8 @@ pub fn run_with_parse_cache( ctx.native_modules .par_iter() .map(|(path, hir_module)| -> Result<_, String> { - if hir_module.classes.is_empty() + if std::env::var("PERRY_CONSTFN_SHAPE").as_deref() != Ok("1") + && hir_module.classes.is_empty() && prepare_module(path, hir_module, true)? .imported_classes .is_empty() @@ -5721,6 +5746,21 @@ pub fn run_with_parse_cache( .iter() .flat_map(|(_, b)| b.iter().map(|m| &m.shape)), ); + // Completed guard facts stay separate from class allocation suppliers. + // Include a foreign class's final content in an importer's cache inputs, + // so warm guards cannot retain an older body/rep/id assignment. + let mut class_final_shapes: BTreeMap> = + BTreeMap::new(); + for (shape, &id) in &static_shape_ids { + if let perry_codegen::BirthProto::Class(cid) = shape.proto { + if !shape.constfn.is_empty() { + class_final_shapes + .entry(cid) + .or_default() + .push((shape.clone(), id)); + } + } + } // Decision 16: each class's id as its DEFINING module assigns it. Every // module gets the slice it can name (its classes and stubs, plus the // producer classes of its short-spread candidates); the slice is in its @@ -5802,11 +5842,19 @@ pub fn run_with_parse_cache( ); if let Some((ids, class_ids)) = static_shape_ids_by_module.get(path) { opts.static_shape_ids = ids.clone(); - let foreign = opts + let foreign: BTreeSet = opts .short_spread_method_candidates .values() .flatten() - .map(|c| c.class_id); + .map(|c| c.class_id) + .collect(); + for cid in class_ids.iter().chain(foreign.iter()) { + if let Some(finals) = class_final_shapes.get(cid) { + opts.static_shape_ids.extend(finals.iter().cloned()); + } + } + opts.static_shape_ids.sort(); + opts.static_shape_ids.dedup(); opts.program_class_shape_ids = program_class_shape_ids .restricted_to(class_ids.iter().copied().chain(foreign)); } diff --git a/crates/perry/tests/shape_record_lookup_in_bound.rs b/crates/perry/tests/shape_record_lookup_in_bound.rs new file mode 100644 index 0000000000..3bc7f61fbf --- /dev/null +++ b/crates/perry/tests/shape_record_lookup_in_bound.rs @@ -0,0 +1,175 @@ +//! `"a" in o` on a megamorphic receiver set reads the receiver's shape record +//! by id without resolving the runtime state. +//! +//! One `in` site meets sixteen literal shapes `{a, kN}`. Each test asks the +//! shape record of the receiver's ShapeId, which the lookup finds through the +//! agent's thread-local shape directory: one load of the band's directory, a +//! band select and two dependent loads, with no `state()` fetch and no +//! presence branch (an absent id reads the shared empty record). Resolving +//! the runtime state and walking the slab per lookup cost 973.1 +//! instructions/op on x86_64; the directory read measures 872.7. +//! +//! The bound is on user-space instructions per op, measured as the +//! difference between a 5,000,000- and a 500,000-op run (fixed costs +//! cancel). It needs the hardware instruction counter: on a host without one +//! it fails, except under CI (`CI` set), where it says so and returns. The +//! program links the runtime as built (`PERRY_NO_AUTO_OPTIMIZE=1`), so the +//! count is the runtime under test (`PERRY_RUNTIME_DIR`, a release build). +// The instruction bound above was calibrated on x86_64. +#![cfg(all(target_os = "linux", target_arch = "x86_64"))] + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +const IN_OP: &str = r#" +function mk(i: number): any { + if (i === 0) return { a: 1, k0: 0 }; + if (i === 1) return { a: 1, k1: 1 }; + if (i === 2) return { a: 1, k2: 2 }; + if (i === 3) return { a: 1, k3: 3 }; + if (i === 4) return { a: 1, k4: 4 }; + if (i === 5) return { a: 1, k5: 5 }; + if (i === 6) return { a: 1, k6: 6 }; + if (i === 7) return { a: 1, k7: 7 }; + if (i === 8) return { a: 1, k8: 8 }; + if (i === 9) return { a: 1, k9: 9 }; + if (i === 10) return { a: 1, k10: 10 }; + if (i === 11) return { a: 1, k11: 11 }; + if (i === 12) return { a: 1, k12: 12 }; + if (i === 13) return { a: 1, k13: 13 }; + if (i === 14) return { a: 1, k14: 14 }; + return { a: 1, k15: 15 }; +} +const N = Number(process.argv[2]); +const xs: any[] = []; +for (let i = 0; i < 16; i++) xs.push(mk(i)); +function has(o: any): boolean { return "a" in o; } +let h = 0; +for (let k = 0; k < N; k++) { if (has(xs[k & 15])) h++; } +console.log(h); +"#; + +/// Instructions per op above which the by-id record lookup has gone back to +/// resolving the runtime state: the directory read measures 872.7 on x86_64, +/// the state walk 973.1. +const MAX_INSTRUCTIONS_PER_OP: f64 = 920.0; + +/// `perf_event_attr` up to `config1` (PERF_ATTR_SIZE_VER0, 64 bytes). +#[repr(C)] +#[derive(Default)] +struct PerfEventAttr { + type_: u32, + size: u32, + config: u64, + sample_period: u64, + sample_type: u64, + read_format: u64, + flags: u64, + wakeup_events: u32, + bp_type: u32, + config1: u64, +} + +const PERF_TYPE_HARDWARE: u32 = 0; +const PERF_COUNT_HW_INSTRUCTIONS: u64 = 1; +const FLAG_DISABLED: u64 = 1 << 0; +const FLAG_INHERIT: u64 = 1 << 1; +const FLAG_EXCLUDE_KERNEL: u64 = 1 << 5; +const FLAG_EXCLUDE_HV: u64 = 1 << 6; +const PERF_EVENT_IOC_ENABLE: libc::c_ulong = 0x2400; +const PERF_EVENT_IOC_DISABLE: libc::c_ulong = 0x2401; + +/// User-space instructions retired by `bin ` and everything it spawns: a +/// counter on this thread that its children inherit, enabled only around the +/// spawn and wait. `None` when the host has no usable counter. +fn instructions_of_run(bin: &std::path::Path, n: u64) -> Option { + let attr = PerfEventAttr { + type_: PERF_TYPE_HARDWARE, + size: std::mem::size_of::() as u32, + config: PERF_COUNT_HW_INSTRUCTIONS, + flags: FLAG_DISABLED | FLAG_INHERIT | FLAG_EXCLUDE_KERNEL | FLAG_EXCLUDE_HV, + ..Default::default() + }; + // SAFETY: a valid attr of the size it declares; pid 0 = this thread. + let fd = unsafe { + libc::syscall( + libc::SYS_perf_event_open, + &attr as *const PerfEventAttr, + 0 as libc::pid_t, + -1 as libc::c_int, + -1 as libc::c_int, + 0 as libc::c_ulong, + ) + } as libc::c_int; + if fd < 0 { + return None; + } + // SAFETY: `fd` is the counter opened above. + unsafe { libc::ioctl(fd, PERF_EVENT_IOC_ENABLE, 0) }; + let out = Command::new(bin).arg(n.to_string()).output(); + // SAFETY: as above; the count of a reaped child is folded into `fd`. + let mut count = 0u64; + let read = unsafe { + libc::ioctl(fd, PERF_EVENT_IOC_DISABLE, 0); + let r = libc::read(fd, &mut count as *mut u64 as *mut libc::c_void, 8); + libc::close(fd); + r + }; + let out = out.expect("run the fixture"); + assert!( + out.status.success(), + "fixture failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim(), + format!("{n}"), + "the fixture must print what node prints" + ); + (read == 8 && count > 0).then_some(count) +} + +#[test] +fn in_on_megamorphic_literals_reads_the_shape_record_from_the_agent_directory() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let bin = dir.path().join("in_op"); + std::fs::write(&entry, IN_OP).expect("write fixture"); + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&bin) + .env("PERRY_NO_CACHE", "1") + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\n{}", + String::from_utf8_lossy(&compile.stderr) + ); + let (small, big) = (500_000u64, 5_000_000u64); + let (Some(a), Some(b)) = ( + instructions_of_run(&bin, small), + instructions_of_run(&bin, big), + ) else { + if std::env::var_os("CI").is_some() { + eprintln!("SKIPPED: no hardware instruction counter on this CI host"); + return; + } + panic!("no hardware instruction counter (perf_event_open): the bound was not measured"); + }; + let per_op = (b as f64 - a as f64) / (big - small) as f64; + eprintln!("in_op: {per_op:.1} instructions/op"); + assert!( + per_op <= MAX_INSTRUCTIONS_PER_OP, + "{per_op:.1} instructions per megamorphic `in` (bound {MAX_INSTRUCTIONS_PER_OP}): \ + the by-id shape record lookup is resolving the runtime state again" + ); +} diff --git a/crates/perry/tests/thread_immutable_global_leaves.rs b/crates/perry/tests/thread_immutable_global_leaves.rs index e16d937c0f..89e3e01742 100644 --- a/crates/perry/tests/thread_immutable_global_leaves.rs +++ b/crates/perry/tests/thread_immutable_global_leaves.rs @@ -38,43 +38,50 @@ fn clean(cmd: &mut Command) -> &mut Command { cmd } -/// Compile `files` (first is the entry) and return the stdout of each mode, -/// after asserting every run exited 0 with no retired from-space report. +/// Compile `files` (first is the entry) for each ConstFn arm and return the +/// stdout of each mode, after asserting every run exited 0 with no retired +/// from-space report. fn run_all_modes(files: &[(&str, &str)]) -> Vec { - let dir = tempfile::tempdir().expect("tempdir"); - for (name, source) in files { - std::fs::write(dir.path().join(name), source).unwrap(); + let mut outputs = Vec::new(); + for constfn in ["0", "1"] { + let dir = tempfile::tempdir().expect("tempdir"); + for (name, source) in files { + std::fs::write(dir.path().join(name), source).unwrap(); + } + let exe = dir.path().join("main_bin"); + let compile = clean(&mut Command::new(perry_bin())) + .current_dir(dir.path()) + .env("PERRY_GC_INSTRUMENTS", "1") + .env("PERRY_NO_CACHE", "1") + .env("PERRY_CONSTFN_SHAPE", constfn) + .arg("compile") + .arg(dir.path().join(files[0].0)) + .arg("--no-auto-optimize") + .arg("-o") + .arg(&exe) + .output() + .expect("compile"); + assert!( + compile.status.success(), + "compile failed (ConstFn {constfn})\n{}", + String::from_utf8_lossy(&compile.stderr) + ); + for (mode, knobs) in [ + ("ordinary", Vec::new()), + ("manual", MOVING.to_vec()), + ("seed1", MOVING.iter().chain(SEED.iter()).copied().collect()), + ] { + outputs.push(run_once(&exe, dir.path(), constfn, mode, &knobs)); + } } - let exe = dir.path().join("main_bin"); - let compile = clean(&mut Command::new(perry_bin())) - .current_dir(dir.path()) - .env("PERRY_GC_INSTRUMENTS", "1") - .env("PERRY_NO_CACHE", "1") - .arg("compile") - .arg(dir.path().join(files[0].0)) - .arg("--no-auto-optimize") - .arg("-o") - .arg(&exe) - .output() - .expect("compile"); - assert!( - compile.status.success(), - "compile failed\n{}", - String::from_utf8_lossy(&compile.stderr) - ); - [ - ("ordinary", Vec::new()), - ("manual", MOVING.to_vec()), - ("seed1", MOVING.iter().chain(SEED.iter()).copied().collect()), - ] - .into_iter() - .map(|(mode, knobs)| run_once(&exe, dir.path(), mode, &knobs)) - .collect() + outputs } -fn run_once(exe: &Path, dir: &Path, mode: &str, knobs: &[(&str, &str)]) -> String { +fn run_once(exe: &Path, dir: &Path, constfn: &str, mode: &str, knobs: &[(&str, &str)]) -> String { let mut cmd = Command::new(exe); - clean(&mut cmd).current_dir(dir); + clean(&mut cmd) + .current_dir(dir) + .env("PERRY_CONSTFN_SHAPE", constfn); for (key, value) in knobs { cmd.env(key, value); } @@ -82,7 +89,7 @@ fn run_once(exe: &Path, dir: &Path, mode: &str, knobs: &[(&str, &str)]) -> Strin let stderr = String::from_utf8_lossy(&run.stderr); assert!( run.status.success() && !stderr.contains("RETIRED FROM-SPACE"), - "{mode}: status {:?}\nstdout:\n{}\nstderr:\n{stderr}", + "ConstFn {constfn} {mode}: status {:?}\nstdout:\n{}\nstderr:\n{stderr}", run.status, String::from_utf8_lossy(&run.stdout) ); @@ -375,3 +382,83 @@ console.log(await second); &format!("hop-module-once\n{line}\n{line}\n{line}\n"), ); } + +/// A worker that reads an eligible binding BEFORE its initializer ran sees the +/// uninitialized `undefined`, and must observe the published value on a later +/// read: a pending read is never cached as ready. Main runs the initializer +/// only after the worker reported its early read, so the order is fixed. +#[test] +fn a_read_before_the_initializer_stays_pending_and_sees_the_later_publication() { + let main = r#"import { spawn } from "perry/thread"; + +const shared = new SharedArrayBuffer(8); +const view = new Int32Array(shared); +// Hoisted reader of a binding whose initializer runs only after the worker read it once. +function readLate(): bigint { return late; } +const result = spawn((): string => { + const w = new Int32Array(shared); + const early = String(readLate()); + Atomics.store(w, 0, 1); + Atomics.notify(w, 0); + while (Atomics.load(w, 1) === 0) { + if (Atomics.wait(w, 1, 0, 10000) === "timed-out") { + throw new Error("main did not initialize"); + } + } + return early + "|" + String(readLate()) + "|" + String(readLate()); +}); +while (Atomics.load(view, 0) === 0) { + if (Atomics.wait(view, 0, 0, 10000) === "timed-out") { + throw new Error("worker did not read early"); + } +} +export const late = 1234567890123456789012345678901234567890n + BigInt(Atomics.load(view, 1)); +Atomics.store(view, 1, 1); +Atomics.notify(view, 1); +console.log(await result); +"#; + assert_every_mode( + &[("main.ts", main)], + "undefined|1234567890123456789012345678901234567890|1234567890123456789012345678901234567890\n", + ); +} + +/// The worker's own collection must move its replicas and the locals that +/// retain them: the worker reads a computed String and BigInt, collects with +/// both live, consumes the retained locals first (no re-read), then re-reads +/// through the agent cache. The replicas are roots of the worker's heap, so a +/// worker whose cache root is unregistered reads a retired object here. The +/// harness asserts correct output and the absence of a retired from-space +/// report; it cannot observe the motion itself. +#[test] +fn a_worker_collection_moves_its_replicas_and_retained_locals_without_a_stale_read() { + let helper = r#"// Computed (not foldable) leaves: the worker must materialize its own replicas. +const zero = new Int32Array(new SharedArrayBuffer(4)); +console.log("wgc-module-once"); +export const s = "worker-local-gc-string-payload-longer-than-sixty-four-bytes-for-relocation-" + Atomics.load(zero, 0); +export const big = 123456789012345678901234567890123n + BigInt(Atomics.load(zero, 0)); +export function readS(): string { return s; } +export function readBig(): bigint { return big; } +"#; + let main = r#"import { spawn } from "perry/thread"; +import { readS, readBig } from "./helper"; + +declare function gc(): void; +const result = spawn((): string => { + const a = readS(); + const b = readBig(); + // The worker collects while its TLS replicas and the retained locals a/b are live. + gc(); + // Consume the retained locals first (no re-read), then re-read through the cache. + const last = a.charCodeAt(a.length - 1); + const sum = (b + 1n).toString(); + const again = readS(); + return last + "|" + sum + "|" + a.length + "|" + (again === a) + "|" + (readBig() === b); +}); +console.log(await result); +"#; + assert_every_mode( + &[("main.ts", main), ("helper.ts", helper)], + "wgc-module-once\n48|123456789012345678901234567890124|76|true|true\n", + ); +} diff --git a/scripts/addr_class_ratchet_baseline.txt b/scripts/addr_class_ratchet_baseline.txt index e03c893a6f..50bec510cd 100644 --- a/scripts/addr_class_ratchet_baseline.txt +++ b/scripts/addr_class_ratchet_baseline.txt @@ -167,7 +167,7 @@ handle-floor | crates/perry-runtime/src/symbol/constructors.rs | 2 handle-floor | crates/perry-runtime/src/symbol/get.rs | 6 handle-floor | crates/perry-runtime/src/symbol/iterator.rs | 1 handle-floor | crates/perry-runtime/src/text.rs | 2 -handle-floor | crates/perry-runtime/src/thread.rs | 10 +handle-floor | crates/perry-runtime/src/thread.rs | 9 handle-floor | crates/perry-runtime/src/timer.rs | 1 handle-floor | crates/perry-runtime/src/tls.rs | 1 handle-floor | crates/perry-runtime/src/tty.rs | 1 diff --git a/scripts/constfn_executable_gates.py b/scripts/constfn_executable_gates.py new file mode 100755 index 0000000000..a72a01b268 --- /dev/null +++ b/scripts/constfn_executable_gates.py @@ -0,0 +1,690 @@ +#!/usr/bin/env python3 +"""Run after the owner releases the heavy slot; never build Cargo archives. + +Requires a truthful build receipt produced for the frozen tested source. Receipt +schema: commit, compiler_sha256, runtime_sha256, stdlib_sha256. The runner verifies +artifact hashes, uses only the supplied full gc-instruments runtime, and prevents +implicit Cargo builds. Logs/artifacts stay under an exclusive output directory. +""" +import argparse +import collections +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys + +EXPECTED = { + "factory": "94208 true 17 29\n103 29\n202 29\nundefined 29\n", + "classes": "280064 523 535 true\n101 535\n8128 8128\n", + "numeric-loops": "4097 2 1639600 256 63.5\n65024\n42\n24384\n", + "numeric-recheck": "1200 200 512 200\n", + "cache": "17 94208 105 true\n", + "workers-before": "17 29 17 29\n5 6\n", + "workers-after": "17 29\n17 29 17 29\n5 6\n", +} +STATIC = re.compile(r"perry-static-shape: (\S+) requested=(0x[0-9a-f]+) got=(0x[0-9a-f]+) (hit|miss)") +CACHE = re.compile(r"codegen cache: (\d+)/(\d+) hit \((\d+) miss\)") + + +def require(condition, reason): + if not condition: + raise RuntimeError(reason) + + +def sha(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def run(command, env, cwd, log): + p = subprocess.run(command, env=env, cwd=cwd, capture_output=True, text=True) + log.with_suffix(".stdout").write_text(p.stdout) + log.with_suffix(".stderr").write_text(p.stderr) + return p + + +def good(p, label): + require(p.returncode == 0, f"{label}: rc={p.returncode}; see captured logs") + + +def census(stderr, label, *, constfn, worker=False): + records = STATIC.findall(stderr) + require(records, f"{label}: static census never ran") + rebuilt = [] + for path, requested, got, verdict in records: + if int(requested, 16) == 0: + # Receiver reconstruction asks the interner for a shape by facts, + # not for a particular static id. Its diagnostic therefore says + # requested=0, got=, miss; that is not an id contradiction. + require(path == "finalized-constfn" and int(got, 16) > 0 and verdict == "miss", + f"{label}: malformed dynamic finalization census") + rebuilt.append((path, requested, got, verdict)) + else: + require(requested == got and verdict == "hit", f"{label}: static-id mismatch") + promoted = any(path == "finalized-constfn" for path, *_ in records) + require(promoted == constfn, f"{label}: completed receiver witness differs from arm") + if worker and constfn: + require(rebuilt, f"{label}: receiver reconstruction did not publish ConstFn facts") + return records + + +def witnesses(stderr, constfn, label, calls=None): + records = census(stderr, label, constfn=constfn) + matches = re.findall(r"\[method-site\][^\n]*", stderr) + primes = [int(x) for line in matches for x in re.findall(r"primes_constfn=(\d+)", line)] + if constfn: + require(primes and max(primes) > 0, f"{label}: no ConstFn own entry primed") + if calls: + misses = [int(x) for line in matches for x in re.findall(r"\bmisses=(\d+)", line)] + require(misses and max(misses) < calls, f"{label}: no repeated inline hit witness") + else: + require(not any(primes), f"{label}: off arm admitted ConstFn") + return collections.Counter(records) + + + +def numeric_routes(stderr, label, *, constfn, refusal=False): + stores = [dict(re.findall(r"([\w.]+)=(\d+)", line)) + for line in re.findall(r"\[store-census\][^\n]*", stderr)] + regions = [dict(re.findall(r"([\w.]+)=(\d+)", line)) + for line in re.findall(r"^PERRY_RECV_ROUTES[^\n]*", stderr, re.MULTILINE)] + require(stores and regions, f"{label}: numeric route instruments did not report") + require(len(stores) == len(regions) == 1, f"{label}: ambiguous numeric route census") + counters = {key: int(value) for row in (stores[0], regions[0]) for key, value in row.items()} + require(counters.get("emit.cfield.loop_raw_store", 0) == 0, + f"{label}: legacy class numeric store executed") + positive = ("rloop_static", "rloop_f", "rloop_f_rep", "rloop_bare", + "emit.elem.read.versioned_indexed") + if refusal: + # The original control mutates Pair/entities, never LoopCell. Its + # unaffected store-only loop must keep exactly its own 200 admissions. + # The isolated controls below reject affected raw accesses separately. + for key, expected in (("rloop_f", 200), ("rloop_f_rep", 200), + ("rloop_bare", 200), ("emit.elem.read.versioned_indexed", 0)): + require(counters.get(key, 0) == expected, + f"{label}: only untouched LoopCell may admit {key}={expected}: {counters}") + require(counters.get("rloop_static", 0) > 0 and counters.get("rloop_g", 0) > 0, + f"{label}: unaffected cell/affected Pair witnesses are missing") + require(sum(counters.get(key, 0) for key in + ("emit.cfield.ic_call", "emit.cfield.guard_fallback_call", + "emit.elem.read.fallback_call", "emit.elem.read.cold_arm")) > 0, + f"{label}: refusal did not execute a generic fallback") + else: + for key in positive: + require(counters.get(key, 0) > 0, f"{label}: numeric positive route never executed: {key}") + if constfn: + records = census(stderr, label, constfn=True) + classes = {got for path, _, got, _ in records if path == "class-constfn"} + finalized = {got for path, _, got, _ in records if path == "finalized-constfn"} + require(len(classes) == 2 and classes <= finalized, + f"{label}: both Pair and Reader must actually finalize, not merely seed") + return counters + + +def recheck_routes(stderr, label, *, mutated=False): + rows = [dict(re.findall(r"([\w.]+)=(\d+)", line)) + for line in re.findall(r"^PERRY_RECV_ROUTES[^\n]*", stderr, re.MULTILINE)] + require(len(rows) == 1, f"{label}: ambiguous or missing recheck route census") + counters = {key: int(value) for key, value in rows[0].items()} + fast, generic = (100, 100) if mutated else (200, 0) + require(counters.get("rloop_f", 0) == fast and counters.get("rloop_g", 0) == generic, + f"{label}: expected F={fast}, G={generic}; got {counters}") + require(counters.get("rloop_bare", 0) == 2 * fast + and counters.get("rloop_f_rep", 0) == fast + and counters.get("rloop_recheck", 0) > 0, + f"{label}: exact numeric reads/recheck did not execute") + return counters + + +def arithmetic_region_formation(ir): + """Require the arithmetic fixture's F body, separately from other tiers.""" + functions = [(name.strip('"'), body) for name, body in re.findall( + r'^define[^\n]*@([^\s(]+)[^\n]*\{\n(.*?)^\}', ir, re.MULTILINE | re.DOTALL)] + bodies = [body for name, body in functions + if name.startswith("perry_fn_") and name.endswith("__regionRead")] + require(len(bodies) == 1, "numeric trace must contain exactly one regionRead body") + body = bodies[0] + blocks = {} + current = None + for line in body.splitlines(): + label = re.match(r'^([\w.]+):(?:\s*;.*)?$', line) + if label: + current = label[1] + blocks[current] = [] + elif current is not None and line.strip(): + blocks[current].append(line.strip()) + work = [label for label in blocks if label.startswith("rloop.fast")] + require(work, "arithmetic regionRead never formed F") + seen = set() + while work: + label = work.pop() + if label.startswith("rloop.join") or label in seen: + continue + require(label in blocks, f"unresolved arithmetic F successor: {label}") + seen.add(label) + instructions = blocks[label] + if instructions: + work.extend(re.findall(r'label %([\w.]+)', instructions[-1])) + fast = "\n".join(line for label in seen for line in blocks[label]) + formation = { + "arithmetic F formed": bool(seen), + "arithmetic F multiplies": "fmul double" in fast, + "arithmetic F adds": "fadd double" in fast, + "arithmetic R witness emitted": bool(re.search(r'@js_recv_route_note\(i32 34\)', fast)), + "arithmetic F uses exact bare reads": "load double" in fast and "@js_object_get_field" not in fast, + "arithmetic F has no dynamic add": "@js_dynamic_string_or_number_add" not in fast, + "arithmetic F has no fact tree": "rloop.tree" not in fast, + "arithmetic F needs no recheck": "rloop.recheck" not in body, + } + require(all(formation.values()), f"arithmetic region formation failed: {formation}") + return formation + +def class_store_routes(stderr, label): + rows = [dict(re.findall(r"([\w.]+)=(\d+)", line)) + for line in re.findall(r"^PERRY_RECV_ROUTES[^\n]*", stderr, re.MULTILINE)] + require(len(rows) == 1, f"{label}: missing or ambiguous replacement routes") + counters = {key: int(value) for key, value in rows[0].items()} + for key, expected in (("rloop_f", 200), ("rloop_f_rep", 200), + ("rloop_bare", 400), ("rloop_g", 0), ("rloop_recheck", 0)): + require(counters.get(key, 0) == expected, + f"{label}: isolated class increment needs {key}={expected}: {counters}") + stores = re.findall(r"\[store-census\][^\n]*", stderr) + require(len(stores) == 1, f"{label}: missing or ambiguous store census") + old = dict(re.findall(r"([\w.]+)=(\d+)", stores[0])) + require(int(old.get("emit.cfield.loop_raw_store", 0)) == 0, + f"{label}: legacy class increment executed") + return counters + + +def guarded_fast_body(body, label): + """F may enter unconditionally inside a conditionally versioned loop.""" + blocks = {} + current = None + for line in body.splitlines(): + match = re.match(r'^([\w.]+):(?:\s*;.*)?$', line) + if match: + current = match[1] + blocks[current] = [] + elif current is not None and line.strip() and not line.lstrip().startswith(";"): + blocks[current].append(line.strip()) + require(blocks, f"{label}: no function CFG") + + def successors(block): + if not blocks[block]: + return [] + terminal = blocks[block][-1] + constant = re.match(r'br i1 (true|false), label %([\w.]+), label %([\w.]+)', terminal) + if constant: + return [constant[2] if constant[1] == "true" else constant[3]] + return re.findall(r'label %([\w.]+)', terminal) + + def reachable(starts, stop_at_join=False): + work, seen = list(starts), set() + while work: + block = work.pop() + if block in seen or (stop_at_join and block.startswith(("rloop.join", "rloop.version.merge"))): + continue + require(block in blocks, f"{label}: unresolved CFG successor {block}") + seen.add(block) + work.extend(successors(block)) + return seen + + live = reachable([next(iter(blocks))]) + starts = {block for block in live if block.startswith("rloop.fast")} + require(starts, f"{label}: F is absent or unreachable from function entry") + entry = next(iter(blocks)) + predecessors = {block: set() for block in live} + for block in live: + for successor in successors(block): + predecessors[successor].add(block) + dominators = {block: ({entry} if block == entry else set(live)) for block in live} + changed = True + while changed: + changed = False + for block in live - {entry}: + incoming = predecessors[block] + common = set.intersection(*(dominators[pred] for pred in incoming)) if incoming else set() + updated = {block} | common + if updated != dominators[block]: + dominators[block] = updated + changed = True + guarded = set() + for block in live: + if not block.startswith(("rloop.guard", "rloop.version")) or not blocks[block]: + continue + branch = re.match(r'br i1 (?!true,|false,)[^,]+, label %([\w.]+), label %([\w.]+)', blocks[block][-1]) + if branch: + first = starts & reachable([branch[1]], True) + second = starts & reachable([branch[2]], True) + guarded.update(fast for fast in first ^ second if block in dominators[fast]) + require(starts <= guarded, f"{label}: conditional region/version guard does not dominate F") + fast = reachable(starts, True) + return "\n".join(line for block in fast for line in blocks[block]) + + +def class_store_region_formation(ir): + functions = [(name.strip('"'), body) for name, body in re.findall( + r'^define[^\n]*@([^\s(]+)[^\n]*\{\n(.*?)^\}', ir, re.MULTILINE | re.DOTALL)] + bodies = [body for name, body in functions + if name.startswith("perry_fn_") and name.endswith("__classLoopReplacement")] + require(len(bodies) == 1, "replacement trace needs one classLoopReplacement function") + body = bodies[0] + fast = guarded_fast_body(body, "class replacement") + require("rloop.guard." in body and "rloop.recheck" not in body, + "replacement needs a generic region guard without a recheck cliff") + require("@js_recv_route_note(i32 34)" in fast, + "replacement store function has no R witness") + require(fast.count("@js_recv_route_note(i32 18)") == 2, + "replacement needs exactly its bare field read and bare store") + require("load double" in fast and "store double" in fast and "fadd double" in fast, + "replacement F must read, add, and commit a raw store") + require(not any(marker in fast for marker in + ("rloop.tree", "@js_dynamic_string_or_number_add", "@js_object_get_field", + "@js_typed_feedback_class_field", "@js_gc_write_barrier", "@js_put_value")), + "replacement F retained ordinary numeric or store bookkeeping") + require(not any(marker in ir for marker in + ("class_field.loop.", "class_field_loop.", "class_field_loop_store.", + "for.class_field_fast", "for.class_field_slow")), + "replacement IR retained the legacy numeric tier") + return {"isolated class R/store formed": True, "isolated legacy absent": True} + + +def isolated_refusal_routes(stderr, label, *, kind, mutated=True): + """One executable isolates one affected receiver; no unrelated F can hide it.""" + stores = re.findall(r"\[store-census\][^\n]*", stderr) + regions = re.findall(r"^PERRY_RECV_ROUTES[^\n]*", stderr, re.MULTILINE) + require(len(stores) == len(regions) == 1, f"{label}: missing/ambiguous isolated census") + counters = {key: int(value) for line in stores + regions + for key, value in re.findall(r"([\w.]+)=(\d+)", line)} + require(counters.get("emit.cfield.loop_raw_store", 0) == 0, + f"{label}: legacy store executed") + fast = 200 if kind == "cell" and not mutated else 0 + for key in ("rloop_f", "rloop_f_rep", "rloop_bare"): + require(counters.get(key, 0) == fast, + f"{label}: isolated {kind} needs {key}={fast}: {counters}") + require(counters.get("emit.elem.read.versioned_indexed", 0) == 0, + f"{label}: affected indexed receiver entered the versioned route") + if fast: + require(counters.get("rloop_g", 0) == 0 and counters.get("rloop_recheck", 0) == 0 + and counters.get("rloop_static", 0) > 0, + f"{label}: untouched cell did not use its static store-only region") + else: + fallback = ("emit.elem.read.fallback_call", "emit.elem.read.cold_arm") if kind == "reader" else ( + "emit.cfield.ic_call", "emit.cfield.guard_fallback_call", "emit.by_name.put_value", + "emit.by_name.runtime") + require(sum(counters.get(key, 0) for key in fallback) > 0, + f"{label}: no affected ordinary fallback execution") + if kind != "reader": + expected = 400 if kind == "all" else 200 + # These static-supplier loops reject before versioning. They + # execute the plain loop once, rather than entering an in-loop G + # clone per iteration. Count the actual ordinary stores as well: + # an empty/dead plain copy must not satisfy refusal coverage. + loops = 2 if kind == "all" else 1 + require(counters.get("rloop_g", 0) == 0 + and counters.get("rloop_plain", 0) == loops + and counters.get("rloop_guard", 0) == loops, + f"{label}: affected preheader refusal needs {loops} guarded plain loops: {counters}") + require(counters.get("emit.cfield.ic_call", 0) == expected + and counters.get("emit.cfield.guard_fallback_call", 0) == expected, + f"{label}: affected ordinary stores must execute exactly {expected} times: {counters}") + return counters + + +def isolated_refusal_formation(ir, kind): + """Runtime zero F is meaningful only if the same executable emitted it.""" + require(not any(marker in ir for marker in ( + "class_field.loop.", "class_field_loop.", "class_field_loop_store.", + "for.class_field_fast", "for.class_field_slow")), "isolated control retained legacy IR") + if kind == "reader": + require("versioned_index.loop.fast.preheader" in ir, + "reader refusal control did not emit the positive indexed route") + return {"indexed route formed but refused at runtime": True} + name = "affectedPair" if kind == "pair" else "untouchedCell" + bodies = [body for symbol, body in re.findall( + r'^define[^\n]*@([^\s(]+)[^\n]*\{\n(.*?)^\}', ir, re.MULTILINE | re.DOTALL) + if symbol.strip('"').startswith("perry_fn_") and symbol.strip('"').endswith("__" + name)] + require(len(bodies) == 1, f"isolated {kind} needs one {name} function") + body = bodies[0] + fast = guarded_fast_body(body, "isolated " + kind) + expected_bare = 3 if kind == "pair" else 1 + require(fast.count("@js_recv_route_note(i32 18)") == expected_bare + and "@js_recv_route_note(i32 34)" in fast and "store double" in fast, + f"isolated {kind} needs its own exact R/store F") + if kind == "pair": + require("load double" in fast and "fadd double" in fast, + "affected Pair F must contain its own reads and addition") + require(not any(marker in fast for marker in ( + "@js_object_get_field", "@js_put_value", "@js_gc_write_barrier", + "@js_dynamic_string_or_number_add")), f"isolated {kind} retained ordinary F work") + return {"isolated " + kind + " R/store route formed": True} + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--compiler", required=True, type=Path) + ap.add_argument("--runtime-dir", required=True, type=Path) + ap.add_argument("--node", required=True, type=Path) + ap.add_argument("--build-receipt", required=True, type=Path) + ap.add_argument("--out", required=True, type=Path) + ap.add_argument("--llvm-opt", type=Path, help="LLVM 22 opt for production native-root rewriting") + args = ap.parse_args() + repo = Path(__file__).resolve().parents[1] + head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip() + dirty = subprocess.check_output( + ["git", "status", "--porcelain", "--untracked-files=normal"], cwd=repo, text=True + ) + require(not dirty, "source must be frozen and clean before checking its build receipt") + receipt = json.loads(args.build_receipt.read_text()) + require(receipt["commit"] == head, "build receipt is for another source head") + paths = {"compiler": args.compiler.resolve(), "runtime": args.runtime_dir.resolve() / "libperry_runtime.a", "stdlib": args.runtime_dir.resolve() / "libperry_stdlib.a"} + for kind, path in paths.items(): + require(sha(path) == receipt[f"{kind}_sha256"], f"{kind}: artifact hash differs from build receipt") + require(not args.out.exists(), "output directory must be new (cold cache proof)") + out = args.out.resolve() + out.mkdir(parents=True) + (out / "receipt.json").write_text(json.dumps(receipt, indent=2)) + guard = out / "tool-guards" + guard.mkdir() + cargo = guard / "cargo" + cargo.write_text("#!/bin/sh\necho 'ConstFn gate forbids implicit Cargo builds' >&2\nexit 126\n") + cargo.chmod(0o755) + clean_env = {key: value for key, value in os.environ.items() + if not key.startswith("PERRY_") and key != "NODE_OPTIONS"} + env = dict(clean_env, PATH=str(guard) + os.pathsep + os.environ["PATH"], + PERRY_RUNTIME_DIR=str(args.runtime_dir.resolve()), PERRY_LIB_DIR=str(args.runtime_dir.resolve()), + PERRY_DEV_VERBOSE="1", PERRY_STATIC_SHAPE_CENSUS="1", PERRY_METHOD_SITE_STATS="1", + PERRY_GC_INSTRUMENTS="1") + # Ambient trace overrides must not divert the LLVM receipt elsewhere. + env.pop("PERRY_SAVE_LL", None) + version = subprocess.check_output([str(args.node.resolve()), "--version"], text=True).strip() + require(version == "v" + (repo / ".node-version").read_text().strip(), "Node oracle is not the exact repository pin") + fixtures = repo / "test-files/constfn-executable-gates" + summary = [] + + def compile_fixture(name, arm, tag, trace=False, fail=False, regions=True): + arm_env = dict(env, PERRY_CONSTFN_SHAPE=str(arm), PERRY_CACHE_DIR=str(out / f"cache-{name}-{arm}")) + if not regions: + arm_env.update(PERRY_REGIONS="0", PERRY_CACHE_DIR=str(out / f"cache-{name}-{arm}-regions-off")) + if name.startswith("numeric-"): + arm_env.update(PERRY_STORE_CENSUS="1", PERRY_RECV_ROUTE_COUNT="1") + binary = out / f"{name}-{arm}-{tag}" + command = [str(args.compiler.resolve()), "compile", str(fixtures / f"{name}.ts"), "--no-auto-optimize", "--debug-symbols", "-o", str(binary)] + if trace: + trace_dir = out / "ir" / name / str(arm) if name.startswith("numeric-refusal-") or name == "numeric-class-loop-replacement" else out / "ir" / name + trace_dir.mkdir(parents=True) + arm_env["PERRY_SAVE_LL"] = str(trace_dir) + arm_env["PERRY_NO_CACHE"] = "1" + arm_env["PERRY_INLINE_SHADOW_SLOT"] = "0" + command += ["--trace", "llvm"] + p = run(command, arm_env, out, out / f"compile-{name}-{arm}-{tag}") + if not fail: + good(p, f"compile {name}/{arm}/{tag}") + return binary, p, arm_env + + refusal_controls = { + "pair": [((), "401 200 200 200\n", True)], + "reader": [((), "24384 1\n", True)], + "cell": [((), "42\n", False), (("refuse",), "42 0 200\n", True)], + "all": [((), "401 200 200 200\n42 0 200\n", True)], + } + for kind, cases in refusal_controls.items(): + name = "numeric-refusal-" + kind + for arguments, expected, _ in cases: + p = run([str(args.node.resolve()), "--experimental-strip-types", + str(fixtures / f"{name}.ts"), *arguments], env, out, + out / f"node-{name}-{'mutated' if arguments else 'default'}") + good(p, "Node " + name) + require(p.stdout == expected, f"{name}: exact descriptor/coercion events changed") + for arm in (0, 1): + for regions in (False, True): + tag = "regions-on" if regions else "regions-off" + binary, _, arm_env = compile_fixture(name, arm, tag, trace=regions, regions=regions) + for arguments, expected, mutated in cases: + log = out / f"run-{name}-{arm}-{tag}-{'mutated' if arguments else 'default'}" + p = run([str(binary), *arguments], arm_env, out, log) + good(p, name + "/" + tag) + require(p.stdout == expected, f"{name}/{arm}/{tag}: exact events differ from Node") + if regions: + routes = isolated_refusal_routes(p.stderr, str(log), kind=kind, mutated=mutated) + log.with_suffix(".routes.json").write_text(json.dumps(routes, indent=2)) + if kind != "all": + ir = "\n".join(p.read_text() for p in (out / "ir" / name / str(arm)).rglob("*.ll")) + isolated_refusal_formation(ir, kind) + summary.append([name, arm, "affected receiver events and on/off attribution PASS", []]) + + collecting_expected = { + "collecting-nested-add": "".join(f"captured-{i}5\n" for i in range(12)) + "12 changed 127\n", + "collecting-field-assignment": "66 11 12 127\n", + } + for name, expected in collecting_expected.items(): + node = run([str(args.node.resolve()), "--expose-gc", "--experimental-strip-types", + str(fixtures / f"{name}.ts")], env, out, out / ("node-" + name)) + good(node, "Node " + name) + require(node.stdout == expected, name + ": pinned coercion/setter oracle changed") + for arm in (0, 1): + binary, _, arm_env = compile_fixture(name, arm, "cold") + results = [] + for seed in (1, 17, 991): + moving = dict(arm_env, PERRY_GC_SCHEDULE_SEED=str(seed), PERRY_GC_SCHEDULE_RATE="0.25", + PERRY_GC_SCHEDULE_ALLOC_KB="0", PERRY_GC_FORCE_EVACUATE="1", + PERRY_GC_VERIFY_EVACUATION="1", PERRY_GC_PROTECT_FROMSPACE="1", PERRY_GC_DIAG="1") + p = run([str(binary)], moving, out, out / f"run-{name}-{arm}-{seed}") + good(p, f"{name}/{arm}/seed{seed}") + require(p.stdout == expected, f"{name}/{arm}/seed{seed}: captured value/result mismatch") + counters = re.search(r"\[gc-schedule\] done:.*copying_minors=(\d+) moved_objects=(\d+)", p.stderr) + require(counters and min(map(int, counters.groups())) > 0, + f"{name}/{arm}/seed{seed}: collecting probe moved no objects") + results.append(tuple(map(int, counters.groups()))) + if arm: + compile_fixture(name, arm, "ir", trace=True) + summary.append([name, arm, "3 moving coercion/setter seeds PASS", results]) + + for name, expected in EXPECTED.items(): + if not name.startswith("workers"): + p = run([str(args.node.resolve()), "--experimental-strip-types", str(fixtures / f"{name}.ts")], env, out, out / f"node-{name}") + good(p, f"Node {name}") + require(p.stdout == expected, f"{name}: expected oracle output changed") + if name == "numeric-recheck": + p = run([str(args.node.resolve()), "--experimental-strip-types", + str(fixtures / "numeric-recheck.ts"), "mutate"], env, out, out / "node-recheck-mutated") + good(p, "Node recheck descriptor mutation") + require(p.stdout == "1600 200 1536 200\n", "recheck mutation changed the oracle result") + for arm in (0, 1): + binary, compiler, arm_env = compile_fixture(name, arm, "cold") + results = [] + for seed in (1, 17, 991): + moving = dict(arm_env, PERRY_GC_SCHEDULE_SEED=str(seed), PERRY_GC_SCHEDULE_RATE="0.25", + PERRY_GC_SCHEDULE_ALLOC_KB="0", PERRY_GC_FORCE_EVACUATE="1", + PERRY_GC_VERIFY_EVACUATION="1", PERRY_GC_PROTECT_FROMSPACE="1", PERRY_GC_DIAG="1") + p = run([str(binary)], moving, out, out / f"run-{name}-{arm}-{seed}") + good(p, f"{name}/{arm}/seed{seed}") + require(p.stdout == expected, f"{name}/{arm}/seed{seed}: output mismatch") + if not name.startswith("workers"): + witnesses(p.stderr, bool(arm), name, 4096 if name in ("factory", "cache") else 1024) + else: + census(p.stderr, name, constfn=bool(arm), worker=True) + if name == "numeric-loops": + routes = numeric_routes(p.stderr, f"numeric/{arm}/seed{seed}", constfn=bool(arm)) + (out / f"numeric-routes-{arm}-{seed}.json").write_text(json.dumps(routes, indent=2)) + counters = re.search(r"\[gc-schedule\] done:.*copying_minors=(\d+) moved_objects=(\d+)", p.stderr) + require(counters and min(map(int, counters.groups())) > 0, f"{name}/{arm}: GC stress moved no objects") + if name == "numeric-recheck": + routes = recheck_routes(p.stderr, f"recheck/{arm}/seed{seed}") + (out / f"recheck-routes-{arm}-{seed}.json").write_text(json.dumps(routes, indent=2)) + changed = run([str(binary), "mutate"], moving, out, + out / f"run-recheck-mutated-{arm}-{seed}") + good(changed, f"recheck mutation/{arm}/seed{seed}") + require(changed.stdout == "1600 200 1536 200\n", + f"recheck mutation/{arm}/seed{seed}: output mismatch") + routes = recheck_routes(changed.stderr, f"recheck mutation/{arm}/seed{seed}", mutated=True) + (out / f"recheck-mutated-routes-{arm}-{seed}.json").write_text(json.dumps(routes, indent=2)) + moved = re.search(r"\[gc-schedule\] done:.*copying_minors=(\d+) moved_objects=(\d+)", changed.stderr) + require(moved and min(map(int, moved.groups())) > 0, + f"recheck mutation/{arm}/seed{seed}: GC stress moved no objects") + results.append(tuple(map(int, counters.groups()))) + summary.append([name, arm, "3 moving seeds PASS", results]) + if name == "numeric-loops": + p = run([str(args.node.resolve()), "--experimental-strip-types", + str(fixtures / "numeric-loops.ts"), "refuse"], env, out, + out / f"node-numeric-refuse-{arm}") + good(p, "Node numeric descriptor refusal") + require(p.stdout == expected, "numeric refusal changed the oracle result") + p = run([str(binary), "refuse"], arm_env, out, out / f"run-numeric-refuse-{arm}") + good(p, "numeric descriptor refusal") + require(p.stdout == expected, "numeric refusal output mismatch") + routes = numeric_routes(p.stderr, f"numeric/{arm}/refuse", + constfn=bool(arm), refusal=True) + (out / f"numeric-routes-{arm}-refuse.json").write_text(json.dumps(routes, indent=2)) + summary.append(["numeric descriptor refusal", arm, "generic route PASS", routes]) + if name == "cache": + cold = CACHE.search(compiler.stderr) + require(cold and int(cold[1]) == 0 and int(cold[3]) > 0, "cold link used preexisting cached objects") + cold_seeds = {str(p.relative_to(out)): p.read_bytes() for p in (out / "cache-cache-1").rglob("*.seeds")} if arm else {} + warm, compiler_warm, _ = compile_fixture(name, arm, "warm") + cache = CACHE.search(compiler_warm.stderr) + require(cache and int(cache[1]) > 0 and int(cache[3]) == 0, "warm link did not reuse actual objects") + p = run([str(warm)], arm_env, out, out / f"run-cache-{arm}-warm") + good(p, "cached executable") + require(p.stdout == expected, "warm executable output mismatch") + witnesses(p.stderr, bool(arm), "warm", 4096) + if arm: + require(cold_seeds == {str(p.relative_to(out)): p.read_bytes() for p in (out / "cache-cache-1").rglob("*.seeds")}, "cold/warm sidecar bytes differ") + cold_run = (out / "run-cache-1-1.stderr").read_text() + require(witnesses(cold_run, True, "cold") == witnesses(p.stderr, True, "warm"), "cold/warm linked static census differs") + # A valid-format body/rep lie must fail the real cached + # link or executable. A corrupt format must fail compilation. + seed_files = list((out / "cache-cache-1").rglob("*.seeds")) + lines = [(f, i, l.split()) for f in seed_files for i, l in enumerate(f.read_text().splitlines()) if len(l.split()) == 6 and l.split()[5] != "-"] + bodies = sorted({e.split("@", 1)[1] for _, _, fields in lines for e in fields[5].split(",")}) + require(len(bodies) > 1, "body sabotage needs two real linked body symbols") + target, index, fields = lines[0] + original = target.read_text() + for kind in ("body", "rep", "format"): + altered = fields.copy() + if kind == "body": + slot, body = altered[5].split(",")[0].split("@") + altered[5] = f"{slot}@{next(b for b in bodies if b != body)}" + elif kind == "rep": + cf_slots = {int(e.split("@")[0]) for e in altered[5].split(",")} + slot = next(i for i in range(int(altered[1])) if i not in cf_slots) + altered[4] = hex(int(altered[4], 16) ^ (1 << (2 * slot))) + else: + altered = ["invalid-sidecar"] + changed = original.splitlines() + changed[index] = " ".join(altered) + target.write_text("\n".join(changed) + "\n") + try: + bad, cp, _ = compile_fixture(name, arm, f"sabotage-{kind}", fail=True) + if cp.returncode == 0: + cache = CACHE.search(cp.stderr) + require(cache and int(cache[1]) > 0 and int(cache[3]) == 0, f"{kind}: sabotage was hidden by recompilation") + rp = run([str(bad)], arm_env, out, out / f"run-sabotage-{kind}") + require(rp.returncode != 0, f"{kind}: linked seed mismatch did not fail") + else: + require("sidecar" in cp.stderr or "seed" in cp.stderr, f"{kind}: compilation failed for unrelated reason") + summary.append(["sidecar " + kind, 1, "detector failed planted cached link", []]) + finally: + target.write_text(original) + # A separate trace compile deliberately bypasses the object cache. + if arm: + _, _, _ = compile_fixture(name, arm, "ir", trace=True) + # Attribute replacement to one executed increment function. The combined + # fixture's other read regions cannot supply this witness. + replacement = "numeric-class-loop-replacement" + p = run([str(args.node.resolve()), "--experimental-strip-types", + str(fixtures / f"{replacement}.ts")], env, out, out / "node-class-replacement") + good(p, "Node class replacement") + require(p.stdout == "200\n", "class replacement oracle changed") + for arm in (0, 1): + binary, _, arm_env = compile_fixture(replacement, arm, "cold") + for seed in (1, 17, 991): + moving = dict(arm_env, PERRY_GC_SCHEDULE_SEED=str(seed), PERRY_GC_SCHEDULE_RATE="0.25", + PERRY_GC_SCHEDULE_ALLOC_KB="0", PERRY_GC_FORCE_EVACUATE="1", + PERRY_GC_VERIFY_EVACUATION="1", PERRY_GC_PROTECT_FROMSPACE="1", PERRY_GC_DIAG="1") + p = run([str(binary)], moving, out, out / f"run-class-replacement-{arm}-{seed}") + good(p, f"class replacement/{arm}/seed{seed}") + require(p.stdout == "200\n", "class replacement result mismatch") + routes = class_store_routes(p.stderr, f"class replacement/{arm}/seed{seed}") + moved = re.search(r"\[gc-schedule\] done:.*copying_minors=(\d+) moved_objects=(\d+)", p.stderr) + require(moved and min(map(int, moved.groups())) > 0, + "class replacement stress moved no objects; retain failed coverage") + (out / f"class-replacement-routes-{arm}-{seed}.json").write_text(json.dumps(routes, indent=2)) + compile_fixture(replacement, arm, "ir", trace=True) + replacement_ir = "\n".join(p.read_text() for p in (out / "ir" / replacement / str(arm)).rglob("*.ll")) + class_store_region_formation(replacement_ir) + summary.append(["class replacement", arm, "isolated R/store and 3 moving seeds PASS", []]) + trace = out / "ir" + require(list(trace.rglob("*.ll")), "no emitted LLVM to inspect") + numeric_ir = "\n".join(p.read_text() for p in (trace / "numeric-loops").rglob("*.ll")) + formation = {name: marker in numeric_ir for name, marker in ( + ("arithmetic loop region", "rloop.guard."), + ("versioned indexed", "versioned_index.loop.fast.preheader"), + )} + formation["legacy class numeric IR absent"] = not any(marker in numeric_ir for marker in + ("class_field.loop.", "class_field_loop.", "class_field_loop_store.", + "for.class_field_fast", "for.class_field_slow")) + formation.update(arithmetic_region_formation(numeric_ir)) + (out / "numeric-formation.json").write_text(json.dumps(formation, indent=2)) + (out / "numeric-campaign-scope.json").write_text(json.dumps({ + "constfn_numeric_route_interop": True, + "p7_arithmetic_or_f_rep_verified": True, + "p8_isolated_class_R_store_verified": True, + "p8_full_deletion_accepted": False, + "performance_measured": False, + "dependency": "P7 arithmetic twins and negative IR unit tests, sabotage, and performance gates remain separately required.", + }, indent=2)) + require(all(formation.values()), "numeric fixture did not form every required guard tier; inspect IR and repair the fixture before acceptance") + checker = repo / "scripts/gc_root_dominance_check.py" + raw_ir = sorted(trace.rglob("*.ll")) + if any('gc "statepoint-example"' in path.read_text() for path in raw_ir): + # --trace llvm is pre-RS4GC. Analyze the actual production rewrite; + # shadow-only passes cannot establish native-root correctness. + from read_statepoint_rewrite_passes import find_declaration + passes_source, passes = find_declaration() + opt = args.llvm_opt or (Path(shutil.which("opt")) if shutil.which("opt") else None) + require(opt is not None and opt.is_file(), "native root gate needs LLVM 22 opt; supply --llvm-opt") + version = run([str(opt.resolve()), "--version"], env, repo, out / "llvm-opt-version") + good(version, "LLVM opt version") + require(re.search(r"LLVM version 22\.", version.stdout), "root rewrite must use LLVM 22") + native = out / "native-ir" + native.mkdir() + rewrites = [] + for index, path in enumerate(raw_ir): + target = native / path.relative_to(trace) + target.parent.mkdir(parents=True, exist_ok=True) + command = [str(opt.resolve()), "-passes=" + passes, "-S", str(path), "-o", str(target)] + p = run(command, env, repo, out / f"root-rewrite-{index}") + good(p, f"production root rewrite {path.relative_to(trace)}") + rewrites.append({"input": str(path.relative_to(out)), "input_sha256": sha(path), + "output": str(target.relative_to(out)), "output_sha256": sha(target), + "command": command}) + (out / "root-rewrite-receipt.json").write_text(json.dumps({ + "commit": head, "passes_source": str(passes_source.relative_to(repo)), + "passes_source_sha256": sha(passes_source), "passes": passes, + "opt": str(opt.resolve()), "opt_sha256": sha(opt.resolve()), "modules": rewrites, + }, indent=2)) + p = run([sys.executable, str(checker), "--statepoints", "--min-funcs", "30", + "--min-statepoints", "100", "--min-live-bundles", "50", "--min-relocates", "100", + str(native)], env, repo, out / "root-statepoints") + good(p, "full rewritten native root correctness") + else: + # The fallback trace explicitly disables inline shadow bindings so + # the checker can see real root stores. Its two modes are exclusive. + for mode in ("stale-registers", "unrooted-allocas"): + p = run([sys.executable, str(checker), "--" + mode, str(trace)], env, repo, + out / ("root-" + mode)) + good(p, "shadow root " + mode) + (out / "summary.json").write_text(json.dumps(summary, indent=2)) + print(json.dumps(summary, indent=2)) + + +if __name__ == "__main__": + try: + main() + except (RuntimeError, OSError, KeyError) as error: + print(f"ConstFn executable gate FAILED: {error}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/constfn_sabotage_patch.py b/scripts/constfn_sabotage_patch.py new file mode 100755 index 0000000000..c2f0628777 --- /dev/null +++ b/scripts/constfn_sabotage_patch.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Emit a targeted mutant patch; apply only in a separate disposable worktree. + +After rebuilding the identical three-package set, the named gate must fail: +held-closure -> executable factory distinct captures/output; +drop-worker-info -> constfn_transfer_production_worker_seed...; +skip-transfer -> constfn_transfer_seed_first...; +ignore-deprecation -> constfn_transfer_does_not_resurrect...; +unsafe-proven-store -> executable classes resetMethod output/field-rep verifier. +Use `git apply --reverse` to restore this exact patch before the next mutant. +""" +import argparse +import difflib +from pathlib import Path +import sys + + +def replace_one(text, before, after): + if text.count(before) != 1: + raise RuntimeError(f"anchor must occur exactly once: {before[:80]!r}") + return text.replace(before, after, 1) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("kind", choices=["held-closure", "drop-worker-info", "skip-transfer", "ignore-deprecation", "unsafe-proven-store"]) + parser.add_argument("--repo", type=Path, default=Path(__file__).resolve().parents[1]) + args = parser.parse_args() + patches = {} + if args.kind == "held-closure": + path = "crates/perry-runtime/src/object/method_site.rs" + text = (args.repo / path).read_text() + start = text.index(" let entry = MethodEntry {", text.index("// The shape, not this closure object")) + end = text.index(" if publish(slot, entry)", start) + region = text[start:end] + changed = replace_one(region, " closure: 0,", " closure: value & crate::value::POINTER_MASK,") + patches[path] = text[:start] + changed + text[end:] + path = "crates/perry-codegen/src/expr/method_site.rs" + text = (args.repo / path).read_text() + start = text.index(" ctx.current_block = constfn_idx;") + end = text.index(" // own spill:", start) + region = text[start:end] + changed = replace_one(region, " let h = emit_handle(blk, &ub);", " let cp = blk.gep(crate::types::I8, &entry, &[(I64, &abi_closure)]);\n let h = blk.load(I64, &cp);\n let _ = ub;") + patches[path] = text[:start] + changed + text[end:] + elif args.kind == "drop-worker-info": + path = "crates/perry-runtime/src/object/shapes_worker_seed.rs" + patches[path] = replace_one((args.repo / path).read_text(), "infos: r.constfn_infos().to_vec(),", "infos: Vec::new(),") + elif args.kind == "skip-transfer": + path = "crates/perry-runtime/src/thread.rs" + patches[path] = replace_one((args.repo / path).read_text(), " constfn_transfer::restore(obj, *class_id, fields.len(), names, facts)", " let _ = (names, facts);\n obj") + elif args.kind == "ignore-deprecation": + path = "crates/perry-runtime/src/object/static_shapes.rs" + patches[path] = replace_one((args.repo / path).read_text(), " && d.deprecation_targets() == (0, 0)", " && true") + else: + path = "crates/perry-codegen/src/expr/property_set.rs" + text = (args.repo / path).read_text() + start = text.index(" let ptr_shape_proven = ptr_shape_proven") + end = text.index(" if ptr_shape_proven", start) + patches[path] = text[:start] + text[end:] + for path, updated in patches.items(): + original = (args.repo / path).read_text() + sys.stdout.writelines(difflib.unified_diff(original.splitlines(True), updated.splitlines(True), fromfile="a/" + path, tofile="b/" + path)) + + +if __name__ == "__main__": + main() diff --git a/scripts/gc_root_dominance_check.py b/scripts/gc_root_dominance_check.py index 662bfcbad9..7f4769c9de 100755 --- a/scripts/gc_root_dominance_check.py +++ b/scripts/gc_root_dominance_check.py @@ -646,6 +646,9 @@ def build_cfg(f): # fresh objects/collections handed back as a whole r"object_keys\w*|object_values\w*|object_entries\w*|object_from_entries|" r"object_assign\w*|object_group_by|object_coerce|" + # A rooted finalizer returns the refreshed heap receiver. Its result is + # a new SSA snapshot and must not cross later collectors without a root. + r"object_finalize_constfn_static|" r"object_get_own_property_descriptor\w*|object_get_own_property_names|" r"object_get_own_property_symbols|" r"map_from_iterable|set_from_iterable|map_group_by|" @@ -5373,6 +5376,37 @@ def self_test(): that has not been shown to work. """ ok = True + # A finalizer hands back its current rooted receiver, a heap-valued SSA + # source even though it did not allocate that object. Prove both the late + # store detector and stale-register detector still see that return value. + finalizer = """define i64 @perry_fn_selftest__constfn(i64 %receiver) { +entry.0: + %slot = alloca i64 + call void @js_shadow_frame_enter(i32 1) + %obj = call i64 @js_object_finalize_constfn_static(i64 %receiver, i32 1, ptr null, i32 1, i32 1, i32 1, i32 0, i64 3, ptr null, i32 1) + %poll = call double @js_gc_loop_safepoint(double 0.0) + store i64 %obj, ptr %slot + call void @js_shadow_slot_bind(i32 0, ptr %slot) + ret i64 %obj +} +""" + rooted_finalizer = finalizer.replace( + " %poll = call double @js_gc_loop_safepoint(double 0.0)\n", "" + ).replace( + " ret i64 %obj", " %poll = call double @js_gc_loop_safepoint(double 0.0)\n" + " %fresh = load i64, ptr %slot\n ret i64 %fresh" + ) + with tempfile.TemporaryDirectory() as td: + for name, fixture, expected in [("late", finalizer, 1), ("rooted", rooted_finalizer, 0)]: + path = os.path.join(td, name + ".ll") + with open(path, "w") as fh: + fh.write(fixture) + hits, _ = _scan([path], False, "alloc") + stale = check_func_stale(path, parse_file(path)[0], moving_only=True) + if len(hits) != expected or bool(stale) != bool(expected): + print(f"self-test FAIL: ConstFn {name} return: late roots={len(hits)}, " + f"stale={len(stale)}, expected {expected}", file=sys.stderr) + ok = False with tempfile.TemporaryDirectory() as td: planted = os.path.join(td, "planted.ll") clean = os.path.join(td, "clean.ll") diff --git a/scripts/shape_descriptor_census.py b/scripts/shape_descriptor_census.py index 0f5e16eca0..ae011b8c28 100644 --- a/scripts/shape_descriptor_census.py +++ b/scripts/shape_descriptor_census.py @@ -763,14 +763,13 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: # ShapeId @4. Guards have no reason to address anything at or past 8. # # #8113 also fixed this arm's VACUITY. It used to match only - # `add(..., "N")`, while all four functions below emit + # `add(..., "N")`, while these guard functions emit # `gep(I8, &p, &[(I64, "N")])` — so planting a keys-offset read left it # green. Both spellings are matched now, and each function must be shown to # read the ShapeId at all, so a guard that stops reading the header # entirely cannot pass by emitting nothing. for source, names in ( (raw_class_guard, ( - "emit_class_field_loop_preheader_check", "emit_proven_shape_recheck", "emit_class_field_inline_precheck", )), diff --git a/scripts/string_payload_access_baseline.txt b/scripts/string_payload_access_baseline.txt index bc200fe844..3fe3869f4c 100644 --- a/scripts/string_payload_access_baseline.txt +++ b/scripts/string_payload_access_baseline.txt @@ -9,7 +9,7 @@ inline-offset | perry-ext-net | 1 inline-offset | perry-ext-nodemailer | 1 inline-offset | perry-ext-zlib | 3 inline-offset | perry-ffi | 3 -inline-offset | perry-runtime | 335 +inline-offset | perry-runtime | 334 inline-offset | perry-stdlib | 26 inline-offset | perry-updater | 5 reader-helper | perry-ext-ethers | 1 diff --git a/scripts/test_constfn_numeric_refusal.py b/scripts/test_constfn_numeric_refusal.py new file mode 100644 index 0000000000..de9ecf2b6c --- /dev/null +++ b/scripts/test_constfn_numeric_refusal.py @@ -0,0 +1,119 @@ +"""Negative controls for receiver attribution; these are harness, not runtime proofs.""" +import unittest + +from constfn_executable_gates import ( + isolated_refusal_formation, isolated_refusal_routes, numeric_routes, +) + + +def census(**overrides): + counters = dict(rloop_f=200, rloop_f_rep=200, rloop_bare=200, + rloop_g=2376, rloop_static=1) + counters.update(overrides) + return ("[store-census] emit.cfield.loop_raw_store=0 emit.cfield.ic_call=200 " + "emit.cfield.guard_fallback_call=200 " + "emit.elem.read.versioned_indexed=0 emit.elem.read.fallback_call=128\n" + "PERRY_RECV_ROUTES " + " ".join(f"{k}={v}" for k, v in counters.items()) + "\n") + + +CELL_IR = '''define void @perry_fn_test__untouchedCell() { +entry: + br label %rloop.guard.1 +rloop.guard.1: + br i1 %valid, label %rloop.fast.1, label %rloop.generic.1 +rloop.fast.1: + call void @js_recv_route_note(i32 34) + call void @js_recv_route_note(i32 18) + store double 4.2e1, ptr %slot + br label %rloop.join.1 +rloop.generic.1: + br label %rloop.join.1 +rloop.join.1: + ret void +} +''' + + +class RefusalAttributionTests(unittest.TestCase): + def test_original_refusal_keeps_only_untouched_cell(self): + numeric_routes(census(), "combined", constfn=False, refusal=True) + + def test_global_zero_cannot_hide_lost_cell_coverage(self): + with self.assertRaises(RuntimeError): + numeric_routes(census(rloop_f=0, rloop_f_rep=0, rloop_bare=0), + "lost cell", constfn=False, refusal=True) + + def test_additional_affected_pair_admissions_fail(self): + with self.assertRaises(RuntimeError): + numeric_routes(census(rloop_f=201, rloop_f_rep=201, rloop_bare=203), + "affected Pair", constfn=False, refusal=True) + + def test_bare_count_cannot_borrow_read_store_witness(self): + with self.assertRaises(RuntimeError): + numeric_routes(census(rloop_bare=400), "wrong region", constfn=False, refusal=True) + + def test_isolated_pair_refuses(self): + isolated_refusal_routes(census(rloop_f=0, rloop_f_rep=0, rloop_bare=0, + rloop_g=0, rloop_plain=1, rloop_guard=1), + "Pair", kind="pair") + + def test_unrelated_cell_cannot_mask_pair_failure(self): + with self.assertRaises(RuntimeError): + isolated_refusal_routes(census(), "Pair", kind="pair") + + def test_refusal_must_execute_its_guarded_plain_loop(self): + with self.assertRaises(RuntimeError): + isolated_refusal_routes(census(rloop_f=0, rloop_f_rep=0, rloop_bare=0, rloop_g=0), + "dead Pair", kind="pair") + + def test_iteration_g_cannot_replace_preheader_refusal(self): + with self.assertRaises(RuntimeError): + isolated_refusal_routes(census(rloop_f=0, rloop_f_rep=0, rloop_bare=0, + rloop_g=200, rloop_plain=1, rloop_guard=1), + "wrong entry", kind="pair") + + def test_plain_loop_without_exact_stores_is_dead_coverage(self): + for count in [0, 199, 201]: + stderr = census(rloop_f=0, rloop_f_rep=0, rloop_bare=0, + rloop_g=0, rloop_plain=1, rloop_guard=1).replace( + "emit.cfield.ic_call=200", f"emit.cfield.ic_call={count}") + with self.subTest(count=count), self.assertRaises(RuntimeError): + isolated_refusal_routes(stderr, "missing stores", kind="pair") + + def test_cell_requires_own_static_store_only_region(self): + isolated_refusal_routes(census(rloop_g=0), "cell", kind="cell", mutated=False) + with self.assertRaises(RuntimeError): + isolated_refusal_routes(census(rloop_g=0, rloop_static=0), + "unattributed cell", kind="cell", mutated=False) + + def test_cell_ir_cannot_be_dead_or_read_store_twin(self): + isolated_refusal_formation(CELL_IR, "cell") + for altered in ( + CELL_IR.replace("br i1 %valid, label %rloop.fast.1, label %rloop.generic.1", + "br label %rloop.generic.1"), + CELL_IR.replace("store double 4.2e1, ptr %slot", "ret void"), + CELL_IR.replace("i32 34", "i32 14"), + CELL_IR.replace("i32 18)", "i32 18)\n call void @js_recv_route_note(i32 18)"), + CELL_IR.replace("rloop.generic.1", "class_field.loop.slow"), + ): + with self.subTest(altered=altered), self.assertRaises(RuntimeError): + isolated_refusal_formation(altered, "cell") + + def test_preheader_versioning_is_a_real_conditional_entry(self): + versioned = CELL_IR.replace("rloop.guard.1", "rloop.version.split.1").replace( + "label %rloop.fast.1,", "label %for.body.1,").replace( + "rloop.fast.1:\n", "for.body.1:\n br label %rloop.fast.1\nrloop.fast.1:\n") + isolated_refusal_formation(versioned, "cell") + dead = versioned.replace("br label %rloop.fast.1", "br label %rloop.generic.1") + with self.assertRaises(RuntimeError): + isolated_refusal_formation(dead, "cell") + + def test_an_unguarded_entry_cannot_borrow_another_guard(self): + unguarded = CELL_IR.replace("br label %rloop.guard.1", + "br i1 %bypass, label %rloop.fast.1, label %rloop.guard.1") + with self.assertRaises(RuntimeError): + isolated_refusal_formation(unguarded, "cell") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/thread_exit_address_globals.json b/scripts/thread_exit_address_globals.json index 7fe94cb5a4..26b20104f4 100644 --- a/scripts/thread_exit_address_globals.json +++ b/scripts/thread_exit_address_globals.json @@ -4036,20 +4036,21 @@ { "file": "crates/perry-runtime/src/object/shapes_store.rs", "names": [ - "ORDINARY_DIR" + "AGENT_SHAPE_DIR" ], "verdict": "per_thread", - "why": "`#[thread_local]` static (not `thread_local!`, so the megamorphic read reaches it with one thread-pointer-relative load): each thread mirrors ITS OWN ShapeSlab page directory (a Rust-heap Vec pointer and length, never an arena address), republished on every change to `pages` and cleared by ShapeSlab::drop before the Vec is freed; const-initialised to (null, 0) with no drop glue." + "why": "`#[thread_local]` static (not `thread_local!`, so every by-id shape lookup reaches it with one thread-pointer-relative load and no runtime-state fetch): each thread mirrors ITS OWN agent ShapeSlab page directories (per band a Rust-heap Vec pointer and length, never an arena address), republished on every change to a band's page vector and reset to empty by ShapeSlab::drop before the Vecs are freed; const-initialised to (null, 0) per band with no drop glue." }, { "file": "crates/perry-runtime/src/object/shapes_store.rs", "names": [ "EMPTY_CHUNK", "EMPTY_PAGE", + "EMPTY_PAGE_ENTRY", "PERRY_EMPTY_SHAPE_DIR" ], "verdict": "no_heap_address", - "why": "Shared all-empty shape-slab structure (a chunk of EMPTY records, a page of pointers to that chunk, an empty page directory). Every pointer inside is the address of another immutable static in the program image, never an arena or heap address; nothing ever writes them, so an exited thread's Arena::drop cannot leave them dangling." + "why": "Shared all-empty shape-slab structure (a chunk of EMPTY records, a page of pointers to that chunk, a directory entry pointing at that page, an empty ordinary-band directory). Every pointer inside is the address of another immutable static in the program image, never an arena or heap address; nothing ever writes them, so an exited thread's Arena::drop cannot leave them dangling." }, { "file": "crates/perry-runtime/src/object/static_shapes.rs", diff --git a/test-files/constfn-executable-gates/cache.ts b/test-files/constfn-executable-gates/cache.ts new file mode 100644 index 0000000000..1ee0e5b213 --- /dev/null +++ b/test-files/constfn-executable-gates/cache.ts @@ -0,0 +1,6 @@ +import { make, other, seen } from './producer.ts'; +function invoke(receiver: any) { return receiver.m(); } +const first = make(17), second = make(29), different = other(5); +let sum = 0; +for (let i = 0; i < 4096; i++) sum += invoke(i % 2 ? second : first); +console.log(seen, sum, invoke(different), first.m !== second.m); diff --git a/test-files/constfn-executable-gates/classes.ts b/test-files/constfn-executable-gates/classes.ts new file mode 100644 index 0000000000..0fd48e1406 --- /dev/null +++ b/test-files/constfn-executable-gates/classes.ts @@ -0,0 +1,24 @@ +function later(): number { + let total = 0; + for (let i = 0; i < 128; i++) { const p = { x: i }; total += p.x; } + return total; +} +class User { + x = 1; + m = () => this.x; + effect = later(); + constructor(x: number) { this.x = x; later(); } + tick() { this.x += 1; return this.x; } + resetMethod() { this.m = () => 101; } +} +function callM(receiver: any) { return receiver.m(); } +const first = new User(11), second = new User(23); +let sum = 0; +for (let i = 0; i < 1024; i++) { + if (i % 2) { second.tick(); sum += callM(second); } + else { first.tick(); sum += callM(first); } +} +console.log(sum, callM(first), callM(second), first.m !== second.m); +first.resetMethod(); +console.log(callM(first), callM(second)); +console.log(first.effect, second.effect); diff --git a/test-files/constfn-executable-gates/collecting-field-assignment.ts b/test-files/constfn-executable-gates/collecting-field-assignment.ts new file mode 100644 index 0000000000..2a740dab79 --- /dev/null +++ b/test-files/constfn-executable-gates/collecting-field-assignment.ts @@ -0,0 +1,22 @@ +declare function gc(): void; +let stored: any = null; +let junk: any = null; +let sets = 0; +function save(rhs: any): void { + stored = rhs; + sets++; + for (let i = 0; i < 128; i++) junk = { x: i, text: "allocation-" + i }; + if (typeof gc === "function") gc(); +} +class Cell { + m: any = () => -1; + write(value: number): number { + const returned = this.m = () => value; + return returned(); + } +} +const cell = new Cell(); +Object.defineProperty(cell, "m", { get: () => stored, set: save, configurable: true }); +let total = 0; +for (let i = 0; i < 12; i++) total += cell.write(i); +console.log(total, stored(), sets, junk.x); diff --git a/test-files/constfn-executable-gates/collecting-nested-add.ts b/test-files/constfn-executable-gates/collecting-nested-add.ts new file mode 100644 index 0000000000..431228ce58 --- /dev/null +++ b/test-files/constfn-executable-gates/collecting-nested-add.ts @@ -0,0 +1,20 @@ +declare function gc(): void; +let saved: any = ""; +let turns = 0; +let junk: any = null; +function coerce(): number { + saved = "changed"; + turns++; + for (let i = 0; i < 128; i++) junk = { x: i, text: "allocation-" + i }; + if (typeof gc === "function") gc(); + return 2; +} +function combine(a: any, b: any): any { + return saved + (a + b); +} +const coercer: any = { valueOf: coerce }; +for (let i = 0; i < 12; i++) { + saved = "captured-" + i; + console.log(combine(coercer, 3)); +} +console.log(turns, saved, junk.x); diff --git a/test-files/constfn-executable-gates/consumer.ts b/test-files/constfn-executable-gates/consumer.ts new file mode 100644 index 0000000000..9b9f4c6de3 --- /dev/null +++ b/test-files/constfn-executable-gates/consumer.ts @@ -0,0 +1,4 @@ +// make is a hoisted function. This runs before producer's module body, and +// cannot rely on that body's initialization to mint its literal final shape. +import { make } from './producer.ts'; +export const importerFirst = make(17).m(); diff --git a/test-files/constfn-executable-gates/factory.ts b/test-files/constfn-executable-gates/factory.ts new file mode 100644 index 0000000000..2dc7aa5e9a --- /dev/null +++ b/test-files/constfn-executable-gates/factory.ts @@ -0,0 +1,12 @@ +function make(value: number) { return { m: () => value, x: value }; } +function invoke(receiver: any) { return receiver.m(); } +const left = make(17), right = make(29); +let sum = 0; +for (let i = 0; i < 4096; i++) sum += invoke(i % 2 ? right : left); +console.log(sum, left.m !== right.m, invoke(left), invoke(right)); +left.m = () => 103; +console.log(invoke(left), invoke(right)); +Object.defineProperty(left, 'm', { get: () => () => 202, configurable: true }); +console.log(invoke(left), invoke(right)); +delete (left as any).m; +console.log(typeof left.m, invoke(right)); diff --git a/test-files/constfn-executable-gates/numeric-class-loop-replacement.ts b/test-files/constfn-executable-gates/numeric-class-loop-replacement.ts new file mode 100644 index 0000000000..b431b75920 --- /dev/null +++ b/test-files/constfn-executable-gates/numeric-class-loop-replacement.ts @@ -0,0 +1,11 @@ +"use strict"; +// Separate executable: no other loop can contribute R/bare route counts. +// This supplements the unchanged original class fixtures; it cannot replace +// their Infinity/subclass/frozen/accessor or retained-tier cost obligations. +class NumericCell { a: number = 0; } +function classLoopReplacement(p: NumericCell): void { + for (let i = 0; i < 200; i++) p.a = p.a + 1; +} +const cell = new NumericCell(); +classLoopReplacement(cell); +console.log(cell.a); diff --git a/test-files/constfn-executable-gates/numeric-control.ts b/test-files/constfn-executable-gates/numeric-control.ts new file mode 100644 index 0000000000..79c588f76c --- /dev/null +++ b/test-files/constfn-executable-gates/numeric-control.ts @@ -0,0 +1,25 @@ +// Kept in another module so descriptor syntax cannot suppress formation in +// numeric-loops.ts. The same executable chooses positive/refusal at runtime. +export function refuseNumericRoutes(pair: any, entities: number[]): void { + // Installing a declared field descriptor on a class prototype retires the + // classic whole-loop guard globally. Keep this in the separate module so + // its syntax cannot suppress positive loop formation at compile time. + Object.defineProperty(Object.getPrototypeOf(pair), "a", { + value: 0, writable: true, configurable: true, + }); + let current = pair.a; + Object.defineProperty(pair, "a", { + get: () => current, + set: (value: number) => { current = value; }, + enumerable: true, + configurable: true, + }); + // The array still reads the identical value, but its descriptor flag must + // refuse versioned indexed admission. No Array.prototype pollution needed. + Object.defineProperty(entities, "0", { + value: entities[0], + writable: true, + enumerable: true, + configurable: true, + }); +} diff --git a/test-files/constfn-executable-gates/numeric-loops.ts b/test-files/constfn-executable-gates/numeric-loops.ts new file mode 100644 index 0000000000..3bd3346569 --- /dev/null +++ b/test-files/constfn-executable-gates/numeric-loops.ts @@ -0,0 +1,58 @@ +"use strict"; +import { refuseNumericRoutes } from "./numeric-control.ts"; + +class Pair { + a: number = 1; + b: number = 2; + m = () => this.a; +} +function callM(receiver: any) { return receiver.m(); } +// Multiplication defeats the ordinary Add fact-tree spelling. P7 must +// derive exact R reads and scoped Number locals for this arithmetic loop. +function regionRead(p: Pair): number { + let sum = 0; + // Stay above the transform's full-unroll limit: exercise an actual loop. + for (let i = 0; i < 200; i++) sum += p.a * 2 + p.b * 2; + return sum; +} +function regionStore(p: Pair, n: number): number { + let sum = 0; + for (let i = 0; i < n; i++) { p.a = i * 0.5; sum += p.b; } + return sum; +} +class Reader { + m = () => 1; + read(a: number[], index: number): number { + // Exercise the checked-reader family that supplies versioned indexing. + if (a === undefined) throw new Error("missing array"); + const value = a[index]; + if (value === -1) throw new Error("missing value"); + return value; + } + visit(entities: number[], values: number[], callback: (e: number, v: number) => void): void { + const n = entities.length; + for (let i = 0; i < n; i++) { + const entity = entities[i]; + callback(entity, this.read(values, i)); + } + } +} +const reader = new Reader(); +const entities: number[] = [], values: number[] = []; +for (let i = 0; i < 128; i++) { entities.push(i); values.push(i * 2); } +// A contained numeric receiver supplies the classic loop tier. Pair remains +// the mixed numeric/ConstFn receiver for independent arithmetic region tests. +class LoopCell { a: number = 0; } +const cell = new LoopCell(); +const pair = new Pair(); +if (process.argv[2] === "refuse") refuseNumericRoutes(pair, entities); +for (let i = 0; i < 2048; i++) pair.a = pair.a + pair.b; +console.log(pair.a, pair.b, regionRead(pair), regionStore(pair, 128), pair.a); +let methodSum = 0; +for (let i = 0; i < 1024; i++) methodSum += callM(pair); +console.log(methodSum); +for (let i = 0; i < 200; i++) cell.a = 42; +console.log(cell.a); +let visited = 0; +reader.visit(entities, values, (e, v) => { visited += e + v; }); +console.log(visited); diff --git a/test-files/constfn-executable-gates/numeric-recheck-control.ts b/test-files/constfn-executable-gates/numeric-recheck-control.ts new file mode 100644 index 0000000000..40e5ce19d3 --- /dev/null +++ b/test-files/constfn-executable-gates/numeric-recheck-control.ts @@ -0,0 +1,7 @@ +// Keep descriptor syntax outside the arithmetic module so it remains a +// runtime invalidation, rather than suppressing region formation globally. +export function mutateNumericReceiver(receiver:any):void { + Object.defineProperty(receiver, "a", { + get: () => 3, enumerable: true, configurable: true, + }); +} diff --git a/test-files/constfn-executable-gates/numeric-recheck.ts b/test-files/constfn-executable-gates/numeric-recheck.ts new file mode 100644 index 0000000000..113db72480 --- /dev/null +++ b/test-files/constfn-executable-gates/numeric-recheck.ts @@ -0,0 +1,20 @@ +import { mutateNumericReceiver } from "./numeric-recheck-control.ts"; +class Pair { a=1; b=2; m=()=>this.a; } +const pair=new Pair(); +let events=0; +let sink:any; +function touch():void { + events++; + sink={event:events}; + if(process.argv[2]==="mutate" && events===100) mutateNumericReceiver(pair); +} +function readEffects(p:Pair, visit:()=>void):number { + let sum=0; + for(let i=0;i<200;i++){ sum+=p.a*2+p.b*2; visit(); } + return sum; +} +function invoke(receiver:any):number { return receiver.m(); } +const total=readEffects(pair,touch); +let methods=0; +for(let i=0;i<512;i++) methods+=invoke(pair); +console.log(total,events,methods,sink.event); diff --git a/test-files/constfn-executable-gates/numeric-refusal-all.ts b/test-files/constfn-executable-gates/numeric-refusal-all.ts new file mode 100644 index 0000000000..f1a918d4ec --- /dev/null +++ b/test-files/constfn-executable-gates/numeric-refusal-all.ts @@ -0,0 +1,10 @@ +"use strict"; +import { countedPairAccessor, countedCellAccessor } from "./numeric-refusal-control.ts"; +class RefusalPair { a: number = 1; b: number = 2; } +class LoopCell { a: number = 0; } +const pair = new RefusalPair(), cell = new LoopCell(); +const pairEvents = countedPairAccessor(pair), cellEvents = countedCellAccessor(cell); +for (let i = 0; i < 200; i++) pair.a = pair.a + pair.b; +for (let i = 0; i < 200; i++) cell.a = 42; +console.log(pairEvents()); +console.log(cellEvents()); diff --git a/test-files/constfn-executable-gates/numeric-refusal-cell.ts b/test-files/constfn-executable-gates/numeric-refusal-cell.ts new file mode 100644 index 0000000000..7e12d08198 --- /dev/null +++ b/test-files/constfn-executable-gates/numeric-refusal-cell.ts @@ -0,0 +1,15 @@ +"use strict"; +import { countedCellAccessor } from "./numeric-refusal-control.ts"; +class LoopCell { a: number = 0; } +function untouchedCell(cell: LoopCell): void { + for (let i = 0; i < 200; i++) cell.a = 42; +} +const cell = new LoopCell(); +if (process.argv[2] === "refuse") { + const events = countedCellAccessor(cell); + untouchedCell(cell); + console.log(events()); +} else { + untouchedCell(cell); + console.log(cell.a); +} diff --git a/test-files/constfn-executable-gates/numeric-refusal-control.ts b/test-files/constfn-executable-gates/numeric-refusal-control.ts new file mode 100644 index 0000000000..c6d97694ac --- /dev/null +++ b/test-files/constfn-executable-gates/numeric-refusal-control.ts @@ -0,0 +1,35 @@ +// Keep descriptor syntax outside the executable's loop module: the runtime +// branch, rather than frontend syntax suppression, must reject admission. +export function countedPairAccessor(pair: any): () => string { + let current = pair.a, gets = 0, sets = 0, coercions = 0; + const boxed = { valueOf: () => { coercions++; return current; } }; + Object.defineProperty(Object.getPrototypeOf(pair), "a", { + value: 0, writable: true, configurable: true, + }); + Object.defineProperty(pair, "a", { + get: () => { gets++; return boxed; }, + set: (value: number) => { sets++; current = value; }, + enumerable: true, configurable: true, + }); + return () => `${current} ${gets} ${sets} ${coercions}`; +} + +export function countedIndexedAccessor(entities: number[]): () => number { + const original = entities[0]; + let gets = 0; + Object.defineProperty(entities, "0", { + get: () => { gets++; return original; }, + enumerable: true, configurable: true, + }); + return () => gets; +} + +export function countedCellAccessor(cell: any): () => string { + let current = cell.a, gets = 0, sets = 0; + Object.defineProperty(cell, "a", { + get: () => { gets++; return current; }, + set: (value: number) => { sets++; current = value; }, + enumerable: true, configurable: true, + }); + return () => `${current} ${gets} ${sets}`; +} diff --git a/test-files/constfn-executable-gates/numeric-refusal-pair.ts b/test-files/constfn-executable-gates/numeric-refusal-pair.ts new file mode 100644 index 0000000000..afadd3995b --- /dev/null +++ b/test-files/constfn-executable-gates/numeric-refusal-pair.ts @@ -0,0 +1,10 @@ +"use strict"; +import { countedPairAccessor } from "./numeric-refusal-control.ts"; +class RefusalPair { a: number = 1; b: number = 2; } +function affectedPair(p: RefusalPair): void { + for (let i = 0; i < 200; i++) p.a = p.a + p.b; +} +const pair = new RefusalPair(); +const events = countedPairAccessor(pair); +affectedPair(pair); +console.log(events()); diff --git a/test-files/constfn-executable-gates/numeric-refusal-reader.ts b/test-files/constfn-executable-gates/numeric-refusal-reader.ts new file mode 100644 index 0000000000..cc1c2859a1 --- /dev/null +++ b/test-files/constfn-executable-gates/numeric-refusal-reader.ts @@ -0,0 +1,25 @@ +"use strict"; +import { countedIndexedAccessor } from "./numeric-refusal-control.ts"; +class RefusalReader { + m = () => 1; + read(a: number[], index: number): number { + if (a === undefined) throw new Error("missing array"); + const value = a[index]; + if (value === -1) throw new Error("missing value"); + return value; + } + visit(entities: number[], values: number[], callback: (e: number, v: number) => void): void { + const n = entities.length; + for (let i = 0; i < n; i++) { + const entity = entities[i]; + callback(entity, this.read(values, i)); + } + } +} +const reader = new RefusalReader(); +const entities: number[] = [], values: number[] = []; +for (let i = 0; i < 128; i++) { entities.push(i); values.push(i * 2); } +const events = countedIndexedAccessor(entities); +let visited = 0; +reader.visit(entities, values, (e, v) => { visited += e + v; }); +console.log(visited, events()); diff --git a/test-files/constfn-executable-gates/producer.ts b/test-files/constfn-executable-gates/producer.ts new file mode 100644 index 0000000000..0dc12615e2 --- /dev/null +++ b/test-files/constfn-executable-gates/producer.ts @@ -0,0 +1,4 @@ +import { importerFirst } from './consumer.ts'; +export function make(value: number) { return { x: value, m: () => value }; } +export function other(value: number) { return { x: value, m: () => value + 100 }; } +export const seen = importerFirst; diff --git a/test-files/constfn-executable-gates/worker-producer.ts b/test-files/constfn-executable-gates/worker-producer.ts new file mode 100644 index 0000000000..68192425de --- /dev/null +++ b/test-files/constfn-executable-gates/worker-producer.ts @@ -0,0 +1,5 @@ +export function make(value: number) { return { x: value, m: () => value }; } +export class WorkerMade { + x = 4; + m = () => this.x; +} diff --git a/test-files/constfn-executable-gates/workers-after.ts b/test-files/constfn-executable-gates/workers-after.ts new file mode 100644 index 0000000000..3970d06eea --- /dev/null +++ b/test-files/constfn-executable-gates/workers-after.ts @@ -0,0 +1,21 @@ +import { parallelMap } from 'perry/thread'; +import { make, WorkerMade } from './worker-producer.ts'; +function invoke(receiver: any) { return receiver.m(); } +// Keep the transferred receiver live through enough allocating worker polls +// for every fixed schedule seed. Verify captures after each possible move. +function collectWhileCalling(receiver: any) { + const expected = invoke(receiver); + for (let i = 0; i < 128; i++) { + const witness = { receiver, tag: 'worker-' + i }; + if (invoke(witness.receiver) !== expected || witness.tag.length < 7) { + throw new Error('worker capture changed during collection'); + } + } + return invoke(receiver); +} +const first = make(17), second = make(29); +console.log(invoke(first), invoke(second)); +const values = parallelMap([first, second], (row: any) => collectWhileCalling(row)); +console.log(values[0], values[1], invoke(first), invoke(second)); +const constructed = parallelMap([1, 2], (n: number) => collectWhileCalling(new WorkerMade()) + n); +console.log(constructed[0], constructed[1]); diff --git a/test-files/constfn-executable-gates/workers-before.ts b/test-files/constfn-executable-gates/workers-before.ts new file mode 100644 index 0000000000..416f26beff --- /dev/null +++ b/test-files/constfn-executable-gates/workers-before.ts @@ -0,0 +1,20 @@ +import { parallelMap } from 'perry/thread'; +import { make, WorkerMade } from './worker-producer.ts'; +function invoke(receiver: any) { return receiver.m(); } +// Keep the transferred receiver live through enough allocating worker polls +// for every fixed schedule seed. Verify captures after each possible move. +function collectWhileCalling(receiver: any) { + const expected = invoke(receiver); + for (let i = 0; i < 128; i++) { + const witness = { receiver, tag: 'worker-' + i }; + if (invoke(witness.receiver) !== expected || witness.tag.length < 7) { + throw new Error('worker capture changed during collection'); + } + } + return invoke(receiver); +} +const first = make(17), second = make(29); +const values = parallelMap([first, second], (row: any) => collectWhileCalling(row)); +console.log(values[0], values[1], invoke(first), invoke(second)); +const constructed = parallelMap([1, 2], (n: number) => collectWhileCalling(new WorkerMade()) + n); +console.log(constructed[0], constructed[1]); diff --git a/test-files/test_constfn_static_user_class.ts b/test-files/test_constfn_static_user_class.ts new file mode 100644 index 0000000000..769a27966d --- /dev/null +++ b/test-files/test_constfn_static_user_class.ts @@ -0,0 +1,44 @@ +// Run executable output with PERRY_CONSTFN_SHAPE=0/1 and moving-GC stress. +// Both closure fields must use the current instance after later effects move it. +function later(): number { + let sum = 0; + for (let i = 0; i < 128; i++) { + const temporary = { value: i }; + sum += temporary.value; + } + return sum; +} +class Base { + value = 0; + method = () => this.value; + constructor(value: number) { + this.value = value; + later(); + } +} +class Child extends Base { + next = () => this.value + 1; + effect = later(); + constructor(value: number) { + super(value); + this.value = value * 2; + later(); + } +} +const first = new Child(11); +const second = new Child(23); +console.log(first.method(), second.method(), first.next(), second.next()); +console.log(first.method !== second.method, first.effect, second.effect); +second.method = () => 99; +console.log(first.method(), second.method()); +delete (first as any).next; +console.log(typeof first.next, second.next()); +Object.defineProperty(second, "next", { get: () => () => 123 }); +console.log(second.next()); +class Replacement { + method = () => 1; + constructor() { + return { method: () => 37 } as any; + } +} +console.log(new Replacement().method()); diff --git a/test-files/test_gap_iterator_close_canceled_completion.ts b/test-files/test_gap_iterator_close_canceled_completion.ts new file mode 100644 index 0000000000..e43a2ca076 --- /dev/null +++ b/test-files/test_gap_iterator_close_canceled_completion.ts @@ -0,0 +1,274 @@ +// Exact transcript regression: a captured exit from finally cancels only the +// completion originating inside its target, preserving inherited outer returns. +function test0(): void { + const events: string[] = []; let count = 0; + const iterable: any = { [Symbol.iterator]() { return { + next() { events.push("next"); return { done: count++ === 2, value: count }; }, + return() { events.push("close"); return {}; } + }; } }; + function fail(): number { events.push("init"); throw "init-error"; } + function consume(): number { + consumeLoop: for (const value of iterable) { + while (true) { try { events.push("return"); return 1; } finally { events.push("finally"); break; } } + events.push("body-tail"); + } + return 9; + } + events.push("result:" + consume()); console.log("inner-break", events.join(",")); +} +test0(); +function test1(): void { + const events: string[] = []; let count = 0; + const iterable: any = { [Symbol.iterator]() { return { + next() { events.push("next"); return { done: count++ === 2, value: count }; }, + return() { events.push("close"); return {}; } + }; } }; + function fail(): number { events.push("init"); throw "init-error"; } + function consume(): number { + consumeLoop: for (const value of iterable) { + for (let i = 0; i < 2; i++) { try { events.push("return:" + i); return 1; } finally { events.push("finally"); continue; } } + events.push("body-tail"); + } + return 9; + } + events.push("result:" + consume()); console.log("inner-for-continue", events.join(",")); +} +test1(); +function test2(): void { + const events: string[] = []; let count = 0; + const iterable: any = { [Symbol.iterator]() { return { + next() { events.push("next"); return { done: count++ === 2, value: count }; }, + return() { events.push("close"); return {}; } + }; } }; + function fail(): number { events.push("init"); throw "init-error"; } + function consume(): number { + consumeLoop: for (const value of iterable) { + let i = 0; do { try { events.push("return:" + i); return 1; } finally { events.push("finally"); i++; continue; } } while (i < 2); + events.push("body-tail"); + } + return 9; + } + events.push("result:" + consume()); console.log("inner-do-continue", events.join(",")); +} +test2(); +function test3(): void { + const events: string[] = []; let count = 0; + const iterable: any = { [Symbol.iterator]() { return { + next() { events.push("next"); return { done: count++ === 2, value: count }; }, + return() { events.push("close"); return {}; } + }; } }; + function fail(): number { events.push("init"); throw "init-error"; } + function consume(): number { + consumeLoop: for (const value of iterable) { + switch (value) { default: try { events.push("return"); return 1; } finally { events.push("finally"); break; } } + events.push("body-tail"); + } + return 9; + } + events.push("result:" + consume()); console.log("switch-break", events.join(",")); +} +test3(); +function test4(): void { + const events: string[] = []; let count = 0; + const iterable: any = { [Symbol.iterator]() { return { + next() { events.push("next"); return { done: count++ === 2, value: count }; }, + return() { events.push("close"); return {}; } + }; } }; + function fail(): number { events.push("init"); throw "init-error"; } + function consume(): number { + consumeLoop: for (const value of iterable) { + exit: { try { events.push("return"); return 1; } finally { events.push("finally"); break exit; } } + events.push("body-tail"); + } + return 9; + } + events.push("result:" + consume()); console.log("label-block-break", events.join(",")); +} +test4(); +function test5(): void { + const events: string[] = []; let count = 0; + const iterable: any = { [Symbol.iterator]() { return { + next() { events.push("next"); return { done: count++ === 2, value: count }; }, + return() { events.push("close"); return {}; } + }; } }; + function fail(): number { events.push("init"); throw "init-error"; } + function consume(): number { + consumeLoop: for (const value of iterable) { + again: for (let i = 0; i < 2; i++) { switch (i) { default: try { events.push("return:" + i); return 1; } finally { events.push("finally"); continue again; } } } + events.push("body-tail"); + } + return 9; + } + events.push("result:" + consume()); console.log("label-loop-continue", events.join(",")); +} +test5(); +function test6(): void { + const events: string[] = []; let count = 0; + const iterable: any = { [Symbol.iterator]() { return { + next() { events.push("next"); return { done: count++ === 2, value: count }; }, + return() { events.push("close"); return {}; } + }; } }; + function fail(): number { events.push("init"); throw "init-error"; } + function consume(): number { + consumeLoop: for (const value of iterable) { + first: second: while (true) { try { events.push("return"); return 1; } finally { events.push("finally"); break first; } } + events.push("body-tail"); + } + return 9; + } + events.push("result:" + consume()); console.log("label-chain-break", events.join(",")); +} +test6(); +function test7(): void { + const events: string[] = []; let count = 0; + const iterable: any = { [Symbol.iterator]() { return { + next() { events.push("next"); return { done: count++ === 2, value: count }; }, + return() { events.push("close"); return {}; } + }; } }; + function fail(): number { events.push("init"); throw "init-error"; } + function consume(): number { + consumeLoop: for (const value of iterable) { + outer: while (true) { while (true) { try { events.push("return"); return 1; } finally { events.push("finally"); break outer; } } } + events.push("body-tail"); + } + return 9; + } + events.push("result:" + consume()); console.log("nested-target-break", events.join(",")); +} +test7(); +function test8(): void { + const events: string[] = []; let count = 0; + const iterable: any = { [Symbol.iterator]() { return { + next() { events.push("next"); return { done: count++ === 2, value: count }; }, + return() { events.push("close"); return {}; } + }; } }; + function fail(): number { events.push("init"); throw "init-error"; } + function consume(): number { + consumeLoop: for (const value of iterable) { + try { events.push("return"); return 1; } finally { events.push("finally"); while (true) { events.push("inner"); break; } events.push("cleanup-tail"); } + events.push("body-tail"); + } + return 9; + } + events.push("result:" + consume()); console.log("inherit-loop-break", events.join(",")); +} +test8(); +function test9(): void { + const events: string[] = []; let count = 0; + const iterable: any = { [Symbol.iterator]() { return { + next() { events.push("next"); return { done: count++ === 2, value: count }; }, + return() { events.push("close"); return {}; } + }; } }; + function fail(): number { events.push("init"); throw "init-error"; } + function consume(): number { + consumeLoop: for (const value of iterable) { + try { events.push("return"); return 1; } finally { events.push("finally"); switch (value) { default: events.push("inner"); break; } events.push("cleanup-tail"); } + events.push("body-tail"); + } + return 9; + } + events.push("result:" + consume()); console.log("inherit-switch-break", events.join(",")); +} +test9(); +function test10(): void { + const events: string[] = []; let count = 0; + const iterable: any = { [Symbol.iterator]() { return { + next() { events.push("next"); return { done: count++ === 2, value: count }; }, + return() { events.push("close"); return {}; } + }; } }; + function fail(): number { events.push("init"); throw "init-error"; } + function consume(): number { + consumeLoop: for (const value of iterable) { + try { events.push("return"); return 1; } finally { events.push("finally"); first: second: for (let i = 0; i < 2; i++) { events.push("inner:" + i); continue second; } events.push("cleanup-tail"); } + events.push("body-tail"); + } + return 9; + } + events.push("result:" + consume()); console.log("inherit-label-continue", events.join(",")); +} +test10(); +function test11(): void { + const events: string[] = []; let count = 0; + const iterable: any = { [Symbol.iterator]() { return { + next() { events.push("next"); return { done: count++ === 2, value: count }; }, + return() { events.push("close"); return {}; } + }; } }; + function fail(): number { events.push("init"); throw "init-error"; } + function consume(): number { + consumeLoop: for (const value of iterable) { + try { events.push("return"); return 1; } finally { events.push("finally"); while (true) { try { events.push("replacement"); return 2; } finally { events.push("inner-finally"); break; } } events.push("cleanup-tail"); } + events.push("body-tail"); + } + return 9; + } + events.push("result:" + consume()); console.log("inherit-canceled-replacement", events.join(",")); +} +test11(); +function test12(): void { + const events: string[] = []; let count = 0; + const iterable: any = { [Symbol.iterator]() { return { + next() { events.push("next"); return { done: count++ === 2, value: count }; }, + return() { events.push("close"); return {}; } + }; } }; + function fail(): number { events.push("init"); throw "init-error"; } + function consume(): number { + consumeLoop: for (const value of iterable) { + try { events.push("return"); return 1; } finally { events.push("finally"); continue consumeLoop; } + events.push("body-tail"); + } + return 9; + } + events.push("result:" + consume()); console.log("outer-label-continue", events.join(",")); +} +test12(); +function test13(): void { + const events: string[] = []; let count = 0; + const iterable: any = { [Symbol.iterator]() { return { + next() { events.push("next"); return { done: count++ === 2, value: count }; }, + return() { events.push("close"); return {}; } + }; } }; + function fail(): number { events.push("init"); throw "init-error"; } + function consume(): number { + consumeLoop: for (const value of iterable) { + try { for (let i = fail(); i < 2; i++) { events.push("unreachable"); } } catch (error) { events.push("caught:" + error); } + events.push("body-tail"); + } + return 9; + } + events.push("result:" + consume()); console.log("for-init-throw", events.join(",")); +} +test13(); +function test14(): void { + const events: string[] = []; let count = 0; + const iterable: any = { [Symbol.iterator]() { return { + next() { events.push("next"); return { done: count++ === 2, value: count }; }, + return() { events.push("close"); return {}; } + }; } }; + function fail(): number { events.push("init"); throw "init-error"; } + function consume(): number { + consumeLoop: for (const value of iterable) { + try { events.push("return"); return 1; } finally { events.push("finally"); first: second: for (let i = 0; i < 2; i++) { events.push("inner:" + i); continue first; } events.push("cleanup-tail"); } + events.push("body-tail"); + } + return 9; + } + events.push("result:" + consume()); console.log("inherit-chain-outer-continue", events.join(",")); +} +test14(); +function test15(): void { + const events: string[] = []; let count = 0; + const iterable: any = { [Symbol.iterator]() { return { + next() { events.push("next"); return { done: count++ === 2, value: count }; }, + return() { events.push("close"); return {}; } + }; } }; + function fail(): number { events.push("init"); throw "init-error"; } + function consume(): number { + consumeLoop: for (const value of iterable) { + first: second: for (let i = 0; i < 2; i++) { switch (i) { default: try { events.push("return:" + i); return 1; } finally { events.push("finally"); continue first; } } } + events.push("body-tail"); + } + return 9; + } + events.push("result:" + consume()); console.log("cancel-chain-outer-continue", events.join(",")); +} +test15(); diff --git a/test-files/test_gap_iterator_close_completion_order.ts b/test-files/test_gap_iterator_close_completion_order.ts new file mode 100644 index 0000000000..98c657e503 --- /dev/null +++ b/test-files/test_gap_iterator_close_completion_order.ts @@ -0,0 +1,135 @@ +// IteratorClose runs after nested finallies, reads return once, and invokes +// that method with the iterator receiver without reading its .call property. +function completionCase(mode: string): void { + const events: string[] = []; + let count = 0; + let reads = 0; + const iter: any = { + next() { + events.push("next"); + if (mode === "next-throw") throw "next-error"; + const done = count++ === 2; + return { + get done() { + events.push("done"); + if (mode === "done-throw") throw "done-error"; + return done; + }, + get value() { + events.push("value"); + if (mode === "value-throw") throw "value-error"; + return count; + } + }; + }, + get return() { + events.push("get-return"); + reads++; + if (mode === "break-get-throw" || mode === "throw-get-throw") throw "get-error"; + if (mode === "null") return null; + if (mode === "undefined") return undefined; + if (mode === "break-noncallable" || mode === "throw-noncallable") return 1; + const method: any = function() { + events.push(this === iter ? "receiver-ok" : "receiver-bad"); + events.push(reads === 1 ? "first-method" : "second-method"); + if (mode === "break-call-throw" || mode === "throw-call-throw") throw "call-error"; + if (mode === "break-nonobject" || mode === "throw-nonobject") return 1; + return {}; + }; + Object.defineProperty(method, "call", { + get() { events.push("poison-call"); throw "call-property-error"; } + }); + return method; + } + }; + const iterable: any = { [Symbol.iterator]() { return iter; } }; + function result(): number { + events.push("return-operand"); + if (mode === "return-operand-throw") throw "operand-error"; + return 17; + } + function consume(): number { + for (const value of iterable) { + try { + events.push("body:" + value); + if (mode === "continue") continue; + if (mode === "return" || mode === "return-operand-throw" || mode === "return-finally-break") return result(); + if (mode.indexOf("throw-") === 0) throw "body-error"; + break; + } finally { + events.push("inner-finally"); + if (mode === "break-finally-continue") continue; + if (mode === "break-finally-return") return result(); + if (mode === "return-finally-break") break; + if (mode === "break-finally-throw") throw "finally-error"; + } + } + events.push("after-loop"); + return 23; + } + try { events.push("result:" + consume()); } + catch (error) { events.push(error instanceof TypeError ? "TypeError" : String(error)); } + console.log(mode, events.join(",")); +} +for (const mode of ["break", "return", "continue", "null", "undefined", + "break-get-throw", "break-call-throw", "break-noncallable", "break-nonobject", + "throw-get-throw", "throw-call-throw", "throw-noncallable", "throw-nonobject", + "return-operand-throw", "break-finally-continue", "break-finally-return", + "return-finally-break", "break-finally-throw", "next-throw", "done-throw", "value-throw"]) { + completionCase(mode); +} + +function nestedCase(mode: string): void { + const events: string[] = []; + function iterable(name: string): any { + let count = 0; + return { [Symbol.iterator]() { return { + next() { events.push(name + ":next"); return { done: count++ === 2, value: count }; }, + return() { events.push(name + ":close"); return {}; } + }; } }; + } + outer: for (const a of iterable("outer")) { + for (const b of iterable("inner")) { + try { + events.push("body"); + if (mode === "outer-continue") continue outer; + if (mode === "outer-break") break outer; + // A switch break is captured locally; its continue targets this loop. + switch (b) { case 1: break; default: continue; } + events.push("after-switch"); + } finally { events.push("finally"); } + } + } + console.log(mode, events.join(",")); +} +for (const mode of ["outer-continue", "outer-break", "switch"]) nestedCase(mode); + +// Close errors occur after the inner try has completed; its catch must not +// intercept them, nor may the preserved finally execute a second time. +function closeOutsideCatch(): void { + const events: string[] = []; + const iterable: any = { [Symbol.iterator]() { return { + next() { return { done: false, value: 1 }; }, + return() { events.push("close"); return 1; } + }; } }; + try { + for (const value of iterable) { + try { events.push("body"); break; } + catch (error) { events.push("inner-catch"); } + finally { events.push("finally"); } + } + } catch (error) { events.push(error instanceof TypeError ? "TypeError" : String(error)); } + console.log("close-outside-catch", events.join(",")); +} +closeOutsideCatch(); + +const topEvents: string[] = []; +const topIterable: any = { [Symbol.iterator]() { return { + next() { return { done: false, value: 1 }; }, + get return() { topEvents.push("get-return"); return function() { topEvents.push("close"); return {}; }; } +}; } }; +for (const value of topIterable) { + try { topEvents.push("body"); break; } + finally { topEvents.push("finally"); } +} +console.log("top-level", topEvents.join(",")); diff --git a/test-files/test_gap_iterator_close_nested_catch.ts b/test-files/test_gap_iterator_close_nested_catch.ts new file mode 100644 index 0000000000..937687a81c --- /dev/null +++ b/test-files/test_gap_iterator_close_nested_catch.ts @@ -0,0 +1,180 @@ +function outerFinallyCatch(): void { + const events: string[] = []; + function consume(): number { + try { + try { events.push("return"); return 1; } + catch (error) { events.push("inner-catch:" + error); } + events.push("after-inner"); + } finally { events.push("outer-finally"); throw "outer-error"; } + return 9; + } + try { events.push("result:" + consume()); } + catch (error) { events.push("escaped:" + error); } + console.log("catch-no-finally", events.join(",")); +} +outerFinallyCatch(); +function closeCatch(): void { + const events: string[] = []; + const iterable: any = { [Symbol.iterator]() { return { + next() { events.push("next"); return {done:false,value:1}; }, + return() { events.push("close"); throw "close-error"; } + }; } }; + try { + for (const value of iterable) { + try { events.push("body"); break; } + catch (error) { events.push("inner-catch:" + error); break; } + events.push("after-inner"); + } + events.push("after-loop"); + } catch (error) { events.push("escaped:" + error); } + console.log("close-catch-no-finally", events.join(",")); +} +closeCatch(); +function handledReturnOperand(): void { + const events: string[] = []; + let count = 0; + const iterable: any = { [Symbol.iterator]() { return { + next() { events.push("next"); return {done:count++ === 2,value:count}; }, + return() { events.push("close"); return {}; } + }; } }; + function fail(): number { events.push("operand"); throw "operand-error"; } + function consume(): number { + for (const value of iterable) { + try { events.push("body:" + value); return fail(); } + catch (error) { events.push("caught:" + error); } + events.push("after-body"); + } + return 9; + } + events.push("result:" + consume()); + console.log("handled-return-operand", events.join(",")); +} +handledReturnOperand(); +function handledFinallyOverride(): void { + const events: string[] = []; + let count = 0; + const iterable: any = { [Symbol.iterator]() { return { + next() { events.push("next"); return {done:count++ === 2,value:count}; }, + return() { events.push("close"); return {}; } + }; } }; + for (const value of iterable) { + try { + try { events.push("body:" + value); break; } + finally { events.push("finally"); throw "override"; } + } catch (error) { events.push("caught:" + error); } + events.push("after-body"); + } + events.push("after-loop"); + console.log("handled-finally-override", events.join(",")); +} +handledFinallyOverride(); +function inheritedReturn(): void { + const events: string[] = []; + const iterable: any = { [Symbol.iterator]() { return { + next() { events.push("next"); return {done:false,value:1}; }, + return() { events.push("close"); return {}; } + }; } }; + function consume(): number { + for (const value of iterable) { + try { events.push("body"); return 7; } + finally { + events.push("finally"); + try { throw "local"; } catch (error) { events.push("caught:" + error); } + } + } + return 9; + } + events.push("result:" + consume()); + console.log("inherited-return", events.join(",")); +} +inheritedReturn(); + +// Both intervening catch-only scopes must be exited before outer cleanup. +function multipleCatchBoundaries(): void { + const events: string[] = []; + function consume(): number { + try { + try { + try { events.push("return"); return 3; } + catch (error) { events.push("inner:" + error); } + } catch (error) { events.push("middle:" + error); } + } finally { events.push("outer"); throw "outside"; } + return 9; + } + try { events.push("result:" + consume()); } + catch (error) { events.push("escaped:" + error); } + console.log("multiple-catch-boundaries", events.join(",")); +} +multipleCatchBoundaries(); + +// Each iterator has independent incoming completion; handled operands in +// the inner loop must leave both iterators running to normal exhaustion. +function nestedHandledOperand(): void { + const events: string[] = []; + function make(name: string): any { + let count = 0; + return { [Symbol.iterator]() { return { + next() { events.push(name + ":next"); return { done: count++ === 2, value: count }; }, + return() { events.push(name + ":close"); return {}; } + }; } }; + } + function fail(): number { events.push("operand"); throw "handled"; } + function consume(): number { + for (const outer of make("outer")) { + for (const inner of make("inner")) { + try { return fail(); } + catch (error) { events.push("caught:" + outer + ":" + inner); } + } + events.push("outer-body-done"); + } + return 9; + } + events.push("result:" + consume()); + console.log("nested-handled-operand", events.join(",")); +} +nestedHandledOperand(); + +// A switch break remains inside its try, so that catch is still live for +// the following throw. The later loop break exits it before IteratorClose. +function switchCatchRemainsLive(): void { + const events: string[] = []; + const iterable: any = { [Symbol.iterator]() { return { + next() { events.push("next"); return {done:false,value:1}; }, + return() { events.push("close"); throw "close-error"; } + }; } }; + try { + for (const value of iterable) { + try { + switch (value) { case 1: events.push("switch"); break; } + events.push("after-switch"); throw "body-error"; + } catch (error) { events.push("caught:" + error); break; } + } + } catch (error) { events.push("escaped:" + error); } + console.log("switch-catch-remains-live", events.join(",")); +} +switchCatchRemainsLive(); + +// A failed replacement return caught inside cleanup keeps the inherited +// pending return, including when its operand itself allocates a value. +function inheritedReturnAfterHandledOperand(): void { + const events: string[] = []; + const iterable: any = { [Symbol.iterator]() { return { + next() { events.push("next"); return {done:false,value:1}; }, + return() { events.push("close"); return {}; } + }; } }; + function fail(): any { events.push("operand"); const x = {value:9}; throw "local"; } + function consume(): any { + for (const value of iterable) { + try { events.push("body"); return {value:7}; } + finally { + events.push("finally"); + try { return fail(); } catch (error) { events.push("caught:" + error); } + events.push("after-catch"); + } + } + return {value:9}; + } + events.push("result:" + consume().value); + console.log("inherited-return-handled-operand", events.join(",")); +} +inheritedReturnAfterHandledOperand(); diff --git a/test-files/test_gap_region_numeric_expression_order.ts b/test-files/test_gap_region_numeric_expression_order.ts new file mode 100644 index 0000000000..7a2153fd2a --- /dev/null +++ b/test-files/test_gap_region_numeric_expression_order.ts @@ -0,0 +1,158 @@ +// Bounded regions must guard after the left GetValue and retain the single +// existing try/iterator handler tree. Run with regions on/off and seeded +// moving GC; compare the exact event transcript against the pinned Node. +function makeCheck(value: any): any { + const check: any = {}; + check.value = value; + return check; +} + +function numericChecks(check: any, left: any): string { + let out = ""; + for (let i = 0; i < 4; i++) { + try { + out += String(left < check.value) + ","; + out += String(left <= check.value) + ","; + out += String(left > check.value) + ","; + out += String(left >= check.value) + ";"; + out += String(check.value < 166) + ","; + out += String(check.value <= 166) + ","; + out += String(check.value > 166) + ","; + out += String(check.value >= 166) + "|"; + } catch (e) { out += "caught|"; } + } + return out; +} +for (const n of [2, NaN, -0, Infinity, -Infinity]) { + console.log("number", String(n), numericChecks(makeCheck(n), 1)); +} +for (const n of [true, "2", undefined, null, { valueOf() { return 2; } }]) { + console.log("refusal", typeof n, numericChecks(makeCheck(n), 1)); +} +const spill: any = {}; +for (let i = 0; i < 40; i++) spill["key" + i] = i; +spill.value = 2; +console.log("spill", numericChecks(spill, 1)); +console.log("special-function", numericChecks(makeCheck(function() { return 2; }), 1)); + +function leftChangesRight(mode: string): void { + const events: string[] = []; + const check = makeCheck(2); + const input: any = {}; + Object.defineProperty(input, "data", { + get() { + events.push("left-data"); + if (mode === "string") check.value = "0"; + if (mode === "delete") delete check.value; + if (mode === "accessor") { + Object.defineProperty(check, "value", { + configurable: true, + get() { events.push("right-get"); return 0; } + }); + } + if (mode === "prototype") { + delete check.value; + Object.setPrototypeOf(check, { value: 0 }); + } + return { get length() { events.push("left-length"); return 1; } }; + } + }); + const values: boolean[] = []; + for (let i = 0; i < 3; i++) { + try { values.push(input.data.length < check.value); } + catch (e) { events.push("caught"); } + } + console.log("mutation", mode, values.join(","), events.join(",")); +} +for (const mode of ["none", "string", "delete", "accessor", "prototype"]) leftChangesRight(mode); + +function coercionOrder(): void { + const events: string[] = []; + const input: any = {}; + const check: any = {}; + Object.defineProperty(input, "length", { get() { + events.push("left-get"); + return { valueOf() { events.push("left-valueOf"); return 1; } }; + }}); + Object.defineProperty(check, "value", { get() { + events.push("right-get"); + return { valueOf() { events.push("right-valueOf"); return 2; } }; + }}); + for (let i = 0; i < 2; i++) { + try { console.log("coerce", input.length < check.value); } + catch (e) { events.push("caught"); } + } + console.log("order", events.join(",")); +} +coercionOrder(); + +function iteratorClose(mode: string): void { + const events: string[] = []; + const iterable: any = { + [Symbol.iterator]() { + let count = 0; + return { + next() { + events.push("next"); + if (mode === "next-throw") throw "next-error"; + const done = count++ === 2; + return { + get done() { events.push("done"); if (mode === "done-throw") throw "done-error"; return done; }, + get value() { events.push("value"); if (mode === "value-throw") throw "value-error"; return makeCheck(2); } + }; + }, + get return() { + events.push("return-get"); + if (mode === "getter-throw") throw "close-get"; + return function() { + events.push("return-call"); + if (mode === "call-throw") throw "close-call"; + if (mode === "return-nonobject") return 1; + return {}; + }; + } + }; + } + }; + try { + for (const check of iterable) { + try { + events.push(String(check.value >= 1)); + if (mode === "continue") continue; + if (mode === "break" || mode === "return-nonobject") break; + throw "body"; + } finally { events.push("finally"); } + } + } catch (e) { events.push(e instanceof TypeError ? "TypeError" : String(e)); } + console.log("close", mode, events.join(",")); +} +for (const mode of ["body", "getter-throw", "call-throw", "continue", "break", "next-throw", "done-throw", "value-throw", "return-nonobject"]) iteratorClose(mode); + +function iteratorReturn(): string { + const events: string[] = []; + const iterable: any = { [Symbol.iterator]() { return { + next() { events.push("next"); return { done: false, value: makeCheck(2) }; }, + return() { events.push("return"); return {}; } + }; } }; + function consume(): void { + for (const check of iterable) { + try { events.push(String(check.value > 1)); return; } + finally { events.push("finally"); } + } + } + consume(); + return events.join(","); +} +console.log("close-return", iteratorReturn()); + +function capturedReceiver(): void { + let check: any = makeCheck(2); + const callback = () => { check = makeCheck(0); }; + const out: boolean[] = []; + for (let i = 0; i < 2; i++) { + try { callback(); out.push(check.value >= 1); } + catch (e) { out.push(true); } + } + console.log("captured", out.join(",")); +} +capturedReceiver(); diff --git a/test-files/test_gap_region_unmarked_numeric_read.ts b/test-files/test_gap_region_unmarked_numeric_read.ts new file mode 100644 index 0000000000..ed3025bf2c --- /dev/null +++ b/test-files/test_gap_region_unmarked_numeric_read.ts @@ -0,0 +1,52 @@ +// Object() allocates a classless runtime record. Appending value learns F64, +// while the record still lacks the plain-data store birth mark. +function count(check: any): number { + let hits = 0; + for (let i = 0; i < 12; i++) { + try { + if (i < check.value) hits++; + } catch { + hits += 100; + } + } + return hits; +} + +const check: any = Object(); +check.kind = "min"; +check.value = 5; +console.log("numeric", count(check), count(check)); +check.value = "7"; +console.log("generalized", count(check)); +let getterCalls = 0; +Object.defineProperty(check, "value", { + configurable: true, + get() { getterCalls++; return 3; }, +}); +console.log("accessor", count(check), getterCalls); + +const altered: any = Object(); +altered.value = 4; +console.log("before-prototype", count(altered)); +Object.setPrototypeOf(altered, null); +console.log("null-prototype", count(altered)); +delete altered.value; +console.log("absent", count(altered)); + +const left: any = Object(); +left.value = 8; +const right: any = Object(); +right.value = 6; +let changed = 0; +Object.defineProperty(left, "value", { + get() { changed++; right.value = changed % 2 ? 4 : "9"; return 5; }, +}); +let total = 0; +for (let i = 0; i < 10; i++) { + try { + if (left.value < right.value) total++; + } catch { + total += 100; + } +} +console.log("left-before-guard", total, changed); diff --git a/test-files/test_thread_concat_site_cache.ts b/test-files/test_thread_concat_site_cache.ts new file mode 100644 index 0000000000..f2056e6857 --- /dev/null +++ b/test-files/test_thread_concat_site_cache.ts @@ -0,0 +1,18 @@ +import { parallelMap } from 'perry/thread'; + +// Two launches call the same concat site. Every cached slot must belong to +// the executing worker, even after the first launch retires its arenas. +function checkConcat(n: number) { + let length = 0; + for (let i = 0; i < 128; i++) { + const text = 'worker-' + i; + if (text !== 'worker-'.concat(String(i))) { + throw new Error('worker concat changed: ' + text); + } + length = text.length; + } + return length + n; +} +const first = parallelMap([1, 2], (n: number) => checkConcat(n)); +const second = parallelMap([1, 2], (n: number) => checkConcat(n)); +console.log(first[0], first[1], second[0], second[1]); From 581b53f653532d35411dc9719a57e63439c31133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 2 Oct 2026 11:27:19 +0200 Subject: [PATCH 02/17] fix(runtime): reuse ConstFn key-add transitions after ordered stores --- .../src/object/constfn_key_add.rs | 129 ++++++++ .../src/object/constfn_key_add_tests.rs | 305 ++++++++++++++++++ .../src/object/field_set_by_name.rs | 20 +- .../object/field_set_by_name/fast_paths.rs | 14 +- .../src/object/field_set_by_name/tail.rs | 14 +- crates/perry-runtime/src/object/mod.rs | 1 + 6 files changed, 465 insertions(+), 18 deletions(-) create mode 100644 crates/perry-runtime/src/object/constfn_key_add.rs create mode 100644 crates/perry-runtime/src/object/constfn_key_add_tests.rs diff --git a/crates/perry-runtime/src/object/constfn_key_add.rs b/crates/perry-runtime/src/object/constfn_key_add.rs new file mode 100644 index 0000000000..c7e83d0249 --- /dev/null +++ b/crates/perry-runtime/src/object/constfn_key_add.rs @@ -0,0 +1,129 @@ +//! Ordered reuse of a body-specific key-add edge. The existing weak +//! transition cache supplies live target keys; shape records own body facts. +//! No new registry or closure root is introduced; a miss keeps the ordered mint. + +use super::{field_rep, field_rep_store, shapes, ObjectHeader}; + +/// The caller has completed the ordinary Set interception/attribute vet. +/// Reuse only an exact current cached body edge whose Any publication shape +/// is still present. This path never allocates in the GC heap or enters JS: +/// table lookup, carrier notes, the regular barrier and both stamps are +/// non-collecting. The current closure is written under Any before SPECIAL. +unsafe fn try_cached_constfn_key_add( + obj: *mut ObjectHeader, + predecessor: u32, + hit: (super::ObjectKeys, u32, u32), + bits: u64, +) -> bool { + let (keys, slot, target) = hit; + let Some(d) = shapes::shape_descriptor_by_id(target) else { + return false; + }; + // Reuse an edge whose only ConstFn lane is the appended slot. Other + // target forms retain the existing slow publication contract. + if slot >= field_rep::REP_SLOTS + || d.special_constfn_mask != 1u32 << slot + || d.deprecation_targets() != (0, 0) + || field_rep::has_deprecated(d.rep) + || d.keys != keys.arr() as usize as u64 + || d.logical_key_count != keys.count() + || d.live_inline_slot_count <= slot + || d.live_inline_slot_count + > super::object_live_slot_count(obj).max(super::INLINE_SLOT_FLOOR as u32) + || d.constfn_infos().len() != 1 + || u32::from(d.constfn_infos()[0].slot) != slot + || field_rep_store::constfn_store_info(bits) != Some(d.constfn_infos()[0].info) + { + return false; + } + let base_rep = field_rep::with_slot_rep(d.rep, slot, field_rep::REP_ANY); + let Some(any) = shapes::shape_descriptor_find_with_rep( + keys.arr(), + d.logical_key_count, + d.live_inline_slot_count, + d.semantic_generation, + d.object_kind, + d.hole_count, + d.proto_id, + d.summary, + base_rep, + ) else { + // A collector may have pruned the uncarried Any intermediate. Re-mint + // it through the rooted slow path rather than cache a second edge. + return false; + }; + if !shapes::shape_descriptor_by_id(any).is_some_and(|base| { + base.rep == base_rep + && base.special_constfn_mask == 0 + && base.deprecation_targets() == (0, 0) + }) { + return false; + } + if !shapes::install_cached_object_shape_transition(obj, predecessor, any, keys) { + return false; + } + #[cfg(test)] + assert_eq!( + field_rep_store::object_slot_rep(obj, slot as usize), + field_rep::REP_ANY + ); + super::slot_store::store_object_field_slot(obj, slot as usize, bits); + // No safepoint or callback separates the barrier from this exact shape + // stamp. The slot now contains this receiver's current safe closure. + #[cfg(test)] + { + assert_eq!( + field_rep_store::object_slot_rep(obj, slot as usize), + field_rep::REP_ANY + ); + let fields = (obj as *const u8).add(std::mem::size_of::()) as *const u64; + assert_eq!( + *fields.add(slot as usize), + bits, + "SPECIAL requires the current closure" + ); + } + shapes::stamp_object_shape_id_with_carrier_note(obj, target); + true +} + +/// Ordinary cache hits retain their existing publication. A completed ConstFn +/// hit has already stored this receiver's value under Any and stamped SPECIAL. +pub(super) enum CachedKeyAdd { + Transition(super::ObjectKeys, u32, u32), + StoredConstFn, +} + +impl CachedKeyAdd { + #[inline(always)] + pub(super) fn transition(self) -> Option<(super::ObjectKeys, u32, u32)> { + match self { + Self::Transition(keys, slot, target) => Some((keys, slot, target)), + Self::StoredConstFn => None, + } + } +} + +/// Resolve a single existing cache probe after the caller's Set semantic vet. +/// Key-only publication retains its SPECIAL refusal in `cached_key_add_admits`. +#[inline(always)] +pub(super) unsafe fn admit_or_store( + obj: *mut ObjectHeader, + predecessor: u32, + hit: (super::ObjectKeys, u32, u32), + bits: u64, +) -> Option { + if field_rep_store::cached_key_add_admits(hit.2, hit.1, Some(bits)) { + return Some(CachedKeyAdd::Transition(hit.0, hit.1, hit.2)); + } + if try_cached_constfn_key_add(obj, predecessor, hit, bits) { + return Some(CachedKeyAdd::StoredConstFn); + } + #[cfg(feature = "shape-mint-diag")] + super::shape_mint_census::note_transition_rep_refused(); + None +} + +#[cfg(test)] +#[path = "constfn_key_add_tests.rs"] +mod tests; diff --git a/crates/perry-runtime/src/object/constfn_key_add_tests.rs b/crates/perry-runtime/src/object/constfn_key_add_tests.rs new file mode 100644 index 0000000000..17adc0cec1 --- /dev/null +++ b/crates/perry-runtime/src/object/constfn_key_add_tests.rs @@ -0,0 +1,305 @@ +//! Positive cache reuse and refusals for ordered ConstFn key-add publication. +use super::*; +use crate::{closure, gc, object, value}; + +extern "C" fn capture_body(c: *const closure::ClosureHeader, _this: closure::JsThis) -> f64 { + f64::from_bits(closure::js_closure_get_capture_bits(c, 0)) +} +extern "C" fn other_body(_c: *const closure::ClosureHeader, _this: closure::JsThis) -> f64 { + 99.0 +} +fn info() -> *const closure::JsFunctionInfo { + crate::fn_info!(capture_body, 0; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)) +} +unsafe fn bits(c: *mut closure::ClosureHeader) -> u64 { + value::js_nanbox_pointer(c as i64).to_bits() +} +unsafe fn slot(obj: *mut ObjectHeader) -> *mut closure::ClosureHeader { + object::js_object_get_field(obj, 0).as_pointer::() as *mut _ +} +unsafe fn key(name: &str) -> *mut crate::StringHeader { + crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32) +} + +#[test] +fn cached_constfn_key_add_uses_current_factory_captures() { + let _lock = gc::global_side_table_test_lock(); + let _no_move = gc::GcSuppressScope::new(); + let scope = gc::RuntimeHandleScope::new(); + unsafe { + let key = scope.root_raw_mut_ptr(key("cached_cf_factory_method")); + let a = scope.root_raw_mut_ptr(closure::js_closure_alloc(info(), 1)); + let b = scope.root_raw_mut_ptr(closure::js_closure_alloc(info(), 1)); + closure::js_closure_set_capture_f64(a.get_raw_mut_ptr(), 0, 31.0); + closure::js_closure_set_capture_f64(b.get_raw_mut_ptr(), 0, 47.0); + let first = scope.root_raw_mut_ptr(object::js_object_alloc(0, 4)); + object::js_object_set_field_by_name( + first.get_raw_mut_ptr(), + key.get_raw_mut_ptr(), + f64::from_bits(bits(a.get_raw_mut_ptr())), + ); + let target = shapes::object_shape_stamp(first.get_raw_mut_ptr()); + assert_eq!( + shapes::shape_descriptor_by_id(target) + .unwrap() + .special_constfn_mask, + 1 + ); + let second = scope.root_raw_mut_ptr(object::js_object_alloc(0, 4)); + shapes::test_watch_cached_transition_stamps( + second.get_raw_mut_ptr::() as usize + ); + object::js_object_set_field_by_name( + second.get_raw_mut_ptr(), + key.get_raw_mut_ptr(), + f64::from_bits(bits(b.get_raw_mut_ptr())), + ); + assert_eq!( + shapes::test_cached_transition_stamps(), + 1, + "must install cached Any intermediate" + ); + shapes::test_reset_cached_transition_stamps(); + assert_eq!(shapes::object_shape_stamp(second.get_raw_mut_ptr()), target); + assert_eq!(slot(second.get_raw_mut_ptr()), b.get_raw_mut_ptr()); + assert_eq!( + capture_body(slot(first.get_raw_mut_ptr()), closure::JsThis::UNDEFINED), + 31.0 + ); + assert_eq!( + capture_body(slot(second.get_raw_mut_ptr()), closure::JsThis::UNDEFINED), + 47.0 + ); + } +} + +#[test] +fn cached_constfn_key_add_refuses_wrong_body_unsafe_this_and_deprecation() { + let _lock = gc::global_side_table_test_lock(); + let _no_move = gc::GcSuppressScope::new(); + let scope = gc::RuntimeHandleScope::new(); + unsafe { + let key = scope.root_raw_mut_ptr(key("cached_cf_refused_method")); + let closure = scope.root_raw_mut_ptr(closure::js_closure_alloc(info(), 1)); + let first = scope.root_raw_mut_ptr(object::js_object_alloc(0, 4)); + let pred = shapes::object_shape_stamp(first.get_raw_mut_ptr()); + object::js_object_set_field_by_name( + first.get_raw_mut_ptr(), + key.get_raw_mut_ptr(), + f64::from_bits(bits(closure.get_raw_mut_ptr())), + ); + let hit = + object::transition_cache_lookup(pred, key.get_raw_mut_ptr()).expect("cached body edge"); + let receiver = scope.root_raw_mut_ptr(object::js_object_alloc(0, 4)); + assert_eq!(shapes::object_shape_stamp(receiver.get_raw_mut_ptr()), pred); + let wrong = scope.root_raw_mut_ptr(closure::js_closure_alloc( + crate::fn_info!(other_body, 0; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)), + 0, + )); + let unsafe_this = scope.root_raw_mut_ptr(closure::js_closure_alloc( + info(), + closure::CAPTURES_THIS_FLAG, + )); + let unloadable = scope.root_raw_mut_ptr(closure::js_closure_alloc( + crate::fn_info!(capture_body, 0), + 1, + )); + for value in [ + bits(wrong.get_raw_mut_ptr()), + bits(unsafe_this.get_raw_mut_ptr()), + bits(unloadable.get_raw_mut_ptr()), + 9.0f64.to_bits(), + value::TAG_UNDEFINED, + ] { + assert!(admit_or_store(receiver.get_raw_mut_ptr(), pred, hit, value).is_none()); + assert_eq!(shapes::object_shape_stamp(receiver.get_raw_mut_ptr()), pred); + assert_eq!( + object::js_object_get_field(receiver.get_raw_mut_ptr(), 0).bits(), + value::TAG_UNDEFINED + ); + } + assert!(shapes::shape_record_by_id(hit.2) + .unwrap() + .deprecate_special_to_any(hit.1)); + assert!(admit_or_store( + receiver.get_raw_mut_ptr(), + pred, + hit, + bits(closure.get_raw_mut_ptr()) + ) + .is_none()); + assert_eq!(shapes::object_shape_stamp(receiver.get_raw_mut_ptr()), pred); + assert_eq!( + object::js_object_get_field(receiver.get_raw_mut_ptr(), 0).bits(), + value::TAG_UNDEFINED + ); + } +} + +#[test] +fn cached_constfn_key_add_moves_receiver_and_current_closure() { + moving_roundtrip("cfmove1", 1); +} + +#[test] +fn cached_constfn_key_add_long_key_safely_falls_back_after_relocation() { + moving_roundtrip("cached_cf_long_moving_method", 0); +} + +fn moving_roundtrip(method_key: &str, expected_cached_stamps: u64) { + let _guard = gc::CopyingNurseryTestGuard::new(0); + let _triggers = gc::GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _forced = gc::knob_overrides::ForcedEvacuationTestGuard::on(); + gc::register_runtime_handle_root_scanner_for_tests(); + gc::gc_register_mutable_root_scanner(crate::string::scan_intern_table_roots_mut); + gc::gc_register_mutable_root_scanner(object::scan_object_cache_roots_mut); + gc::gc_register_mutable_root_scanner(object::scan_shape_cache_roots_mut); + gc::gc_register_mutable_root_scanner(object::scan_transition_cache_roots_mut); + gc::gc_register_mutable_root_scanner(shapes::scan_shape_table_rekey_mut); + let previous = + gc::set_conservative_stack_scan_override(Some(gc::ConservativeStackScanMode::Disabled)); + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + gc::set_conservative_stack_scan_override(self.0); + } + } + let _restore = Restore(previous); + let scope = gc::RuntimeHandleScope::new(); + unsafe { + let key = scope.root_raw_mut_ptr(key(method_key)); + let a = scope.root_raw_mut_ptr(closure::js_closure_alloc(info(), 1)); + let b = scope.root_raw_mut_ptr(closure::js_closure_alloc(info(), 1)); + closure::js_closure_set_capture_f64(a.get_raw_mut_ptr(), 0, 17.0); + closure::js_closure_set_capture_f64(b.get_raw_mut_ptr(), 0, 29.0); + let first = scope.root_raw_mut_ptr(object::js_object_alloc(0, 4)); + object::js_object_set_field_by_name( + first.get_raw_mut_ptr(), + key.get_raw_mut_ptr(), + f64::from_bits(bits(a.get_raw_mut_ptr())), + ); + let target = shapes::object_shape_stamp(first.get_raw_mut_ptr()); + let second = scope.root_raw_mut_ptr(object::js_object_alloc(0, 4)); + let original_receiver = second.get_raw_mut_ptr::() as usize; + let original_closure = b.get_raw_mut_ptr::() as usize; + assert!(crate::arena::pointer_in_nursery(original_receiver)); + assert!(crate::arena::pointer_in_nursery(original_closure)); + gc::gc_collect_minor(); + assert_ne!( + second.get_raw_mut_ptr::() as usize, + original_receiver, + "pre-store receiver must move" + ); + assert_ne!( + b.get_raw_mut_ptr::() as usize, + original_closure, + "incoming closure root must refresh" + ); + assert_eq!( + field_rep_store::object_slot_rep(second.get_raw_mut_ptr(), 0), + field_rep::REP_ANY + ); + assert_eq!( + object::js_object_get_field(second.get_raw_mut_ptr(), 0).bits(), + value::TAG_UNDEFINED + ); + shapes::test_watch_cached_transition_stamps( + second.get_raw_mut_ptr::() as usize + ); + object::js_object_set_field_by_name( + second.get_raw_mut_ptr(), + key.get_raw_mut_ptr(), + f64::from_bits(bits(b.get_raw_mut_ptr())), + ); + assert_eq!( + shapes::test_cached_transition_stamps(), + expected_cached_stamps, + "content keys reuse the cache; relocated pointer keys safely miss" + ); + shapes::test_reset_cached_transition_stamps(); + let before_receiver = second.get_raw_mut_ptr::() as usize; + let before_closure = slot(second.get_raw_mut_ptr()) as usize; + gc::gc_collect_minor(); + assert_ne!( + second.get_raw_mut_ptr::() as usize, + before_receiver, + "post-store receiver must move" + ); + assert_ne!( + slot(second.get_raw_mut_ptr()) as usize, + before_closure, + "SPECIAL current closure slot must rewrite" + ); + assert_eq!(shapes::object_shape_stamp(second.get_raw_mut_ptr()), target); + assert_eq!( + capture_body(slot(first.get_raw_mut_ptr()), closure::JsThis::UNDEFINED), + 17.0 + ); + assert_eq!( + capture_body(slot(second.get_raw_mut_ptr()), closure::JsThis::UNDEFINED), + 29.0 + ); + } +} + +#[test] +fn cached_constfn_key_add_preserves_preceding_f64_lane() { + let _lock = gc::global_side_table_test_lock(); + let _no_move = gc::GcSuppressScope::new(); + let scope = gc::RuntimeHandleScope::new(); + unsafe { + let number_key = scope.root_raw_mut_ptr(key("cfnum001")); + let method_key = scope.root_raw_mut_ptr(key("cfmethod")); + let a = scope.root_raw_mut_ptr(closure::js_closure_alloc(info(), 1)); + let b = scope.root_raw_mut_ptr(closure::js_closure_alloc(info(), 1)); + closure::js_closure_set_capture_f64(a.get_raw_mut_ptr(), 0, 41.0); + closure::js_closure_set_capture_f64(b.get_raw_mut_ptr(), 0, 43.0); + let first = scope.root_raw_mut_ptr(object::js_object_alloc(0, 4)); + object::js_object_set_field_by_name( + first.get_raw_mut_ptr(), + number_key.get_raw_mut_ptr(), + 3.0, + ); + object::js_object_set_field_by_name( + first.get_raw_mut_ptr(), + method_key.get_raw_mut_ptr(), + f64::from_bits(bits(a.get_raw_mut_ptr())), + ); + let target = shapes::object_shape_stamp(first.get_raw_mut_ptr()); + assert_eq!( + shapes::shape_descriptor_by_id(target) + .unwrap() + .special_constfn_mask, + 2 + ); + let second = scope.root_raw_mut_ptr(object::js_object_alloc(0, 4)); + object::js_object_set_field_by_name( + second.get_raw_mut_ptr(), + number_key.get_raw_mut_ptr(), + 7.0, + ); + shapes::test_watch_cached_transition_stamps( + second.get_raw_mut_ptr::() as usize + ); + object::js_object_set_field_by_name( + second.get_raw_mut_ptr(), + method_key.get_raw_mut_ptr(), + f64::from_bits(bits(b.get_raw_mut_ptr())), + ); + assert_eq!(shapes::test_cached_transition_stamps(), 1); + shapes::test_reset_cached_transition_stamps(); + assert_eq!(shapes::object_shape_stamp(second.get_raw_mut_ptr()), target); + assert_eq!( + field_rep_store::object_slot_rep(second.get_raw_mut_ptr(), 0), + field_rep::REP_F64 + ); + assert_eq!( + object::js_object_get_field(second.get_raw_mut_ptr(), 0).bits(), + 7.0f64.to_bits() + ); + let current = object::js_object_get_field(second.get_raw_mut_ptr(), 1) + .as_pointer::(); + assert_eq!(current, b.get_raw_mut_ptr()); + assert_eq!(capture_body(current, closure::JsThis::UNDEFINED), 43.0); + } +} diff --git a/crates/perry-runtime/src/object/field_set_by_name.rs b/crates/perry-runtime/src/object/field_set_by_name.rs index 7ca44332c6..cc40b799b0 100644 --- a/crates/perry-runtime/src/object/field_set_by_name.rs +++ b/crates/perry-runtime/src/object/field_set_by_name.rs @@ -213,13 +213,21 @@ pub extern "C" fn js_object_set_field_by_name( { let prev_shape_id = super::shapes::object_shape_stamp(o); if prev_shape_id != 0 { - if let Some((next_keys, slot_idx, target_shape_id)) = - transition_cache_lookup_for_value( - prev_shape_id, - key, - Some(value.to_bits()), - ) + if let Some(edge) = transition_cache_lookup(prev_shape_id, key) + .and_then(|hit| { + super::constfn_key_add::admit_or_store( + o, + prev_shape_id, + hit, + value.to_bits(), + ) + }) { + let Some((next_keys, slot_idx, target_shape_id)) = + edge.transition() + else { + return; + }; // Same store semantics as the in-body fast // path: strip a raw-null POINTER_TAG value, // transition the keys array, note the dynamic diff --git a/crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs b/crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs index 496c743ae0..0cc13a2fa0 100644 --- a/crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs +++ b/crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs @@ -676,12 +676,8 @@ fn object_set_field_by_name_transition_fast_impl_value( let value = value_handle.get_nanbox_f64(); let prev_shape_id = super::shapes::object_shape_stamp(obj); - let Some((next_keys, slot_idx, target_shape_id)) = - transition_cache_lookup_for_value(prev_shape_id, interned_key, Some(value.to_bits())) - else { - return None; - }; - if next_keys.is_null() { + let hit = transition_cache_lookup(prev_shape_id, interned_key)?; + if hit.0.is_null() { return None; } @@ -697,6 +693,12 @@ fn object_set_field_by_name_transition_fast_impl_value( return None; } + let edge = + super::constfn_key_add::admit_or_store(obj, prev_shape_id, hit, value.to_bits())?; + let Some((next_keys, slot_idx, target_shape_id)) = edge.transition() else { + return Some(value_handle.get_nanbox_f64()); + }; + if !super::shapes::install_cached_object_shape_transition( obj, prev_shape_id, diff --git a/crates/perry-runtime/src/object/field_set_by_name/tail.rs b/crates/perry-runtime/src/object/field_set_by_name/tail.rs index 518f3daf0c..c79b566f87 100644 --- a/crates/perry-runtime/src/object/field_set_by_name/tail.rs +++ b/crates/perry-runtime/src/object/field_set_by_name/tail.rs @@ -569,12 +569,14 @@ pub(crate) fn set_field_by_name_object_tail( super::prop_plan::receiver_proto_bits(obj), ); } - let lane_probe = transition_cache_lookup_for_value( - prev_shape_id, - interned_key, - Some(value.to_bits()), - ); - if let Some((next_keys, slot_idx, target_shape_id)) = lane_probe { + let lane_probe = transition_cache_lookup(prev_shape_id, interned_key).and_then(|hit| { + super::constfn_key_add::admit_or_store(obj, prev_shape_id, hit, value.to_bits()) + }); + if let Some(edge) = lane_probe { + let Some((next_keys, slot_idx, target_shape_id)) = edge.transition() else { + mirror_class_object_static_write(obj, key, value); + return; + }; // Defensive: strip a raw-null POINTER_TAG value the same // way the slow overflow path below does, so a bogus // 0x7FFD_0000_0000_0000 store doesn't leak into an diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 871643b5ca..721b3bf24c 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -102,6 +102,7 @@ pub(crate) mod accessor_pair; pub(crate) mod attr_census; pub(crate) mod canonical_keys; mod census; +mod constfn_key_add; pub(crate) mod field_rep; pub(crate) mod field_rep_store; pub(crate) mod key_attrs; From 344b9fa7d9d9acac475af4b6faedaf096e155394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 2 Oct 2026 11:35:35 +0200 Subject: [PATCH 03/17] docs: record ordered ConstFn transition reuse --- changelog.d/11680-one-shape-campaign.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/changelog.d/11680-one-shape-campaign.md b/changelog.d/11680-one-shape-campaign.md index 36c05f504c..1b44c5fe52 100644 --- a/changelog.d/11680-one-shape-campaign.md +++ b/changelog.d/11680-one-shape-campaign.md @@ -29,3 +29,10 @@ Fix synchronous for-of IteratorClose when an inner break or continue from finall Preserve normal call boundaries for closure-bearing inline candidates inside loops that perform receiver field arithmetic. This keeps closure creation out of the body that guarded numeric regions must version, while retaining codegen's refusal to duplicate closures and its recheck after calls. Tiny callees, calls outside these loops, and loops without receiver field arithmetic continue to inline. Focused tests cover all loop forms, nested helper inlining, and both controls. Run the try/catch native-root probe on Windows after its exception lowering moved to landing pads. Require Node-equivalent execution, a native root map, nonzero evacuation and RS4GC root records; keep the linker refusal for actual WinEH funclet IR. Use the runtime TLS declaration macro for worker launch test observations. + +Reuse cached key-add transitions for an exact safe ConstFn body when its traced +Any intermediate is still live. Store the current receiver's closure with the +ordinary barrier before publishing its body-specific shape, using one existing +cache probe. Missing intermediates, deprecated facts and unsupported targets +retain the rooted slow publication path. Regressions cover distinct captures, +actual moving collections, pointer-key fallback and publication ordering. From bf1b622a7ac388bfabaa6eaa77969f62d42c84f3 Mon Sep 17 00:00:00 2001 From: Perry Maintainer Date: Fri, 2 Oct 2026 03:32:10 -0700 Subject: [PATCH 04/17] Verify SPECIAL ConstFn field body invariants --- crates/perry-runtime/src/gc/forwarding.rs | 27 +++++++ crates/perry-runtime/src/gc/mod.rs | 6 ++ .../src/object/constfn_key_add_tests.rs | 10 +++ .../src/object/field_rep_store.rs | 67 +++++++++++----- .../src/object/field_rep_store_tests.rs | 79 ++++++++++++++++++- crates/perry-runtime/src/object/gc_slots.rs | 2 +- .../perry-runtime/src/object/method_site.rs | 14 ++++ 7 files changed, 183 insertions(+), 22 deletions(-) diff --git a/crates/perry-runtime/src/gc/forwarding.rs b/crates/perry-runtime/src/gc/forwarding.rs index d45dfae7da..d6c73026f7 100644 --- a/crates/perry-runtime/src/gc/forwarding.rs +++ b/crates/perry-runtime/src/gc/forwarding.rs @@ -212,3 +212,30 @@ pub(super) fn accept_forwarding_target(user_addr: usize) -> bool { note_refused_forwarding_target(); false } + +/// Resolve a live field's closure while collector traversal may still see a +/// forwarding stub. Use the same validated source/target gates as the copying +/// rewrite walk; never read closure payload metadata from a forwarded stub. +#[cfg(any( + debug_assertions, + feature = "field-rep-assert", + feature = "gc-instruments" +))] +pub(crate) fn field_rep_live_address(mut addr: usize) -> Option { + for _ in 0..64 { + let Some(header) = forwarding_walk_header(addr) else { + return Some(addr); + }; + unsafe { + if (*header).gc_flags & GC_FLAG_FORWARDED == 0 { + return Some(addr); + } + let next = forwarding_address(header) as usize; + if next == addr || !accept_forwarding_target(next) { + return None; + } + addr = next; + } + } + None +} diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index ac263f992d..2329410162 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -184,6 +184,12 @@ use copying_first_cycle::*; // Named rather than glob-imported: a glob does not propagate through the // transitive re-exports the gc submodules reach these through. use copying_pointer_set::{plausible_gc_header, CopyingPointer, CopyingPointerKind}; +#[cfg(any( + debug_assertions, + feature = "field-rep-assert", + feature = "gc-instruments" +))] +pub(crate) use forwarding::field_rep_live_address; use forwarding::*; use sticky_remembered::*; // The copied-minor pointer classifier is consumed by the weak-holder registry diff --git a/crates/perry-runtime/src/object/constfn_key_add_tests.rs b/crates/perry-runtime/src/object/constfn_key_add_tests.rs index 17adc0cec1..8ef8c8a555 100644 --- a/crates/perry-runtime/src/object/constfn_key_add_tests.rs +++ b/crates/perry-runtime/src/object/constfn_key_add_tests.rs @@ -219,7 +219,17 @@ fn moving_roundtrip(method_key: &str, expected_cached_stamps: u64) { shapes::test_reset_cached_transition_stamps(); let before_receiver = second.get_raw_mut_ptr::() as usize; let before_closure = slot(second.get_raw_mut_ptr()) as usize; + field_rep_store::assert_field_rep_lanes( + second.get_raw_mut_ptr(), + shapes::object_shape_record(second.get_raw_mut_ptr()), + 1, + ); gc::gc_collect_minor(); + field_rep_store::assert_field_rep_lanes( + second.get_raw_mut_ptr(), + shapes::object_shape_record(second.get_raw_mut_ptr()), + 1, + ); assert_ne!( second.get_raw_mut_ptr::() as usize, before_receiver, diff --git a/crates/perry-runtime/src/object/field_rep_store.rs b/crates/perry-runtime/src/object/field_rep_store.rs index cc527a3880..982b15283a 100644 --- a/crates/perry-runtime/src/object/field_rep_store.rs +++ b/crates/perry-runtime/src/object/field_rep_store.rs @@ -492,7 +492,7 @@ pub(crate) fn shape_slot_is_f64(id: u32, slot: u32) -> bool { } /// Does every trace of an object check the field-representation invariant -/// ([`assert_f64_lanes_hold_numbers`])? Always in a debug build or with the +/// ([`assert_field_rep_lanes`])? Always in a debug build or with the /// `field-rep-assert` feature; with `gc-instruments`, when /// `PERRY_FIELD_REPR_VERIFY=1` (DESIGN §3.1 verify mode). A binary built /// without either feature compiles no check, and the knob is one of @@ -554,17 +554,16 @@ pub(crate) unsafe fn birth_fill_f64_lanes(obj: *mut ObjectHeader) { } } -/// The field-representation invariant (charter step 5): inside the live -/// bound, every slot under an `F64` (or deprecated) lane of the receiver's -/// shape holds a canonical double. Run at every trace of an object when -/// [`field_rep_verify_enabled`], so a writer that skips the store check trips -/// it at the next collection. +/// Verify all admitted field representations, including deprecated carriers: +/// deprecation changes future admission, never an existing carrier's body fact. +/// Collector traversal may precede closure-slot rewriting, so SPECIAL checks +/// resolve validated forwarding before examining closure payload metadata. #[cfg(any( debug_assertions, feature = "field-rep-assert", feature = "gc-instruments" ))] -pub(crate) unsafe fn assert_f64_lanes_hold_numbers( +pub(crate) unsafe fn assert_field_rep_lanes( obj: *const ObjectHeader, record: Option, live: usize, @@ -578,22 +577,54 @@ pub(crate) unsafe fn assert_f64_lanes_hold_numbers( } let fields = (obj as *const u8).add(std::mem::size_of::()) as *const u64; for slot in 0..live.min(REP_SLOTS as usize) { - if !matches!( - slot_rep(rep, slot as u32), - field_rep::REP_F64 | field_rep::REP_F64_DEPRECATED - ) { - continue; - } let bits = *fields.add(slot); - if field_rep::f64_slot_bits(bits) != Some(bits) { - panic!( - "field-rep invariant: slot {slot} of {obj:p} (shape {:#x}, rep {rep:#x}) holds {bits:#018x}, not a canonical double", - object_shape_stamp(obj) - ); + match slot_rep(rep, slot as u32) { + field_rep::REP_F64 | field_rep::REP_F64_DEPRECATED => { + if field_rep::f64_slot_bits(bits) != Some(bits) { + panic!( + "field-rep invariant: slot {slot} of {obj:p} (shape {:#x}, rep {rep:#x}) holds {bits:#018x}, not a canonical double", + object_shape_stamp(obj) + ); + } + } + field_rep::REP_SPECIAL if record.special_constfn_mask() & (1 << slot) != 0 => { + assert_constfn_slot_body(obj, record, slot, bits); + } + _ => {} // The reserved optional NoPointer producer remains disabled. } } } +/// Also used at the existing cold method-prime refusal: an unchecked store +/// must be diagnosed even if no collection follows before generic dispatch. +#[cfg(any( + debug_assertions, + feature = "field-rep-assert", + feature = "gc-instruments" +))] +#[cold] +#[inline(never)] +pub(crate) unsafe fn assert_constfn_slot_body( + obj: *const ObjectHeader, + record: super::shapes::ShapeRecordRef, + slot: usize, + bits: u64, +) { + let expected = record.constfn_info(slot as u32); + let actual = if bits & !crate::value::POINTER_MASK == crate::value::POINTER_TAG { + crate::gc::field_rep_live_address((bits & crate::value::POINTER_MASK) as usize) + .and_then(|addr| constfn_store_info(crate::value::POINTER_TAG | addr as u64)) + } else { + None + }; + if expected.is_none() || actual != expected { + panic!( + "field-rep invariant: SPECIAL ConstFn slot {slot} of {obj:p} (shape {:#x}) holds {bits:#018x}, body {actual:?} disagrees with shape body {expected:?}", + object_shape_stamp(obj) + ); + } +} + /// The shape a generalized lineage converges to from `id`: the same facts /// with every deprecated lane `Any`. The record the identity table answers /// with may itself have learned a deprecated lane since, so this follows the diff --git a/crates/perry-runtime/src/object/field_rep_store_tests.rs b/crates/perry-runtime/src/object/field_rep_store_tests.rs index 37436db353..d612a789f4 100644 --- a/crates/perry-runtime/src/object/field_rep_store_tests.rs +++ b/crates/perry-runtime/src/object/field_rep_store_tests.rs @@ -486,7 +486,7 @@ fn the_invariant_check_fires_on_a_non_number_under_an_f64_lane() { let f64_a = with_rep(id, with_slot_rep(REP_ANY, 0, REP_F64)); stamp_object_shape_id_with_carrier_note(obj, f64_a); let fields = (obj as *mut u8).add(std::mem::size_of::()) as *mut u64; - super::field_rep_store::assert_f64_lanes_hold_numbers( + super::field_rep_store::assert_field_rep_lanes( obj, super::shapes::object_shape_record(obj), 3, @@ -494,7 +494,7 @@ fn the_invariant_check_fires_on_a_non_number_under_an_f64_lane() { // GC_STORE_AUDIT(INIT): the deliberate unchecked store this test // exists to catch; nothing collects before the check below. *fields = boxed("not a number").to_bits(); - super::field_rep_store::assert_f64_lanes_hold_numbers( + super::field_rep_store::assert_field_rep_lanes( obj, super::shapes::object_shape_record(obj), 3, @@ -530,7 +530,7 @@ fn delete_publishes_any_lanes_before_its_raw_moves() { REP_ANY, "the delete successor carries no lane" ); - super::field_rep_store::assert_f64_lanes_hold_numbers( + super::field_rep_store::assert_field_rep_lanes( obj, super::shapes::object_shape_record(obj), 3, @@ -563,3 +563,76 @@ fn a_class_instance_key_add_earns_the_lane_too() { assert_eq!(slot_rep(inst_rep, 0), REP_F64); } } + +/// The diagnostic validates old carriers too: deprecation grants no permission +/// for an unchecked store to replace their recorded body. +#[test] +fn constfn_verifier_valid_stale_deprecated_and_lifecycle() { + let _lock = crate::gc::global_side_table_test_lock(); + let _no_move = crate::gc::GcSuppressScope::new(); + unsafe { + let a = crate::closure::js_closure_alloc( + crate::fn_info!(constfn_body_a, 0; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)), + 0, + ); + let b = crate::closure::js_closure_alloc( + crate::fn_info!(constfn_body_b, 0; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)), + 0, + ); + let obj = crate::object::js_object_alloc(0, 4); + let name = key("constfn_verifier_body"); + crate::object::js_object_set_field_by_name( + obj, + name, + f64::from_bits(crate::JSValue::object_ptr(a.cast()).bits()), + ); + let record = super::shapes::object_shape_record(obj).unwrap(); + assert_eq!( + record.special_constfn_mask(), + 1, + "fixture must carry SPECIAL" + ); + let verify = || super::field_rep_store::assert_field_rep_lanes(obj, Some(record), 1); + verify(); + assert!(record.deprecate_special_to_any(0)); + verify(); // An unchanged old carrier still satisfies its old body. + let fields = (obj as *mut u8).add(std::mem::size_of::()) as *mut u64; + for bits in [ + crate::JSValue::object_ptr(b.cast()).bits(), + crate::value::TAG_UNDEFINED, + ] { + // GC_STORE_AUDIT(INIT): deliberate sabotage, no collection before verification. + *fields = bits; + let failure = std::panic::catch_unwind(std::panic::AssertUnwindSafe(verify)); + let message = failure.expect_err("stale SPECIAL must fail"); + let text = message + .downcast_ref::() + .map(String::as_str) + .unwrap_or(""); + assert!( + text.contains("field-rep invariant: SPECIAL ConstFn"), + "{text}" + ); + } + // Restore before the checked store; its Any successor relinquishes the + // image-body fact, so revocation requires no metadata dereference. + *fields = crate::JSValue::object_ptr(a.cast()).bits(); + crate::object::js_object_set_field_by_name( + obj, + name, + f64::from_bits(crate::value::TAG_UNDEFINED), + ); + assert_eq!( + super::shapes::object_shape_record(obj) + .unwrap() + .special_constfn_mask(), + 0 + ); + (*a).info = std::ptr::null(); + super::field_rep_store::assert_field_rep_lanes( + obj, + super::shapes::object_shape_record(obj), + 1, + ); + } +} diff --git a/crates/perry-runtime/src/object/gc_slots.rs b/crates/perry-runtime/src/object/gc_slots.rs index 3acae75c2b..15372af550 100644 --- a/crates/perry-runtime/src/object/gc_slots.rs +++ b/crates/perry-runtime/src/object/gc_slots.rs @@ -66,7 +66,7 @@ pub(crate) unsafe fn gc_field_slot_range( feature = "gc-instruments" ))] if super::field_rep_store::field_rep_verify_enabled() { - super::field_rep_store::assert_f64_lanes_hold_numbers(obj, record, field_count); + super::field_rep_store::assert_field_rep_lanes(obj, record, field_count); } let fields = (obj as *mut u8).add(std::mem::size_of::()) as *mut u64; Some(crate::gc::HeapSlotRange::new(fields, field_count)) diff --git a/crates/perry-runtime/src/object/method_site.rs b/crates/perry-runtime/src/object/method_site.rs index 45e9d4f8fb..4fe6c17d08 100644 --- a/crates/perry-runtime/src/object/method_site.rs +++ b/crates/perry-runtime/src/object/method_site.rs @@ -645,6 +645,20 @@ unsafe fn prime(slot: *mut MethodSiteSlot, recv: f64, name: &[u8], argc: usize) if body != Some(info as *const crate::closure::JsFunctionInfo as u64) || slot_word & METHOD_SITE_SPILL != 0 { + #[cfg(any( + debug_assertions, + feature = "field-rep-assert", + feature = "gc-instruments" + ))] + if super::field_rep_store::field_rep_verify_enabled() { + if let Some(record) = + super::shapes::shape_record_by_id(super::shapes::object_shape_stamp(obj)) + { + super::field_rep_store::assert_constfn_slot_body( + obj, record, s as usize, value, + ); + } + } refuse(17); return; } From 76558cc18740316afbcf684a29d5406bcf2e60f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 2 Oct 2026 12:44:22 +0200 Subject: [PATCH 05/17] Audit verifier store and census snapshot pins --- crates/perry-runtime/src/object/field_rep_store_tests.rs | 1 + scripts/gc_runtime_root_holders.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/object/field_rep_store_tests.rs b/crates/perry-runtime/src/object/field_rep_store_tests.rs index d612a789f4..298257da05 100644 --- a/crates/perry-runtime/src/object/field_rep_store_tests.rs +++ b/crates/perry-runtime/src/object/field_rep_store_tests.rs @@ -616,6 +616,7 @@ fn constfn_verifier_valid_stale_deprecated_and_lifecycle() { } // Restore before the checked store; its Any successor relinquishes the // image-body fact, so revocation requires no metadata dereference. + // GC_STORE_AUDIT(INIT): restore the live body before leaving deliberate sabotage. *fields = crate::JSValue::object_ptr(a.cast()).bits(); crate::object::js_object_set_field_by_name( obj, diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 69c9065866..1aea4e8a30 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -360,7 +360,7 @@ "sources": { "crates/perry-runtime/src/gc/census.rs": "4a611bfe5615559642b0e6e1eaf0f25440c5e228db5302d67dba43e577e0a1b6", "crates/perry-runtime/src/gc/cycle.rs": "9b992d27700382c84758f6f4a5574115a9862234bed2e18e0c5a3cca25c25236", - "crates/perry-runtime/src/gc/mod.rs": "dd9761bc38694444bf4839c5829c328828a465548d08cae4b256002972a4bd1f", + "crates/perry-runtime/src/gc/mod.rs": "8c0c1ce5f553fe71563dbb05b957cdebfd4ebcf2183071b3b37332f10504f4e6", "crates/perry-runtime/src/gc/policy.rs": "84a869e0aa09e932a4d2b6601186260129ba851b4482f29579047713c60fc95e", "crates/perry-runtime/src/gc/progress.rs": "a5ad3971bbe4047229ca57325234780daa85921dbc778e1c08dff4ad07ccfb96" } From 52f2e6cba41b67db31c2718c716b94883d80f250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 2 Oct 2026 12:51:17 +0200 Subject: [PATCH 06/17] Document SPECIAL verifier and re-audit census window --- changelog.d/11680-one-shape-campaign.md | 6 ++++++ scripts/gc_runtime_root_holders.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/changelog.d/11680-one-shape-campaign.md b/changelog.d/11680-one-shape-campaign.md index 1b44c5fe52..f72adabeef 100644 --- a/changelog.d/11680-one-shape-campaign.md +++ b/changelog.d/11680-one-shape-campaign.md @@ -36,3 +36,9 @@ ordinary barrier before publishing its body-specific shape, using one existing cache probe. Missing intermediates, deprecated facts and unsupported targets retain the rooted slow publication path. Regressions cover distinct captures, actual moving collections, pointer-key fallback and publication ordering. + +Extend the existing field-representation verifier to check SPECIAL ConstFn +slots against their shape-owned body identity, including deprecated carriers. +Resolve validated forwarding before reading closure metadata during collection, +and diagnose stale body facts at the existing cold method-prime refusal. Add +valid, stale, deprecated, revoked and moving-collection regression coverage. diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 1aea4e8a30..b783333ef9 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -343,7 +343,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 → 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 — it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase — after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged — `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` — and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` → `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only — no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound — the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses — no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects — and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module — all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete → sweep-entry window of a synchronous full — where PASS1_MARKED is populated and consumed within one `run_to_completion` — is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize — INSIDE the window — the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-13 for #10182's full-collection throughput follow-up, which touched `gc/cycle.rs` in one hunk, INSIDE the window: the `RememberedSetRebuild` subphase of a synchronous full now first asks `verify::full_remembered_rebuild_provably_empty` and, when it holds, installs `OldToYoungRememberedRebuildState::provably_empty()` (an empty sticky set, no walk) instead of the require-marked rebuild. The predicate reads `arena_block_snapshots()` (one `Vec` through the global allocator), the census's per-block reached/pre-marked facts and the malloc registry's length; the constructor bumps a `Cell` counter and prints one line under `PERRY_GC_DIAG`. None of it allocates a GC object, relocates anything, collects, or runs a JS callback, and both census boundaries stay where they were. Re-audited 2026-09-14 for #10182's pacing-full work, which touched `gc/cycle.rs`, `gc/mod.rs` and `gc/policy.rs`. `gc/cycle.rs`: `GcCycleState::new_full` no longer calls `materialize_all_promoted_page_runs`; that call ran in the constructor, before the census and far before `census_pass1_if_armed`, and removing it adds nothing to the window. `gc/mod.rs`: one `mod promoted_cohort;` declaration. `gc/policy.rs`: (a) `credit_promoted_bytes_to_old_baseline` also credits a `Cell` cohort counter (it runs after a copying minor completes); (b) `finish_full_old_reclaim_baseline` also records the verified old live bytes and resets that counter (Publish, after `step_sweep` consumed the snapshot); (c) `gc_safepoint_moving_minor` arms and disarms the promotion-census record around its nursery minor and calls `run_promoted_cohort_full_if_due`, which starts a synchronous full through the same `gc_collect_full_mark_sweep_with_trigger` entry and reads byte counters before and after it. All of it runs before a cycle starts or after it completes; none of it runs between mark completion and sweep entry, allocates a GC object, relocates anything, or calls into JS. The census the promoted-cohort full may adopt from the promotion walk is built in `BuildValidPointerSet`, before either boundary. Both boundaries are unchanged. Re-audited 2026-09-14 for the #10182 dead-stack scrub in `gc/cycle.rs`: `step_build_valid_pointer_set` now calls `scrub_dead_stack_below`, which zeroes a local array in its own frame (dead stack below the caller), right after the census finishes — in `BuildValidPointerSet`, before the root scan and far before `census_pass1_if_armed`. It writes no heap memory, allocates nothing, relocates nothing and calls no JS; both boundaries are unchanged. Re-audited 2026-09-14 for #10241 (cohort survival), which touched `gc/cycle.rs` and `gc/policy.rs`. `gc/cycle.rs`: one call, `promoted_cohort::survival::check_minor_view_at_full_sweep_start()`, in `step_sweep` immediately AFTER `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS, i.e. outside the window. It is a no-op unless a promoted-cohort full armed its survival probe; when armed it walks the old page index over the preceding minor's dirty pages (`old_arena_walk_objects_on_pages`, Rust-allocator Vecs), reads GC headers' mark flags and the slots of unmarked ones, and records one enum. It writes no heap memory, allocates no GC object, relocates nothing and calls no JS. `gc/policy.rs`: `run_promoted_cohort_full_if_due` arms the probe before `gc_collect_full_mark_sweep_with_trigger` and takes it after the full returns (feeding `note_full_measured_promotion_survival` and one diagnostic line); both run before a cycle starts or after it completes. Both boundaries are unchanged. Re-audited 2026-09-14 for #10241's in-place-only cohort: `gc/policy.rs` drops the `promoted_cohort::note_promoted` call from `credit_promoted_bytes_to_old_baseline` (the copying minor now calls `promoted_cohort::note_minor_promotion` itself, after the credit). Both run at the end of a copying minor, outside any full cycle; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-14 for the parse-boundary side-allocation band (medium-parse pacing), which touched `gc/policy.rs`. Three hunks: (a) a `Cell` thread-local (`GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES`, a byte COUNT, no pointer) plus three pure predicates over it and `external_side_live_bytes()`; (b) that predicate added as a third disjunct of `tiny_parse_generational_collection_due`, which is read only from the three JSON.parse mutator-side boundaries (`gc_bump_malloc_trigger_inner`, `gc_collect_pending_suppressed_parse_slow`, `gc_schedule_parse_boundary_collection_if_pressure`), none of them reachable from `step_mark_propagation` or `step_sweep`; and (c) one extra `Cell` store in `note_collection_finished_arena_occupancy` plus two extra reads in the `PERRY_GC_DIAG` tiny-parse line. `note_collection_finished_arena_occupancy` runs from `publish_reclaim_outcome` in the Publish subphase, i.e. AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local, exactly as #9831's store on the same line does. Nothing added allocates a GC object, relocates anything, or runs a JS callback, and neither census boundary moved. Re-audited 2026-09-14 for the drained-bytes counterweight to that band, which touched `gc/policy.rs` again. Four hunks: a second `Cell` thread-local (`GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL`, a byte COUNT); one increment of it inside `gc_note_external_side_free`; a pure read (`external_side_old_reclaim_pressure_bytes`) substituted for `external_side_live_bytes()` at the four old-reclaim pressure sites; and one `Cell` store at the top of `finish_full_old_reclaim_baseline`. None of it can run between the census boundaries. `gc_note_external_side_free` is also reached by mutator-side tape materialization, regex scratch teardown, native-addon adjustments and buffer replacement. Its added operation is only a saturating increment of a scalar Cell, with no GC allocation, relocation, collection or JS callback, so this wider caller set does not invalidate the census window. `finish_full_old_reclaim_baseline` runs from `publish_reclaim_outcome` in the Publish subphase, the same place #9831's store already sits. The pressure reads happen at trigger decisions, before a cycle starts. No allocation, relocation, collection or JS callback is added to the mark-complete -> sweep-entry window, and neither boundary moved. Re-audited 2026-09-15 for turnloop P0, which touched `gc/mod.rs` with one added call: `crate::event_pump::shutdown_wait_driver()` inside `js_gc_release_current_thread_collection_side_allocations`, the process-exit funnel. That function runs once no more JavaScript can run on the thread, never from inside a collection cycle; the added call drops the thread's turnloop wait loop (closing its kqueue/epoll descriptor) and may print a diagnostic line. It allocates no GC object, relocates nothing, starts no collection and runs no JS callback. The census boundaries and the mark-complete -> sweep-entry window are untouched.. Re-audited 2026-09-16 for the copying minor's per-parent weak-holder fact: `gc/mod.rs` gains exactly one line, `mod copying_parent_facts;`, a module declaration. The module it declares holds `weak_holder_fact` (a read of the parent's `obj_type`/`class_id` via `weakref::is_weak_holder_header`) and the copying minor's `visit_slot_with_parent`, moved verbatim out of `gc/copying.rs` for the 2000-line lint. Both run only inside a COPYING MINOR, which skips both census boundaries (`census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` are synchronous-full only). Nothing was added to any full-cycle phase, and the declaration itself executes no code. Neither boundary moved and the synchronous mark-complete to sweep-entry window gains no allocation, relocation, collection or JS callback. Re-audited 2026-09-18 for the #10532 follow-up argument-list rooting fix, which touched `gc/mod.rs`. The only change there is `mod collection_points;` plus a `pub(crate) use collection_points::collection_point;` re-export (and, under `#[cfg(test)]`, `arm_collection_point`). `collection_point` is an inline no-op outside `cfg(test)`; under test it only runs a copying minor when called from ordinary MUTATOR code (`proxy.rs`'s `Reflect.apply` rebind path and `registry.rs`'s rest-array bundler), never from inside `step_mark_propagation` or `step_sweep`. Neither `census_pass1_if_armed` nor `census_take_if_armed_at_full_sweep_start` is reachable from it, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-18 (same PR, round 2) for the added `arm_collection_point_after` re-export in `gc/mod.rs`: another pure re-export line, same as the `collection_point`/`arm_collection_point` one already covered above. `arm_collection_point_after` only changes test-only arming state in `collection_points.rs` (which named site fires and on which hit); it still runs no mark/sweep control flow. Re-audited 2026-09-19 for #10735 (require.main threading): gc/mod.rs gains exactly one line, `reg_scanner!(crate::module_require::scan_cjs_main_module_root_mut);`, registering the new CJS_MAIN_MODULE thread-local's mutable-root scanner beside the existing `scan_module_path_roots_mut` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it runs during root scanning, before mark propagation completes, and does not execute between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start`. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-20 for #10834 (inherited-property read cache). `gc/mod.rs` gains exactly one line: `reg_scanner!(crate::object::inherited_read_cache::scan_inherited_read_cache_roots_mut);` in `gc_init()`. A scanner registration adds a root SOURCE for the mutable-root walks. The walk runs inside `RootScanCycleState::step_current_subphase`, i.e. entirely within the RootScan phase: `step_root_scan` only sets `self.phase = GcCyclePhase::MarkPropagation` once that loop reports done (`gc/cycle.rs:958-961`), and `census_pass1_if_armed()` fires at the END of `step_mark_propagation` (`gc/cycle.rs:982`). The scanner therefore runs strictly BEFORE the window opens and can never execute between the boundaries. Its body is a bounded walk of a fixed 512-entry thread-local array calling `visit_tagged_usize_slot` / `visit_usize_slot`; it allocates nothing, relocates nothing and runs no JS callback. Same shape as #9769, #9976/#9977, #10054, #10055 and #10735, all previously cleared. The PR also adds an `INHERITED_READ_CACHE` entry to `DEAD_KEY_PRUNES` in `gc/dead_owner.rs` (not a pinned source). That registry is consumed by `IncrementalSweepState::with_dead_collection_finalize` at `gc/cycle.rs:1548`, which is AFTER `census_take_if_armed_at_full_sweep_start` at `gc/cycle.rs:1505` has already `take()`n the snapshot out of the thread-local -- the same argument that cleared #9845's `collect_dead_registered_regexps_post_trace`. The prune reads addresses and zeroes entries; no GC allocation, relocation or callback. Both additions sit outside the window, on opposite sides of it. Neither boundary moved and the synchronous mark-complete to sweep-entry interval is unchanged. Re-audited 2026-09-22 for #10399 (per-thread module init), which touched `gc/mod.rs`. Two hunks, both init-time: a new free function `raise_default_thread_stack_floor()` and one call to it at the top of `js_gc_init`, before `enter_current_thread_image`'s successor statements. The function reads `RUST_MIN_STACK` from the environment and, only when it is unset, sets it to 32 MiB so a thread spawned against a multi-megabyte static TLS block still has usable stack (glibc carves static TLS out of the thread's stack mapping). It touches no heap object, allocates no GC object, relocates nothing and runs no JS callback. `js_gc_init` is the first runtime call of a compiled `main`, so it runs once before any cycle exists, and it is not reachable from `step_mark_propagation` or `step_sweep`. Same shape as the 2026-09-11 startup-memory-profile re-audit, which cleared the pre-main allocator-policy constructor in the same function. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-23 (size/runtime-decouple, #11135) after the binary-size branch touched `gc/census.rs`, `gc/mod.rs` and `gc/policy.rs`. census.rs: `census_pass1_if_armed` / `census_take_if_armed_at_full_sweep_start` keep their bodies verbatim, moved into `_impl` functions compiled only with the new `gc-instruments` feature (without it both are empty and `census_path()` is `None`, so nothing is ever armed); the take still empties PASS1_MARKED before `take_census`. gc/mod.rs: `gc_init` gains a startup env check that aborts when an instrument knob is set without the feature, before any cycle exists. gc/policy.rs: env-knob OnceLock caches now initialize through `crate::once_init::get_or_init` (same closures, same values). No mark/sweep control flow between the two census boundaries changed; the window is unchanged. Re-audited for Fetch handle reclamation: cycle.rs only redirects the incomplete-cycle Drop cancellation hook to also cancel the Fetch trace. The full-trace finish hook removes native records and cached slots without allocating GC objects or invoking JS; it cannot relocate the census addresses before sweep entry. Re-audited 2026-09-22 for #10928 (one proportional old-reclaim rule), which touched `gc/policy.rs`. Six hunks. (a) Two new thread-locals, `GC_OLD_RECLAIM_PRE_IN_USE_BYTES` (`Cell`) and `GC_OLD_RECLAIM_BACKOFF_SHIFT` (`Cell`): both are byte/shift COUNTS, neither holds a pointer. (b) `gc_old_reclaim_growth_band_bytes` gains a `Cell` read and a left shift -- pure arithmetic over byte counts. (c) `old_reclaim_pressure_due` loses the #7937 absolute first-crossing arm, splits its pure form out as `old_reclaim_pressure_due_inner`, and calls `note_old_reclaim_cycle_started()` when the answer is true. That predicate is read at TRIGGER decisions only -- the allocation-point `gc_check_trigger` and `gc_budgeted_due_trigger` at safepoints -- i.e. before a cycle starts, never between the boundaries; an allocation inside the window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before this code, the same argument the 2026-09-14 due-check fast-path re-audit made for the same function. Even if it did run there it would be sound: `note_old_reclaim_cycle_started` stores one scalar `Cell` from `pacing_arena_in_use_bytes()` (a read of `arena_live_allocated_bytes`), which allocates no GC object, relocates nothing and runs no JS callback -- the window's contract. (d) `update_old_reclaim_backoff` is called only from `finish_full_old_reclaim_baseline`, which runs from `publish_reclaim_outcome` in the Publish subphase, AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local -- exactly where #9831's store and the medium-parse pacing store already sit. (e) `gc_old_reclaim_debt_bytes` drops the absolute arm it mirrored; it remains pure arithmetic read at debt/trigger decisions. (f) `#[cfg(test)]` seams, absent from production builds. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback between them; the change alters only WHEN a collection is scheduled, never what runs inside one. Neither boundary moved and the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-24 for #10960 (growth-aware old-reclaim backoff), which touched `gc/policy.rs` again. One new thread-local, `GC_OLD_RECLAIM_LAST_POST_IN_USE_BYTES` (`Cell`), a byte COUNT that holds no pointer. It is written only by `update_old_reclaim_backoff`, which runs from `finish_full_old_reclaim_baseline` in the Publish subphase, after `step_sweep` has already taken the snapshot out of the thread-local; the change there is pure integer arithmetic deciding whether to widen the band. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback, and neither window boundary moved. Re-audited 2026-09-24 after the class-capture environment added one `reg_scanner!` registration (`scan_class_env_roots_mut`, visiting each guarded class environment's owner class object) to `gc/mod.rs`: a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-25 after the inherited-access lane touched `gc/mod.rs`: the change is one `reg_scanner!` registration for `object::chain_store::scan_chain_store_roots_mut`, a root scanner that visits store-site chain verdicts (one interned key pointer each) during root scanning. It runs at the start of a cycle, never between mark completion and sweep entry, relocates nothing and runs no JS callback. The synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-25 for #10698 (allocation-point trigger watermark), which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains `pub(crate) mod trigger_watermark;` and a `pub(crate) use` re-export -- no control flow. In `gc/policy.rs`: (a) `gc_check_trigger` becomes an out-of-line wrapper over an inlined fast path that reads the watermark cell, the malloc registry's length through an unguarded shared borrow and the inline allocator's offset, then either returns or runs the previous body unchanged (`gc_check_trigger_evaluate`). An allocation inside the window reaches it with `GC_FLAG_IN_ALLOC` set: the fast path returns without acting, which is the outcome of the `GC_FLAG_IN_ALLOC` early return it would otherwise reach, and the slow path still takes that early return. (b) The due-trigger evaluation also returns a watermark -- integer arithmetic over values it already read -- published only on the no-action return past every entry guard. (c) The ladder's thresholds and flags are retyped `Cell` -> `TriggerInput`, whose writes add one store retiring the watermark; heap-generation advances and `Arena::set_current` retire it too. (d) `#[cfg(test)]` seams. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback; `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-25 for #10498 (class-accessor cache), which adds one `reg_scanner!` registration (`scan_class_accessor_cache_roots_mut`, marking and rewriting the cache's key strings) to `gc/mod.rs`: again a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-26 for #10572: `gc/mod.rs` gains one `#[cfg(not(feature = \"hot-diag\"))]` call to `hot_diag::refuse_knobs_without_hot_diag()` in `gc_init`, a startup-only knob check that runs before any cycle and alters no mark/sweep control flow; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-27 for #11416: `gc/mod.rs` loses the `reg_scanner!` registration for `class_accessor_cache::scan_class_accessor_cache_roots_mut` (the cache is deleted); removing a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-27 for the method-calls lane, which adds one `reg_scanner!` registration (`scan_method_site_roots_mut`, marking and rewriting the method closures inherited method-site entries hold) to `gc/mod.rs`: a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-28 (charter step 5, P0): the field-representation census adds `mod census_field_repr;` to `gc/mod.rs` and, in `census.rs`, one Rust-owned accumulator fed from `visit_object` inside `take_census`, which runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED; it reads slots and the per-object layout maps (try_borrow), allocates nothing on the JS heap and alters no mark/sweep control flow. The window is unchanged. Re-audited 2026-09-27 for #11416: `gc/mod.rs` loses the `reg_scanner!` registration for `class_accessor_cache::scan_class_accessor_cache_roots_mut` (the cache is deleted); removing a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-27 for the method-calls lane, which adds one `reg_scanner!` registration (`scan_method_site_roots_mut`, marking and rewriting the method closures inherited method-site entries hold) to `gc/mod.rs`: a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-27 (class constructors as function objects, #11414): `gc/mod.rs` gains one `reg_scanner!` registration (`object::class_value::scan_class_value_roots_mut`, the per-agent class function-object table, visited and rewritten) — a root-scanner registration that alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-29 after #11659 added `verify::verify_array_hole_tails_at_collection()` to `gc/mod.rs` and `gc/policy.rs`. Every call sits at a collection or budgeted-cycle ENTRY, beside `roots::ensure_stack_maps_built()`, i.e. before mark begins and so before `census_pass1_if_armed` opens the window. The call is a read-only walk of array headers (debug builds, or release with PERRY_GC_VERIFY_ARRAY_HOLES): it neither allocates GC memory, relocates, runs JS, nor reads or writes PASS1_MARKED, and it panics rather than continuing on a violation. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Pin re-audited 2026-09-29 after PR #11646 added one call inside that window, in `cycle.rs` beside `census_take_if_armed_at_full_sweep_start`: `object::shapes::store_kind::audit_heap_at_full_sweep_start()`. It compiles to nothing unless the `shape-fact-audit` feature is on; when on it walks the arena read-only (`gc::for_each_live_object_at_sweep_start`, in `gc/verify.rs`) and reads each marked object header and its shape record. It allocates no GC object, moves nothing, runs no JS callback and holds no address past the walk, so PASS1_MARKED stays valid across it; a disagreement panics rather than continuing. Re-audited 2026-09-29 after the pinned-roots fix touched `gc/cycle.rs` and `gc/mod.rs`: the block-persistence live-block predicate now reads GC_FLAG_MARKED alone (pinned objects are marked as roots, so the set of live blocks is unchanged), and `gc/mod.rs` adds one `reg_scanner!` registration. Neither relocates, runs JS, or moves the mark-complete to sweep-entry window. Re-audited again 2026-09-29 (same fix, copying-minor follow-up): `BlockPersistCycleState`'s force-mark in `gc/cycle.rs` no longer skips an unmarked pinned header, so it marks and pushes it like any other object of a live block. That is marking inside the budgeted cycle's persistence step: nothing relocates, no JS runs, and the budgeted path still skips both census boundaries. The window is unchanged. Re-audited 2026-09-29 for this-as-a-parameter stage 3: `gc/mod.rs` registers `scan_dispatch_binding_roots_mut` in place of `scan_implicit_this_roots_mut` (the implicit-`this` cell is deleted; the same scanner body keeps `new.target`, the static-`this` override and the static private-owner stack) and rewords its comment; a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-28 for the JsFunctionInfo lane: `gc/census.rs`'s `side_tables()` loses its `closure_registry_census()` row (the closure-body registry is deleted); a census report row, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-29 for the read-site holder entry: `gc/mod.rs` adds one `reg_scanner!` registration, `read_holder::scan_read_holder_roots_mut`, which visits the holder and hop words of registered read-site caches during ROOT SCAN, before mark propagation completes. It rewrites root slots only through the visitor (as every registered scanner does), runs no JS, and nothing it does executes between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start`. The window is unchanged. Re-audited 2026-09-30 for Step 5 P4: census.rs only removes the typed-layout count, and gc/mod.rs removes shape-install registration/re-export and an init-time typed-intact verifier gate. The snapshot remains stored after mark propagation and taken at sweep entry; none of these edits relocates objects or invokes callbacks in that interval. Re-audited 2026-09-29 (#11549 trace cost): `gc/mod.rs` gains one `mod copying_object_scan;` declaration, the copying minor's plain-object slot walk. It is reached only from the copying drain, never from a full cycle, and changes no mark or sweep control flow. The window is unchanged. Re-audited 2026-09-30 after rebasing P4 over #11549/#11676: gc/mod.rs retains the copying_object_scan declaration and P4 removes typed-layout registration; both are module wiring outside the synchronous full mark-complete to sweep-entry window. The census boundaries and the window owner in cycle.rs are unchanged. Re-audited 2026-09-30 for the scope-context change: gc/mod.rs only removes the box root-scanner registration and exit-time box statistics. The former ran during root scan, before mark completion, and the latter runs at process exit. Neither changes the synchronous mark-complete to sweep-entry window. Re-audited for A2 inherited-read-cache deletion: gc/mod.rs removes only its old root-scanner registration and adjacent comments. The method-site and read-holder scanners still run during root scan before mark completion; no collection phase or callback was added inside the mark-complete to sweep-entry window. Re-audited 2026-09-30 for the setter-site root scanner: gc/mod.rs adds only its reg_scanner! registration in the root-scan setup. That scanner visits per-site key and holder slots before mark propagation completes; it runs no JS and adds no relocation or callback between census pass1 and sweep entry. The synchronous-full snapshot window and both boundaries remain 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 → 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 — it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase — after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged — `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` — and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` → `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only — no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound — the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses — no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects — and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module — all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete → sweep-entry window of a synchronous full — where PASS1_MARKED is populated and consumed within one `run_to_completion` — is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize — INSIDE the window — the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-13 for #10182's full-collection throughput follow-up, which touched `gc/cycle.rs` in one hunk, INSIDE the window: the `RememberedSetRebuild` subphase of a synchronous full now first asks `verify::full_remembered_rebuild_provably_empty` and, when it holds, installs `OldToYoungRememberedRebuildState::provably_empty()` (an empty sticky set, no walk) instead of the require-marked rebuild. The predicate reads `arena_block_snapshots()` (one `Vec` through the global allocator), the census's per-block reached/pre-marked facts and the malloc registry's length; the constructor bumps a `Cell` counter and prints one line under `PERRY_GC_DIAG`. None of it allocates a GC object, relocates anything, collects, or runs a JS callback, and both census boundaries stay where they were. Re-audited 2026-09-14 for #10182's pacing-full work, which touched `gc/cycle.rs`, `gc/mod.rs` and `gc/policy.rs`. `gc/cycle.rs`: `GcCycleState::new_full` no longer calls `materialize_all_promoted_page_runs`; that call ran in the constructor, before the census and far before `census_pass1_if_armed`, and removing it adds nothing to the window. `gc/mod.rs`: one `mod promoted_cohort;` declaration. `gc/policy.rs`: (a) `credit_promoted_bytes_to_old_baseline` also credits a `Cell` cohort counter (it runs after a copying minor completes); (b) `finish_full_old_reclaim_baseline` also records the verified old live bytes and resets that counter (Publish, after `step_sweep` consumed the snapshot); (c) `gc_safepoint_moving_minor` arms and disarms the promotion-census record around its nursery minor and calls `run_promoted_cohort_full_if_due`, which starts a synchronous full through the same `gc_collect_full_mark_sweep_with_trigger` entry and reads byte counters before and after it. All of it runs before a cycle starts or after it completes; none of it runs between mark completion and sweep entry, allocates a GC object, relocates anything, or calls into JS. The census the promoted-cohort full may adopt from the promotion walk is built in `BuildValidPointerSet`, before either boundary. Both boundaries are unchanged. Re-audited 2026-09-14 for the #10182 dead-stack scrub in `gc/cycle.rs`: `step_build_valid_pointer_set` now calls `scrub_dead_stack_below`, which zeroes a local array in its own frame (dead stack below the caller), right after the census finishes — in `BuildValidPointerSet`, before the root scan and far before `census_pass1_if_armed`. It writes no heap memory, allocates nothing, relocates nothing and calls no JS; both boundaries are unchanged. Re-audited 2026-09-14 for #10241 (cohort survival), which touched `gc/cycle.rs` and `gc/policy.rs`. `gc/cycle.rs`: one call, `promoted_cohort::survival::check_minor_view_at_full_sweep_start()`, in `step_sweep` immediately AFTER `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS, i.e. outside the window. It is a no-op unless a promoted-cohort full armed its survival probe; when armed it walks the old page index over the preceding minor's dirty pages (`old_arena_walk_objects_on_pages`, Rust-allocator Vecs), reads GC headers' mark flags and the slots of unmarked ones, and records one enum. It writes no heap memory, allocates no GC object, relocates nothing and calls no JS. `gc/policy.rs`: `run_promoted_cohort_full_if_due` arms the probe before `gc_collect_full_mark_sweep_with_trigger` and takes it after the full returns (feeding `note_full_measured_promotion_survival` and one diagnostic line); both run before a cycle starts or after it completes. Both boundaries are unchanged. Re-audited 2026-09-14 for #10241's in-place-only cohort: `gc/policy.rs` drops the `promoted_cohort::note_promoted` call from `credit_promoted_bytes_to_old_baseline` (the copying minor now calls `promoted_cohort::note_minor_promotion` itself, after the credit). Both run at the end of a copying minor, outside any full cycle; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-14 for the parse-boundary side-allocation band (medium-parse pacing), which touched `gc/policy.rs`. Three hunks: (a) a `Cell` thread-local (`GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES`, a byte COUNT, no pointer) plus three pure predicates over it and `external_side_live_bytes()`; (b) that predicate added as a third disjunct of `tiny_parse_generational_collection_due`, which is read only from the three JSON.parse mutator-side boundaries (`gc_bump_malloc_trigger_inner`, `gc_collect_pending_suppressed_parse_slow`, `gc_schedule_parse_boundary_collection_if_pressure`), none of them reachable from `step_mark_propagation` or `step_sweep`; and (c) one extra `Cell` store in `note_collection_finished_arena_occupancy` plus two extra reads in the `PERRY_GC_DIAG` tiny-parse line. `note_collection_finished_arena_occupancy` runs from `publish_reclaim_outcome` in the Publish subphase, i.e. AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local, exactly as #9831's store on the same line does. Nothing added allocates a GC object, relocates anything, or runs a JS callback, and neither census boundary moved. Re-audited 2026-09-14 for the drained-bytes counterweight to that band, which touched `gc/policy.rs` again. Four hunks: a second `Cell` thread-local (`GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL`, a byte COUNT); one increment of it inside `gc_note_external_side_free`; a pure read (`external_side_old_reclaim_pressure_bytes`) substituted for `external_side_live_bytes()` at the four old-reclaim pressure sites; and one `Cell` store at the top of `finish_full_old_reclaim_baseline`. None of it can run between the census boundaries. `gc_note_external_side_free` is also reached by mutator-side tape materialization, regex scratch teardown, native-addon adjustments and buffer replacement. Its added operation is only a saturating increment of a scalar Cell, with no GC allocation, relocation, collection or JS callback, so this wider caller set does not invalidate the census window. `finish_full_old_reclaim_baseline` runs from `publish_reclaim_outcome` in the Publish subphase, the same place #9831's store already sits. The pressure reads happen at trigger decisions, before a cycle starts. No allocation, relocation, collection or JS callback is added to the mark-complete -> sweep-entry window, and neither boundary moved. Re-audited 2026-09-15 for turnloop P0, which touched `gc/mod.rs` with one added call: `crate::event_pump::shutdown_wait_driver()` inside `js_gc_release_current_thread_collection_side_allocations`, the process-exit funnel. That function runs once no more JavaScript can run on the thread, never from inside a collection cycle; the added call drops the thread's turnloop wait loop (closing its kqueue/epoll descriptor) and may print a diagnostic line. It allocates no GC object, relocates nothing, starts no collection and runs no JS callback. The census boundaries and the mark-complete -> sweep-entry window are untouched.. Re-audited 2026-09-16 for the copying minor's per-parent weak-holder fact: `gc/mod.rs` gains exactly one line, `mod copying_parent_facts;`, a module declaration. The module it declares holds `weak_holder_fact` (a read of the parent's `obj_type`/`class_id` via `weakref::is_weak_holder_header`) and the copying minor's `visit_slot_with_parent`, moved verbatim out of `gc/copying.rs` for the 2000-line lint. Both run only inside a COPYING MINOR, which skips both census boundaries (`census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` are synchronous-full only). Nothing was added to any full-cycle phase, and the declaration itself executes no code. Neither boundary moved and the synchronous mark-complete to sweep-entry window gains no allocation, relocation, collection or JS callback. Re-audited 2026-09-18 for the #10532 follow-up argument-list rooting fix, which touched `gc/mod.rs`. The only change there is `mod collection_points;` plus a `pub(crate) use collection_points::collection_point;` re-export (and, under `#[cfg(test)]`, `arm_collection_point`). `collection_point` is an inline no-op outside `cfg(test)`; under test it only runs a copying minor when called from ordinary MUTATOR code (`proxy.rs`'s `Reflect.apply` rebind path and `registry.rs`'s rest-array bundler), never from inside `step_mark_propagation` or `step_sweep`. Neither `census_pass1_if_armed` nor `census_take_if_armed_at_full_sweep_start` is reachable from it, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-18 (same PR, round 2) for the added `arm_collection_point_after` re-export in `gc/mod.rs`: another pure re-export line, same as the `collection_point`/`arm_collection_point` one already covered above. `arm_collection_point_after` only changes test-only arming state in `collection_points.rs` (which named site fires and on which hit); it still runs no mark/sweep control flow. Re-audited 2026-09-19 for #10735 (require.main threading): gc/mod.rs gains exactly one line, `reg_scanner!(crate::module_require::scan_cjs_main_module_root_mut);`, registering the new CJS_MAIN_MODULE thread-local's mutable-root scanner beside the existing `scan_module_path_roots_mut` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it runs during root scanning, before mark propagation completes, and does not execute between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start`. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-20 for #10834 (inherited-property read cache). `gc/mod.rs` gains exactly one line: `reg_scanner!(crate::object::inherited_read_cache::scan_inherited_read_cache_roots_mut);` in `gc_init()`. A scanner registration adds a root SOURCE for the mutable-root walks. The walk runs inside `RootScanCycleState::step_current_subphase`, i.e. entirely within the RootScan phase: `step_root_scan` only sets `self.phase = GcCyclePhase::MarkPropagation` once that loop reports done (`gc/cycle.rs:958-961`), and `census_pass1_if_armed()` fires at the END of `step_mark_propagation` (`gc/cycle.rs:982`). The scanner therefore runs strictly BEFORE the window opens and can never execute between the boundaries. Its body is a bounded walk of a fixed 512-entry thread-local array calling `visit_tagged_usize_slot` / `visit_usize_slot`; it allocates nothing, relocates nothing and runs no JS callback. Same shape as #9769, #9976/#9977, #10054, #10055 and #10735, all previously cleared. The PR also adds an `INHERITED_READ_CACHE` entry to `DEAD_KEY_PRUNES` in `gc/dead_owner.rs` (not a pinned source). That registry is consumed by `IncrementalSweepState::with_dead_collection_finalize` at `gc/cycle.rs:1548`, which is AFTER `census_take_if_armed_at_full_sweep_start` at `gc/cycle.rs:1505` has already `take()`n the snapshot out of the thread-local -- the same argument that cleared #9845's `collect_dead_registered_regexps_post_trace`. The prune reads addresses and zeroes entries; no GC allocation, relocation or callback. Both additions sit outside the window, on opposite sides of it. Neither boundary moved and the synchronous mark-complete to sweep-entry interval is unchanged. Re-audited 2026-09-22 for #10399 (per-thread module init), which touched `gc/mod.rs`. Two hunks, both init-time: a new free function `raise_default_thread_stack_floor()` and one call to it at the top of `js_gc_init`, before `enter_current_thread_image`'s successor statements. The function reads `RUST_MIN_STACK` from the environment and, only when it is unset, sets it to 32 MiB so a thread spawned against a multi-megabyte static TLS block still has usable stack (glibc carves static TLS out of the thread's stack mapping). It touches no heap object, allocates no GC object, relocates nothing and runs no JS callback. `js_gc_init` is the first runtime call of a compiled `main`, so it runs once before any cycle exists, and it is not reachable from `step_mark_propagation` or `step_sweep`. Same shape as the 2026-09-11 startup-memory-profile re-audit, which cleared the pre-main allocator-policy constructor in the same function. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-23 (size/runtime-decouple, #11135) after the binary-size branch touched `gc/census.rs`, `gc/mod.rs` and `gc/policy.rs`. census.rs: `census_pass1_if_armed` / `census_take_if_armed_at_full_sweep_start` keep their bodies verbatim, moved into `_impl` functions compiled only with the new `gc-instruments` feature (without it both are empty and `census_path()` is `None`, so nothing is ever armed); the take still empties PASS1_MARKED before `take_census`. gc/mod.rs: `gc_init` gains a startup env check that aborts when an instrument knob is set without the feature, before any cycle exists. gc/policy.rs: env-knob OnceLock caches now initialize through `crate::once_init::get_or_init` (same closures, same values). No mark/sweep control flow between the two census boundaries changed; the window is unchanged. Re-audited for Fetch handle reclamation: cycle.rs only redirects the incomplete-cycle Drop cancellation hook to also cancel the Fetch trace. The full-trace finish hook removes native records and cached slots without allocating GC objects or invoking JS; it cannot relocate the census addresses before sweep entry. Re-audited 2026-09-22 for #10928 (one proportional old-reclaim rule), which touched `gc/policy.rs`. Six hunks. (a) Two new thread-locals, `GC_OLD_RECLAIM_PRE_IN_USE_BYTES` (`Cell`) and `GC_OLD_RECLAIM_BACKOFF_SHIFT` (`Cell`): both are byte/shift COUNTS, neither holds a pointer. (b) `gc_old_reclaim_growth_band_bytes` gains a `Cell` read and a left shift -- pure arithmetic over byte counts. (c) `old_reclaim_pressure_due` loses the #7937 absolute first-crossing arm, splits its pure form out as `old_reclaim_pressure_due_inner`, and calls `note_old_reclaim_cycle_started()` when the answer is true. That predicate is read at TRIGGER decisions only -- the allocation-point `gc_check_trigger` and `gc_budgeted_due_trigger` at safepoints -- i.e. before a cycle starts, never between the boundaries; an allocation inside the window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before this code, the same argument the 2026-09-14 due-check fast-path re-audit made for the same function. Even if it did run there it would be sound: `note_old_reclaim_cycle_started` stores one scalar `Cell` from `pacing_arena_in_use_bytes()` (a read of `arena_live_allocated_bytes`), which allocates no GC object, relocates nothing and runs no JS callback -- the window's contract. (d) `update_old_reclaim_backoff` is called only from `finish_full_old_reclaim_baseline`, which runs from `publish_reclaim_outcome` in the Publish subphase, AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local -- exactly where #9831's store and the medium-parse pacing store already sit. (e) `gc_old_reclaim_debt_bytes` drops the absolute arm it mirrored; it remains pure arithmetic read at debt/trigger decisions. (f) `#[cfg(test)]` seams, absent from production builds. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback between them; the change alters only WHEN a collection is scheduled, never what runs inside one. Neither boundary moved and the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-24 for #10960 (growth-aware old-reclaim backoff), which touched `gc/policy.rs` again. One new thread-local, `GC_OLD_RECLAIM_LAST_POST_IN_USE_BYTES` (`Cell`), a byte COUNT that holds no pointer. It is written only by `update_old_reclaim_backoff`, which runs from `finish_full_old_reclaim_baseline` in the Publish subphase, after `step_sweep` has already taken the snapshot out of the thread-local; the change there is pure integer arithmetic deciding whether to widen the band. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback, and neither window boundary moved. Re-audited 2026-09-24 after the class-capture environment added one `reg_scanner!` registration (`scan_class_env_roots_mut`, visiting each guarded class environment's owner class object) to `gc/mod.rs`: a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-25 after the inherited-access lane touched `gc/mod.rs`: the change is one `reg_scanner!` registration for `object::chain_store::scan_chain_store_roots_mut`, a root scanner that visits store-site chain verdicts (one interned key pointer each) during root scanning. It runs at the start of a cycle, never between mark completion and sweep entry, relocates nothing and runs no JS callback. The synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-25 for #10698 (allocation-point trigger watermark), which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains `pub(crate) mod trigger_watermark;` and a `pub(crate) use` re-export -- no control flow. In `gc/policy.rs`: (a) `gc_check_trigger` becomes an out-of-line wrapper over an inlined fast path that reads the watermark cell, the malloc registry's length through an unguarded shared borrow and the inline allocator's offset, then either returns or runs the previous body unchanged (`gc_check_trigger_evaluate`). An allocation inside the window reaches it with `GC_FLAG_IN_ALLOC` set: the fast path returns without acting, which is the outcome of the `GC_FLAG_IN_ALLOC` early return it would otherwise reach, and the slow path still takes that early return. (b) The due-trigger evaluation also returns a watermark -- integer arithmetic over values it already read -- published only on the no-action return past every entry guard. (c) The ladder's thresholds and flags are retyped `Cell` -> `TriggerInput`, whose writes add one store retiring the watermark; heap-generation advances and `Arena::set_current` retire it too. (d) `#[cfg(test)]` seams. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback; `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-25 for #10498 (class-accessor cache), which adds one `reg_scanner!` registration (`scan_class_accessor_cache_roots_mut`, marking and rewriting the cache's key strings) to `gc/mod.rs`: again a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-26 for #10572: `gc/mod.rs` gains one `#[cfg(not(feature = \"hot-diag\"))]` call to `hot_diag::refuse_knobs_without_hot_diag()` in `gc_init`, a startup-only knob check that runs before any cycle and alters no mark/sweep control flow; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-27 for #11416: `gc/mod.rs` loses the `reg_scanner!` registration for `class_accessor_cache::scan_class_accessor_cache_roots_mut` (the cache is deleted); removing a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-27 for the method-calls lane, which adds one `reg_scanner!` registration (`scan_method_site_roots_mut`, marking and rewriting the method closures inherited method-site entries hold) to `gc/mod.rs`: a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-28 (charter step 5, P0): the field-representation census adds `mod census_field_repr;` to `gc/mod.rs` and, in `census.rs`, one Rust-owned accumulator fed from `visit_object` inside `take_census`, which runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED; it reads slots and the per-object layout maps (try_borrow), allocates nothing on the JS heap and alters no mark/sweep control flow. The window is unchanged. Re-audited 2026-09-27 for #11416: `gc/mod.rs` loses the `reg_scanner!` registration for `class_accessor_cache::scan_class_accessor_cache_roots_mut` (the cache is deleted); removing a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-27 for the method-calls lane, which adds one `reg_scanner!` registration (`scan_method_site_roots_mut`, marking and rewriting the method closures inherited method-site entries hold) to `gc/mod.rs`: a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-27 (class constructors as function objects, #11414): `gc/mod.rs` gains one `reg_scanner!` registration (`object::class_value::scan_class_value_roots_mut`, the per-agent class function-object table, visited and rewritten) — a root-scanner registration that alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-29 after #11659 added `verify::verify_array_hole_tails_at_collection()` to `gc/mod.rs` and `gc/policy.rs`. Every call sits at a collection or budgeted-cycle ENTRY, beside `roots::ensure_stack_maps_built()`, i.e. before mark begins and so before `census_pass1_if_armed` opens the window. The call is a read-only walk of array headers (debug builds, or release with PERRY_GC_VERIFY_ARRAY_HOLES): it neither allocates GC memory, relocates, runs JS, nor reads or writes PASS1_MARKED, and it panics rather than continuing on a violation. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Pin re-audited 2026-09-29 after PR #11646 added one call inside that window, in `cycle.rs` beside `census_take_if_armed_at_full_sweep_start`: `object::shapes::store_kind::audit_heap_at_full_sweep_start()`. It compiles to nothing unless the `shape-fact-audit` feature is on; when on it walks the arena read-only (`gc::for_each_live_object_at_sweep_start`, in `gc/verify.rs`) and reads each marked object header and its shape record. It allocates no GC object, moves nothing, runs no JS callback and holds no address past the walk, so PASS1_MARKED stays valid across it; a disagreement panics rather than continuing. Re-audited 2026-09-29 after the pinned-roots fix touched `gc/cycle.rs` and `gc/mod.rs`: the block-persistence live-block predicate now reads GC_FLAG_MARKED alone (pinned objects are marked as roots, so the set of live blocks is unchanged), and `gc/mod.rs` adds one `reg_scanner!` registration. Neither relocates, runs JS, or moves the mark-complete to sweep-entry window. Re-audited again 2026-09-29 (same fix, copying-minor follow-up): `BlockPersistCycleState`'s force-mark in `gc/cycle.rs` no longer skips an unmarked pinned header, so it marks and pushes it like any other object of a live block. That is marking inside the budgeted cycle's persistence step: nothing relocates, no JS runs, and the budgeted path still skips both census boundaries. The window is unchanged. Re-audited 2026-09-29 for this-as-a-parameter stage 3: `gc/mod.rs` registers `scan_dispatch_binding_roots_mut` in place of `scan_implicit_this_roots_mut` (the implicit-`this` cell is deleted; the same scanner body keeps `new.target`, the static-`this` override and the static private-owner stack) and rewords its comment; a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-28 for the JsFunctionInfo lane: `gc/census.rs`'s `side_tables()` loses its `closure_registry_census()` row (the closure-body registry is deleted); a census report row, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-29 for the read-site holder entry: `gc/mod.rs` adds one `reg_scanner!` registration, `read_holder::scan_read_holder_roots_mut`, which visits the holder and hop words of registered read-site caches during ROOT SCAN, before mark propagation completes. It rewrites root slots only through the visitor (as every registered scanner does), runs no JS, and nothing it does executes between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start`. The window is unchanged. Re-audited 2026-09-30 for Step 5 P4: census.rs only removes the typed-layout count, and gc/mod.rs removes shape-install registration/re-export and an init-time typed-intact verifier gate. The snapshot remains stored after mark propagation and taken at sweep entry; none of these edits relocates objects or invokes callbacks in that interval. Re-audited 2026-09-29 (#11549 trace cost): `gc/mod.rs` gains one `mod copying_object_scan;` declaration, the copying minor's plain-object slot walk. It is reached only from the copying drain, never from a full cycle, and changes no mark or sweep control flow. The window is unchanged. Re-audited 2026-09-30 after rebasing P4 over #11549/#11676: gc/mod.rs retains the copying_object_scan declaration and P4 removes typed-layout registration; both are module wiring outside the synchronous full mark-complete to sweep-entry window. The census boundaries and the window owner in cycle.rs are unchanged. Re-audited 2026-09-30 for the scope-context change: gc/mod.rs only removes the box root-scanner registration and exit-time box statistics. The former ran during root scan, before mark completion, and the latter runs at process exit. Neither changes the synchronous mark-complete to sweep-entry window. Re-audited for A2 inherited-read-cache deletion: gc/mod.rs removes only its old root-scanner registration and adjacent comments. The method-site and read-holder scanners still run during root scan before mark completion; no collection phase or callback was added inside the mark-complete to sweep-entry window. Re-audited 2026-09-30 for the setter-site root scanner: gc/mod.rs adds only its reg_scanner! registration in the root-scan setup. That scanner visits per-site key and holder slots before mark propagation completes; it runs no JS and adds no relocation or callback between census pass1 and sweep entry. The synchronous-full snapshot window and both boundaries remain unchanged. Re-audited 2026-10-02 for the SPECIAL ConstFn verifier: gc/mod.rs adds only a feature-gated forwarding-helper re-export. The helper follows existing validated forwarding and runs no JS, allocation or collection. No holder, scanner registration or mark/sweep control flow is added; both census boundaries and their synchronous window remain unchanged.", "window": { "start": { "file": "crates/perry-runtime/src/gc/census.rs", From 86888296fd8b0d9b66aff7b1d714dc963af9c7b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 2 Oct 2026 13:37:32 +0200 Subject: [PATCH 07/17] Honor fixture GC witness environments in loop-poll matrix arm --- .github/workflows/test.yml | 1 + .../11680-gc-loop-witness-environments.md | 7 + scripts/gc_matrix_fixture_env.py | 124 ++++++++++++++++ scripts/gc_matrix_fixture_env_test.py | 133 ++++++++++++++++++ scripts/gc_repsel_matrix.sh | 89 ++++++++++-- 5 files changed, 344 insertions(+), 10 deletions(-) create mode 100644 changelog.d/11680-gc-loop-witness-environments.md create mode 100644 scripts/gc_matrix_fixture_env.py create mode 100644 scripts/gc_matrix_fixture_env_test.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 768273ca66..c02510aebf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -928,6 +928,7 @@ jobs: if: ${{ !cancelled() }} run: | ./scripts/gc_repsel_matrix.sh --self-test-liveness-parser + ./scripts/gc_repsel_matrix.sh --self-test-fixture-env python3 scripts/gc_repsel_matrix_merge.py --self-test python3 scripts/gc_matrix_liveness_check.py --self-test python3 scripts/gc_matrix_liveness_check.py --check-registry diff --git a/changelog.d/11680-gc-loop-witness-environments.md b/changelog.d/11680-gc-loop-witness-environments.md new file mode 100644 index 0000000000..c92d9d75a7 --- /dev/null +++ b/changelog.d/11680-gc-loop-witness-environments.md @@ -0,0 +1,7 @@ +The GC representation matrix now honors each fixture's existing `parity-env` +settings in the explicit `loop_polls` arm at both compilation and execution. +Seeded and protected witnesses therefore select the instrumented runtime through +the normal auto-optimize path. Fixture metadata is restricted to validated GC +settings; all other arms keep their original environments. Reports and progress +logs record each cell's compile and run assignments. A CI routing self-test +checks control isolation and rejects planted compile/run dispatch defects. diff --git a/scripts/gc_matrix_fixture_env.py b/scripts/gc_matrix_fixture_env.py new file mode 100644 index 0000000000..d6d8114223 --- /dev/null +++ b/scripts/gc_matrix_fixture_env.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Read the loop_polls fixture's GC parity-env without interpreting shell code. + +The parity harness uses whitespace-separated KEY=VALUE assignments. Restrict +the matrix extension to GC witness settings: metadata cannot change an OFF +arm, compiler selection, library providers, pressure, or the shipped control. +""" + +from __future__ import annotations + +import argparse +from decimal import Decimal +from pathlib import Path +import re +import sys + + +DIRECTIVE = re.compile(r"^\s*//\s*parity-env:\s*(.*)$", re.MULTILINE) +ON_KEYS = { + "PERRY_GC_MOVING_LOOP_POLLS", + "PERRY_GC_FORCE_EVACUATE", + "PERRY_GC_VERIFY_EVACUATION", +} +UINT_KEYS = { + "PERRY_GC_SCHEDULE_SEED", + "PERRY_GC_SCHEDULE_ALLOC_KB", + "PERRY_GC_PROTECT_FROMSPACE_DEPTH", +} + + +def parse(text: str) -> str: + lines = DIRECTIVE.findall(text) + if not lines: + return "" + if len(lines) != 1 or not lines[0].strip(): + raise ValueError("require exactly one nonempty parity-env directive") + values: dict[str, str] = {} + for assignment in lines[0].split(): + key, sep, value = assignment.partition("=") + if not sep or key in values: + raise ValueError(f"invalid or duplicate assignment {assignment!r}") + if key in ON_KEYS: + valid = value == "1" + elif key in UINT_KEYS: + valid = bool(re.fullmatch(r"[0-9]{1,20}", value)) and int(value) <= 2**64 - 1 + if key == "PERRY_GC_PROTECT_FROMSPACE_DEPTH": + valid = valid and int(value) > 0 + elif key == "PERRY_GC_SCHEDULE_RATE": + valid = bool(re.fullmatch(r"(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)", value)) + valid = valid and Decimal(0) <= Decimal(value) <= Decimal(1) + elif key == "PERRY_GC_PROTECT_FROMSPACE": + valid = value in ("1", "poison") + else: + raise ValueError(f"unsupported GC witness setting {key!r}") + if not valid: + raise ValueError(f"invalid GC witness setting {assignment!r}") + values[key] = value + if any(key in values for key in ("PERRY_GC_SCHEDULE_RATE", "PERRY_GC_SCHEDULE_ALLOC_KB")): + if "PERRY_GC_SCHEDULE_SEED" not in values: + raise ValueError("schedule rate/allocation gating requires a seed") + if "PERRY_GC_PROTECT_FROMSPACE_DEPTH" in values and "PERRY_GC_PROTECT_FROMSPACE" not in values: + raise ValueError("from-space depth requires protection") + return " ".join(f"{key}={value}" for key, value in values.items()) + + +def fixture_env(arm: str, text: str) -> str: + # This early return is deliberate: even malformed metadata must not alter + # compilation or execution of a shipped/default/OFF control. + return parse(text) if arm == "loop_polls" else "" + + +def self_test() -> None: + witness = "// parity-env: PERRY_GC_SCHEDULE_SEED=10061 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_SCHEDULE_ALLOC_KB=0 PERRY_GC_PROTECT_FROMSPACE=1\n" + assert fixture_env("loop_polls", witness) == witness.split(": ", 1)[1].strip() + for arm in ("shipped_default", "default", "safepoint_minor", "gen_gc_off", "wb_off", "rep_ptr_shape_off"): + assert fixture_env(arm, witness) == "" + assert fixture_env(arm, "// parity-env: PATH=/tmp/evil") == "" + assert parse("// no metadata\n") == "" + assert parse(" // parity-env: PERRY_GC_MOVING_LOOP_POLLS=1\n") == "PERRY_GC_MOVING_LOOP_POLLS=1" + invalid = ( + "PATH=/tmp/evil", "PERRY_NO_AUTO_OPTIMIZE=1", "PERRY_RUNTIME_DIR=/tmp/foreign", + "PERRY_GC_MOVING_LOOP_POLLS=0", "PERRY_GC_SCHEDULE_SEED=$(touch /tmp/evil)", + "PERRY_GC_SCHEDULE_SEED=1;echo", "PERRY_GC_SCHEDULE_SEED=`id`", + "PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_SEED=2", + "PERRY_GC_SCHEDULE_SEED=18446744073709551616", "PERRY_GC_SCHEDULE_SEED=-1", + "PERRY_GC_SCHEDULE_RATE=1", "PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=NaN", + "PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1.01", "PERRY_GC_PROTECT_FROMSPACE_DEPTH=0", + "PERRY_GC_PROTECT_FROMSPACE_DEPTH=4", + "", "PERRY_GC_FORCE_EVACUATE=1\n// parity-env: PERRY_GC_VERIFY_EVACUATION=1", + ) + for settings in invalid: + try: + parse("// parity-env: " + settings) + except ValueError: + pass + else: + raise AssertionError(f"accepted unsafe/malformed metadata: {settings!r}") + print("GC matrix fixture env self-test: PASS (syntax, injection rejection, OFF/control isolation)") + from gc_matrix_fixture_env_test import self_test as routing_self_test + + routing_self_test() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("fixture", type=Path, nargs="?") + parser.add_argument("--arm", default="loop_polls") + parser.add_argument("--self-test", action="store_true") + args = parser.parse_args() + if args.self_test: + self_test() + return 0 + if args.fixture is None: + parser.error("fixture is required") + try: + print(fixture_env(args.arm, args.fixture.read_text(encoding="utf-8"))) + except (OSError, ValueError) as exc: + print(f"{args.fixture}: {exc}", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/gc_matrix_fixture_env_test.py b/scripts/gc_matrix_fixture_env_test.py new file mode 100644 index 0000000000..2a5542a2a2 --- /dev/null +++ b/scripts/gc_matrix_fixture_env_test.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Exercise matrix env dispatch with command probes, never GC acceptance. + +Copies the original suffix fixture verbatim. The probes do not compile or run +TypeScript and emit no GC counters: moving cells must stay UNVER. Receipts +check the environment actually delivered to each compile/run command. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile + + +ROOT = Path(__file__).resolve().parent.parent +TEST = "test_gap_gc_string_suffix_cursor" +ARMS = "loop_polls,safepoint_minor,shipped_default,wb_off" + + +def assignments(text: str) -> dict[str, str]: + return dict(word.split("=", 1) for word in text.split()) + + +def run_probe(root: Path, matrix: str, metadata: str | None = None) -> subprocess.CompletedProcess[str]: + (root / "scripts/gc_repsel_matrix.sh").write_text(matrix) + if metadata is not None: + (root / f"test-files/{TEST}.ts").write_text(metadata) + env = {key: value for key, value in os.environ.items() if not key.startswith("PERRY_")} + env.update(PATH=str(root / "probes") + os.pathsep + env["PATH"], + ROUTING_RECEIPTS=str(root / "receipts.jsonl")) + receipts = root / "receipts.jsonl" + receipts.unlink(missing_ok=True) + return subprocess.run( + ["bash", str(root / "scripts/gc_repsel_matrix.sh"), "--no-build", "--arms", ARMS, + "--jobs", "1", "--defer-liveness", "--json", str(root / "report.json")], + env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=30, + ) + + +def check_receipts(root: Path, result: subprocess.CompletedProcess[str]) -> None: + assert result.returncode == 0, result.stdout[-2000:] + report = json.loads((root / "report.json").read_text()) + receipts = [json.loads(line) for line in (root / "receipts.jsonl").read_text().splitlines()] + runs = [receipt for receipt in receipts if receipt["stage"] == "run"] + assert len(runs) == len(report["cells"]) == 4 + for cell, receipt in zip(report["cells"], runs): + compile_env, run_env = assignments(cell["compile_env"]), assignments(cell["run_env"]) + assert compile_env == receipt["compile_env"], (cell["arm"], "compile receipt", receipt) + assert run_env == receipt["run_env"], (cell["arm"], "run receipt", receipt) + if cell["arm"] == "loop_polls": + for effective in (compile_env, run_env): + assert effective["PERRY_GC_SCHEDULE_SEED"] == "10061" + assert effective["PERRY_GC_SCHEDULE_ALLOC_KB"] == "0" + assert effective["PERRY_GC_PROTECT_FROMSPACE"] == "1" + assert effective["PERRY_GC_VERIFY_EVACUATION"] == "1" + assert compile_env["PERRY_GC_MOVING_LOOP_POLLS"] == "1" + assert run_env["PERRY_GC_HEAP_LIMIT"] == "8" + else: + for effective in (compile_env, run_env): + assert "PERRY_GC_SCHEDULE_SEED" not in effective, (cell["arm"], effective) + assert "PERRY_GC_PROTECT_FROMSPACE" not in effective, (cell["arm"], effective) + if cell["arm"] == "shipped_default": + assert compile_env == {} + assert run_env == {"PERRY_GC_TRACE": "1", "PERRY_GC_DIAG": "1"} + assert cell["result"] == "PASS" + else: + assert cell["result"] == "UNVER", "command probes must never certify moving GC" + if cell["arm"] == "wb_off": + assert compile_env == {"PERRY_WRITE_BARRIERS": "0"} + assert run_env["PERRY_WRITE_BARRIERS"] == "0" + + +def self_test() -> None: + matrix = (ROOT / "scripts/gc_repsel_matrix.sh").read_text() + original_fixture = (ROOT / f"test-files/{TEST}.ts").read_text() + with tempfile.TemporaryDirectory(prefix="gc-matrix-routing-test-") as work: + root = Path(work) + for directory in ("scripts", "test-files", "test-parity", "probes", "target/release"): + (root / directory).mkdir(parents=True) + shutil.copy(ROOT / "scripts/gc_matrix_fixture_env.py", root / "scripts") + shutil.copy(ROOT / ".node-version", root) + (root / f"test-files/{TEST}.ts").write_text(original_fixture) + (root / "test-parity/gc_repsel_corpus.txt").write_text(TEST + "\n") + (root / "probes/node").write_text( + "#!/usr/bin/env python3\nimport sys\nfrom pathlib import Path\n" + "print('v' + Path('.node-version').read_text().strip() if '--version' in sys.argv else 'routing-probe-only')\n" + ) + compiler = root / "target/release/perry" + compiler.write_text( + "#!/usr/bin/env python3\nimport json, os, sys\nfrom pathlib import Path\n" + "captured = {k:v for k,v in os.environ.items() if k.startswith('PERRY_') and k != 'PERRY_BIN'}\n" + "receipt = {'stage':'compile', 'compile_env':captured}\n" + "with open(os.environ['ROUTING_RECEIPTS'], 'a') as f: f.write(json.dumps(receipt)+'\\n')\n" + "output = Path(sys.argv[sys.argv.index('-o')+1])\n" + "runtime = '#!/usr/bin/env python3\\nimport json, os\\n'\n" + "runtime += 'receipt = '+repr({'stage':'run','compile_env':captured})+'\\n'\n" + "runtime += \"receipt['run_env'] = {k:v for k,v in os.environ.items() if k.startswith('PERRY_')}\\n\"\n" + "runtime += \"with open(os.environ['ROUTING_RECEIPTS'], 'a') as f: f.write(json.dumps(receipt)+'\\\\n')\\n\"\n" + "runtime += \"print('routing-probe-only')\\n\"\n" + "output.write_text(runtime); output.chmod(0o755)\n" + ) + compiler.chmod(0o755) + (root / "probes/node").chmod(0o755) + check_receipts(root, run_probe(root, matrix)) + mutations = { + "missing compile-time instruments": ( + 'effective_cenv="$effective_cenv ${FIXTURE_ENVS[$ti]}"', 'effective_cenv="$effective_cenv"'), + "metadata leaks to OFF/control arms": ( + 'if [ "$id" = loop_polls ] &&', 'if [ "$id" != absent ] &&'), + "compile group aliases safepoint_minor": ( + 'slug="${slug}_fixture"; fixture_group=1', 'fixture_group=1'), + } + for name, (old, new) in mutations.items(): + assert old in matrix + result = run_probe(root, matrix.replace(old, new)) + try: + check_receipts(root, result) + except (AssertionError, KeyError): + pass + else: + raise AssertionError(f"routing proof failed to reject sabotage: {name}") + result = run_probe(root, matrix, "// parity-env: PERRY_NO_AUTO_OPTIMIZE=1\n" + original_fixture) + assert result.returncode == 2, result.stdout + assert not (root / "receipts.jsonl").exists(), "malformed metadata reached compiler" + print("GC matrix routing self-test: PASS (actual command env, 3 sabotage controls, early refusal; no GC acceptance)") + + +if __name__ == "__main__": + self_test() diff --git a/scripts/gc_repsel_matrix.sh b/scripts/gc_repsel_matrix.sh index 049f95f09d..aee2ad0e2f 100755 --- a/scripts/gc_repsel_matrix.sh +++ b/scripts/gc_repsel_matrix.sh @@ -73,6 +73,7 @@ # [--shard N/M] [--defer-liveness] # [--list-arms] [--liveness-report-only] # [--self-test-liveness-parser] +# [--self-test-fixture-env] set -uo pipefail # Sum objects actually relocated by completed copying minors. The diagnostic @@ -113,6 +114,7 @@ JSON_OUT="" PROFILE="release" LIVENESS_REPORT_ONLY=0 SELF_TEST_LIVENESS_PARSER=0 +SELF_TEST_FIXTURE_ENV=0 SHARD_INDEX=1 SHARD_COUNT=1 DEFER_LIVENESS=0 @@ -143,6 +145,7 @@ while [ $# -gt 0 ]; do --defer-liveness) DEFER_LIVENESS=1; shift ;; --list-arms) ARMS_SEL="__list__"; shift ;; --self-test-liveness-parser) SELF_TEST_LIVENESS_PARSER=1; shift ;; + --self-test-fixture-env) SELF_TEST_FIXTURE_ENV=1; shift ;; # Local exploration only (e.g. a `--filter` narrow enough that an arm # legitimately has nothing to bite). CI never passes this: the whole # point of #7255 is that an inert arm must be able to turn a run red. @@ -152,6 +155,13 @@ while [ $# -gt 0 ]; do esac done +if [ "$SELF_TEST_FIXTURE_ENV" = 1 ]; then + exec python3 "$SCRIPT_DIR/gc_matrix_fixture_env.py" --self-test +fi +case "$PRESSURE_MB" in + ''|*[!0-9]*) echo "invalid --pressure '$PRESSURE_MB' (expected integer MB)" >&2; exit 2 ;; +esac + case "$SHARD_INDEX:$SHARD_COUNT" in *[!0-9:]*|:*|*:) echo "invalid --shard '$SHARD_INDEX/$SHARD_COUNT' (expected positive integers)" >&2 @@ -455,6 +465,21 @@ fi echo "==> shard $SHARD_INDEX/$SHARD_COUNT: ${#CORPUS[@]}/$CORPUS_TOTAL corpus files (stable manifest round-robin)" progress "selected files=${#CORPUS[@]} corpus_total=$CORPUS_TOTAL" +# Fixture settings belong only to the explicit loop-poll witness arm. Validate +# before compiling/running anything; no eval, shell quoting, or arbitrary env +# keys are accepted. Apply the same settings at compile time so auto-optimize +# selects gc-instruments for seeded/protected witnesses (freshness.rs). +FIXTURE_ENVS=() +for b in "${CORPUS[@]}"; do + fixture_env="" + case ",$SELECTED," in + *,loop_polls,*) + fixture_env="$(python3 "$SCRIPT_DIR/gc_matrix_fixture_env.py" "test-files/$b.ts")" || exit 2 + ;; + esac + FIXTURE_ENVS+=("$fixture_env") +done + # --------------------------------------------------------------------------- # Oracle + compiler. THE ORACLE VERSION IS LOAD-BEARING: a test the oracle # cannot run would drop out of the gate silently, so refuse to run at all. @@ -503,17 +528,24 @@ done # --------------------------------------------------------------------------- ARM_IDS=(); ARM_CENVS=(); ARM_RENVS=(); ARM_LIVES=(); ARM_NOTES=(); ARM_SLUGS=() GROUP_SLUGS=(); GROUP_ENVS=() +GROUP_FIXTURES=() for rec in "${ARMS[@]}"; do id="$(arm_field "$rec" 1)" case ",$SELECTED," in *",$id,"*) ;; *) continue ;; esac cenv="$(arm_field "$rec" 2)" slug="$(printf '%s' "${cenv:-_base}" | tr -c 'A-Za-z0-9' '_')" + fixture_group=0 + # safepoint_minor has the same base compile env; never share its binaries + # with loop_polls when the latter carries fixture-specific instruments. + if [ "$id" = loop_polls ]; then slug="${slug}_fixture"; fixture_group=1; fi ARM_IDS+=("$id"); ARM_CENVS+=("$cenv"); ARM_RENVS+=("$(arm_field "$rec" 3)") ARM_LIVES+=("$(arm_field "$rec" 4)"); ARM_NOTES+=("$(arm_field "$rec" 5)") ARM_SLUGS+=("$slug") known=0 for g in ${GROUP_SLUGS[@]+"${GROUP_SLUGS[@]}"}; do [ "$g" = "$slug" ] && known=1 && break; done - if [ "$known" = 0 ]; then GROUP_SLUGS+=("$slug"); GROUP_ENVS+=("$cenv"); fi + if [ "$known" = 0 ]; then + GROUP_SLUGS+=("$slug"); GROUP_ENVS+=("$cenv"); GROUP_FIXTURES+=("$fixture_group") + fi done NARMS="${#ARM_IDS[@]}" [ "$NARMS" -gt 0 ] || { echo "no arms selected ($ARMS_SEL)" >&2; exit 2; } @@ -532,15 +564,45 @@ progress "compile-start env=_warm test=${CORPUS[0]}" || { echo "${RED}warm-up compile failed${NC} (see $WORK/bin/_warm/warm.log)" >&2; } progress "compile-result env=_warm test=${CORPUS[0]} result=$([ -x "$WORK/bin/_warm/warm" ] && echo PASS || echo FAIL)" +# Seed/protection metadata selects a second auto-optimize runtime feature set. +# Warm it serially too, preserving normal shipping archive selection and the +# existing parallelism for all corpus compiles that follow. +ti=0 +while [ "$ti" -lt "${#CORPUS[@]}" ]; do + fixture_env="${FIXTURE_ENVS[$ti]}" + case "$fixture_env" in + *PERRY_GC_SCHEDULE_SEED=*|*PERRY_GC_PROTECT_FROMSPACE=*) + progress "compile-start env=_warm_fixture test=${CORPUS[$ti]} compile_env=$fixture_env" + # All words have been validated as literal GC KEY=VALUE tokens. + # shellcheck disable=SC2086 + env $fixture_env "$PERRY_BIN" "test-files/${CORPUS[$ti]}.ts" -o "$WORK/bin/_warm/fixture" \ + > "$WORK/bin/_warm/fixture.log" 2>&1 \ + || { echo "${RED}fixture warm-up compile failed${NC}" >&2; } + progress "compile-result env=_warm_fixture test=${CORPUS[$ti]} result=$([ -x "$WORK/bin/_warm/fixture" ] && echo PASS || echo FAIL)" + break + ;; + esac + ti=$((ti+1)) +done + echo "==> compiling ${#CORPUS[@]} files x ${#GROUP_SLUGS[@]} compile-env groups (jobs=$JOBS)" gi=0 while [ "$gi" -lt "${#GROUP_SLUGS[@]}" ]; do slug="${GROUP_SLUGS[$gi]}"; cenv="${GROUP_ENVS[$gi]}" - mkdir -p "$WORK/bin/$slug" + mkdir -p "$WORK/bin/$slug" "$WORK/env/$slug" + ti=0 + for b in "${CORPUS[@]}"; do + effective_cenv="$cenv" + if [ "${GROUP_FIXTURES[$gi]}" = 1 ] && [ -n "${FIXTURE_ENVS[$ti]}" ]; then + effective_cenv="$effective_cenv ${FIXTURE_ENVS[$ti]}" + fi + printf '%s\n' "$effective_cenv" > "$WORK/env/$slug/$b" + ti=$((ti+1)) + done printf '%s\n' "${CORPUS[@]}" | WORK="$WORK" PERRY_BIN="$PERRY_BIN" CENV="$cenv" SLUG="$slug" \ PROGRESS_OUT="$PROGRESS_OUT" SHARD_INDEX="$SHARD_INDEX" SHARD_COUNT="$SHARD_COUNT" \ xargs -P "$JOBS" -I{} sh -c \ - 'echo " compile env=$SLUG test=$1"; [ -z "$PROGRESS_OUT" ] || printf "%s shard=%s/%s compile-start env=%s test=%s\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$SHARD_INDEX" "$SHARD_COUNT" "$SLUG" "$1" >> "$PROGRESS_OUT"; if env $CENV "$PERRY_BIN" "test-files/$1.ts" -o "$WORK/bin/$SLUG/$1" > "$WORK/bin/$SLUG/$1.log" 2>&1; then [ -z "$PROGRESS_OUT" ] || printf "%s shard=%s/%s compile-result env=%s test=%s result=PASS\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$SHARD_INDEX" "$SHARD_COUNT" "$SLUG" "$1" >> "$PROGRESS_OUT"; else echo "COMPILEFAIL $SLUG $1"; [ -z "$PROGRESS_OUT" ] || printf "%s shard=%s/%s compile-result env=%s test=%s result=FAIL\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$SHARD_INDEX" "$SHARD_COUNT" "$SLUG" "$1" >> "$PROGRESS_OUT"; fi' _ {} + 'CENV=$(cat "$WORK/env/$SLUG/$1"); echo " compile env=$SLUG test=$1"; [ -z "$PROGRESS_OUT" ] || printf "%s shard=%s/%s compile-start env=%s test=%s compile_env=%s\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$SHARD_INDEX" "$SHARD_COUNT" "$SLUG" "$1" "$CENV" >> "$PROGRESS_OUT"; if env $CENV "$PERRY_BIN" "test-files/$1.ts" -o "$WORK/bin/$SLUG/$1" > "$WORK/bin/$SLUG/$1.log" 2>&1; then [ -z "$PROGRESS_OUT" ] || printf "%s shard=%s/%s compile-result env=%s test=%s result=PASS\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$SHARD_INDEX" "$SHARD_COUNT" "$SLUG" "$1" >> "$PROGRESS_OUT"; else echo "COMPILEFAIL $SLUG $1"; [ -z "$PROGRESS_OUT" ] || printf "%s shard=%s/%s compile-result env=%s test=%s result=FAIL\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$SHARD_INDEX" "$SHARD_COUNT" "$SLUG" "$1" >> "$PROGRESS_OUT"; fi' _ {} gi=$((gi+1)) done @@ -562,20 +624,26 @@ triage_reason() { # $1 test, $2 arm END { exit(found ? 0 : 1) }' } -CELLS=(); EVID=(); CYC=(); EVA=(); SCA=(); REC=() +CELLS=(); EVID=(); CYC=(); EVA=(); SCA=(); REC=(); CELL_CENVS=(); CELL_RENVS=() n_pass=0; n_unver=0; n_fail=0; n_xfail=0 ai=0 while [ "$ai" -lt "$NARMS" ]; do id="${ARM_IDS[$ai]}"; slug="${ARM_SLUGS[$ai]}"; live="${ARM_LIVES[$ai]}" - renv="$(printf '%s' "${ARM_RENVS[$ai]}" | sed -e "s/%P%/$PRESSURE_ENV/" -e "s/%E%/$EVAC_ENV/")" - [ "$renv" = "-" ] && renv="" + arm_renv="$(printf '%s' "${ARM_RENVS[$ai]}" | sed -e "s/%P%/$PRESSURE_ENV/" -e "s/%E%/$EVAC_ENV/")" + [ "$arm_renv" = "-" ] && arm_renv="" echo "==> arm $id" ti=0 while [ "$ti" -lt "${#CORPUS[@]}" ]; do b="${CORPUS[$ti]}"; bin="$WORK/bin/$slug/$b"; idx=$((ti*NARMS+ai)) + renv="$arm_renv" + if [ "$id" = loop_polls ] && [ -n "${FIXTURE_ENVS[$ti]}" ]; then + renv="$renv ${FIXTURE_ENVS[$ti]}" + fi + CELL_CENVS[idx]="$(cat "$WORK/env/$slug/$b")" + CELL_RENVS[idx]="$renv PERRY_GC_TRACE=1 PERRY_GC_DIAG=1" echo " run [$((ti+1))/${#CORPUS[@]}] test=$b env=$slug arm=$id" - progress "cell-start test=$b env=$slug arm=$id" - cycles=0; evacuated=0; scavenged=0 + progress "cell-start test=$b env=$slug arm=$id compile_env=${CELL_CENVS[$idx]} run_env=${CELL_RENVS[$idx]}" + cycles=0; evacuated=0; scavenged=0; reclaimed=0 if [ ! -x "$bin" ]; then result="FAIL"; ev="compile-failed" else @@ -793,9 +861,10 @@ JSON_REPORT="${JSON_OUT:-$WORK/matrix.json}" # characters that would make this invalid JSON, so a malformed # report can never be the reason the gate fails. ev_json="$(printf '%s' "${EVID[$idx]:-}" | tr '"\\' "''")" - printf '{"test":"%s","arm":"%s","result":"%s","cycles":%d,"evacuated":%d,"scavenged":%d,"reclaimed":%d,"evidence":"%s"}' \ + printf '{"test":"%s","arm":"%s","result":"%s","cycles":%d,"evacuated":%d,"scavenged":%d,"reclaimed":%d,"evidence":"%s","compile_env":"%s","run_env":"%s"}' \ "${CORPUS[$ti]}" "${ARM_IDS[$ai]}" "${CELLS[$idx]:-?}" \ - "${CYC[$idx]:-0}" "${EVA[$idx]:-0}" "${SCA[$idx]:-0}" "${REC[$idx]:-0}" "$ev_json" + "${CYC[$idx]:-0}" "${EVA[$idx]:-0}" "${SCA[$idx]:-0}" "${REC[$idx]:-0}" "$ev_json" \ + "${CELL_CENVS[$idx]}" "${CELL_RENVS[$idx]}" ai=$((ai+1)) done ti=$((ti+1)) From 239f74c02294253c4a657f2de957abae685788d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 2 Oct 2026 15:39:53 +0200 Subject: [PATCH 08/17] Exercise short GC witnesses with seeded moving collections --- changelog.d/11680-gc-loop-witness-environments.md | 6 ++++++ .../test_gap_gc_11590_packed_loop_global_cache_rooting.ts | 1 + test-files/test_gap_gc_call_argument_rooting.ts | 1 + 3 files changed, 8 insertions(+) diff --git a/changelog.d/11680-gc-loop-witness-environments.md b/changelog.d/11680-gc-loop-witness-environments.md index c92d9d75a7..addbc46408 100644 --- a/changelog.d/11680-gc-loop-witness-environments.md +++ b/changelog.d/11680-gc-loop-witness-environments.md @@ -5,3 +5,9 @@ the normal auto-optimize path. Fixture metadata is restricted to validated GC settings; all other arms keep their original environments. Reports and progress logs record each cell's compile and run assignments. A CI routing self-test checks control isolation and rejects planted compile/run dispatch defects. + +The call-argument and packed-global-cache witnesses now carry their recorded +seeded collection settings, including the 4 KiB allocation interval and protected +from-space. Their TypeScript workloads remain unchanged. This makes the existing +moving arm exercise these short fixtures instead of accepting oracle parity +without any collection; the per-cell movement check remains mandatory. diff --git a/test-files/test_gap_gc_11590_packed_loop_global_cache_rooting.ts b/test-files/test_gap_gc_11590_packed_loop_global_cache_rooting.ts index 34b4cf7cfc..017e523fb7 100644 --- a/test-files/test_gap_gc_11590_packed_loop_global_cache_rooting.ts +++ b/test-files/test_gap_gc_11590_packed_loop_global_cache_rooting.ts @@ -1,3 +1,4 @@ +// parity-env: PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_GC_SCHEDULE_SEED=11590 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_SCHEDULE_ALLOC_KB=4 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_VERIFY_EVACUATION=1 // #11590: a packed-f64 range loop at module scope caches every loop-invariant // module global it reads in an entry alloca, for both loop clones. When the // global holds a heap receiver — here an array grown from `[]` by `d[j] = v`, diff --git a/test-files/test_gap_gc_call_argument_rooting.ts b/test-files/test_gap_gc_call_argument_rooting.ts index 38c55fec1c..7c18b40471 100644 --- a/test-files/test_gap_gc_call_argument_rooting.ts +++ b/test-files/test_gap_gc_call_argument_rooting.ts @@ -47,6 +47,7 @@ // it. That is the same shape of blind spot `js_implicit_this_set` (#7226) and // `js_regexp_new` (#7227) each cost a round for. // +// parity-env: PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_SCHEDULE_ALLOC_KB=4 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_VERIFY_EVACUATION=1 // LIVE BY CONSTRUCTION. `churn` keeps allocating AFTER the back-edge poll that // collects, so the abandoned from-space bytes are recycled before the callee // reads them — a stale read returns wrong text instead of the right answer out From 75c71179250c49ff250b63f5fd34e02308674f32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 2 Oct 2026 16:51:40 +0200 Subject: [PATCH 09/17] test(gc): retain large-Eden relocation coverage alongside policy ratchet --- .github/workflows/gc-ratchet.yml | 16 +++- benchmarks/gc_ratchet/README.md | 44 +++++++++-- .../gc_ratchet/check_large_eden_relocation.py | 73 +++++++++++++++++++ changelog.d/11680-gc-large-eden-relocation.md | 1 + tests/test_large_eden_gc_ratchet.py | 64 ++++++++++++++++ 5 files changed, 191 insertions(+), 7 deletions(-) create mode 100644 benchmarks/gc_ratchet/check_large_eden_relocation.py create mode 100644 changelog.d/11680-gc-large-eden-relocation.md create mode 100644 tests/test_large_eden_gc_ratchet.py diff --git a/.github/workflows/gc-ratchet.yml b/.github/workflows/gc-ratchet.yml index f99ace093a..2297811369 100644 --- a/.github/workflows/gc-ratchet.yml +++ b/.github/workflows/gc-ratchet.yml @@ -168,7 +168,7 @@ jobs: # deliberately broad; it exists only to spare docs-only PRs a build. # If the listing is empty or the API failed, `set -e` already aborted # — the job does not silently fall through to "not relevant". - if grep -qE '^(crates/|benchmarks/gc_ratchet/|Cargo\.(toml|lock)$|\.github/workflows/gc-ratchet\.yml$|tests/test_gc_ratchet\.py$)' changed.txt; then + if grep -qE '^(crates/|benchmarks/gc_ratchet/|Cargo\.(toml|lock)$|\.github/workflows/gc-ratchet\.yml$|tests/(test_gc_ratchet|test_large_eden_gc_ratchet)\.py$)' changed.txt; then echo "run=true" >> "$GITHUB_OUTPUT" echo "Change touches collector-relevant paths; measuring." else @@ -227,6 +227,7 @@ jobs: || { echo "::error::expected Node $expected, found $actual"; exit 1; } - name: Measure + id: measurement if: steps.relevance.outputs.run == 'true' env: PERRY_RUNTIME_DIR: ${{ github.workspace }}/target/release @@ -261,6 +262,18 @@ jobs: --current .bench-results/gc-ratchet-current.json \ --profile shared_ci + # Run even when the ordinary fingerprint is red, retaining both verdicts. + - name: Check large-Eden relocation separately + if: always() && steps.measurement.outcome == 'success' + env: + PERRY_RUNTIME_DIR: ${{ github.workspace }}/target/release + PERRY_NO_AUTO_OPTIMIZE: "1" + run: | + python3 benchmarks/gc_ratchet/check_large_eden_relocation.py \ + --perry target/release/perry \ + --node "$(command -v node)" \ + --output .bench-results/gc-ratchet-relocation.json + - name: Upload measurement if: always() && steps.relevance.outputs.run == 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -268,6 +281,7 @@ jobs: name: gc-ratchet-${{ github.sha }} path: | .bench-results/gc-ratchet-current.json + .bench-results/gc-ratchet-relocation.json benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json retention-days: 90 diff --git a/benchmarks/gc_ratchet/README.md b/benchmarks/gc_ratchet/README.md index 88484e6864..33775d66a2 100644 --- a/benchmarks/gc_ratchet/README.md +++ b/benchmarks/gc_ratchet/README.md @@ -154,10 +154,40 @@ probes**, and 21.8 MB on `12_large_live_set`, whose tenured-proportional cap term (`gc/tenuring.rs`, `max(influx x scale, tenured/2)`) already raises its Eden a little. That last row is worth noticing — it is the shipped path by which a large Eden is reached without any knob, and it tops out around 22 MB on the -biggest workload the suite has. The guard that keeps this honest is `check`'s existing -liveness rule (`minor_cycles > 0` and `copied_objects + promoted_objects > 0`): -a future change that stops reaching the copying minor at this cadence cannot be -pinned, it fails. +biggest workload the suite had at that measurement. These are historical cadence +measurements, not invariants of the current adaptive nursery policy. + +### Relocation coverage alongside the normal policy measurement + +Promotion can now retain whole nursery blocks in place. Consequently, +`minor_cycles > 0` and `copied_objects + promoted_objects > 0` prove minor +activity but do not prove a reference crossed physical relocation. Probe 13's +normal configuration still measures that shipping policy, with all original +counter bands and baseline rows intact. + +`check_large_eden_relocation.py` additionally compiles the unchanged probe and +runs it twice with its declared 64 MB nursery setting plus +`PERRY_GC_PROMOTE_IN_PLACE=0` and `PERRY_GC_DIAG=1`. Each run must exit normally, +match the exact pinned Node oracle, and report positive `minor_cycles`, +`copied_objects`, and +`copied_bytes`. The JSON retains both complete traces and verdicts. The workflow +runs this check even if the ordinary counter comparison fails, and uploads both +artifacts. Its result never accepts a changed normal-policy counter. + +The 64 MB setting is a base for the adaptive ladder, not a promise of a fixed +collection cadence. At #11645's child, the separate arm measured eight minors, +335,661 copied objects / 19,017,288 copied bytes and 109,455,704 freed bytes; +the original parent had three minors. This restores evacuation of the original +survivor graph, fresh note edges and strings at the large nursery setting; it +does not reproduce the parent's exact timing. Its timing and memory costs are +not shipping-policy measurements. + +```bash +PERRY_RUNTIME_DIR=target/release PERRY_NO_AUTO_OPTIMIZE=1 \ + python3 benchmarks/gc_ratchet/check_large_eden_relocation.py \ + --perry target/release/perry --node "$(command -v node)" \ + --output .bench-results/gc-ratchet-relocation.json +``` ## Why wall time is excluded from the shared-CI gate @@ -447,8 +477,10 @@ minors — was being reported as passing. **Why the second probe is a sum (#7558).** It used to be `copied_objects` alone. Both counters come from the same `[gc-copy-minor] ran` line: they are the evacuating minor's own accounting of *where* it put each survivor — survivor -space, or straight to old-gen. Either one alone names a destination; only the -sum answers "did the copying minor move anything". #7558 produced the +space, or straight to old-gen. At #7558 both destinations required relocation, so the sum then +answered "did the copying minor move anything". In-place promotion subsequently +made the sum a minor-activity check only; the separate probe-13 check above +requires actual copying. #7558 produced the distinction for real: with the conservative scan gone, the adaptive-tenuring seed (`gc/tenuring.rs`, which deliberately refuses input from a conservatively scanned cycle) started receiving data on `gc()`-driven workloads, diff --git a/benchmarks/gc_ratchet/check_large_eden_relocation.py b/benchmarks/gc_ratchet/check_large_eden_relocation.py new file mode 100644 index 0000000000..b5fa9bfc9d --- /dev/null +++ b/benchmarks/gc_ratchet/check_large_eden_relocation.py @@ -0,0 +1,73 @@ +"""Exercise probe 13's relocation path separately from its policy fingerprint.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import tempfile + +if __package__: + from . import gc_ratchet as ratchet +else: + import gc_ratchet as ratchet + + +SOURCE = Path(__file__).resolve().parent / "probes/13_large_eden_survivors.ts" + + +def check_relocation(binary: Path, node: Path, source: Path = SOURCE) -> dict: + """Keep both raw runs, including failures; promotion alone is not movement.""" + run_env = ratchet.probe_run_env(source) + if run_env.get("PERRY_GC_SCAVENGE_NURSERY_MB") != "64": + raise ratchet.RatchetError("probe 13 must retain its 64 MB nursery setting") + run_env.update(PERRY_GC_PROMOTE_IN_PLACE="0", PERRY_GC_DIAG="1") + oracle_version = "v" + (ratchet.REPO_ROOT / ".node-version").read_text().strip().lstrip("v") + result = { + "probe": source.stem, + "source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(), + "binary_sha256": hashlib.sha256(binary.read_bytes()).hexdigest(), + "run_env": run_env, + "runs": [], + "failures": [], + } + for index in range(2): + run = ratchet.run_once([str(binary)], extra_env=run_env) + counters = ratchet.parse_gc_diag(run["stderr"]) + correctness = ratchet._check_against_node(node, source, run["stdout"]) + result["runs"].append({**run, "counters": counters, "correctness": correctness}) + if run["returncode"] != 0: + result["failures"].append(f"run {index + 1}: exited {run['returncode']}") + if correctness.get("status") != "pass": + result["failures"].append(f"run {index + 1}: Node parity was not verified") + if correctness.get("oracle_version") != oracle_version: + result["failures"].append(f"run {index + 1}: expected Node {oracle_version}") + # These are positive on actual evacuation into survivor space, whereas + # promoted_objects also counts survivors whose blocks stayed in place. + for metric in ("minor_cycles", "copied_objects", "copied_bytes"): + if counters[metric] <= 0: + result["failures"].append(f"run {index + 1}: {metric} must be positive") + result["status"] = "fail" if result["failures"] else "pass" + return result + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--perry", required=True, type=Path) + parser.add_argument("--node", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + args = parser.parse_args(argv) + with tempfile.TemporaryDirectory(prefix="gc-ratchet-relocation-") as tmp: + binary = ratchet.compile_probe(args.perry.resolve(), SOURCE, Path(tmp)) + result = check_relocation(binary, args.node.resolve()) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + print(f"probe 13 relocation: {result['status']}") + for failure in result["failures"]: + print(failure) + return int(result["status"] != "pass") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/changelog.d/11680-gc-large-eden-relocation.md b/changelog.d/11680-gc-large-eden-relocation.md new file mode 100644 index 0000000000..96986c1dbe --- /dev/null +++ b/changelog.d/11680-gc-large-eden-relocation.md @@ -0,0 +1 @@ +Keep large-nursery relocation coverage live alongside the GC policy ratchet. The unchanged survivor-graph workload now also runs with in-place promotion disabled, requiring actual copied objects and bytes plus parity with the pinned Node oracle. Retain both traces and separate verdicts; the normal measurements, baseline, and counter tolerances remain unchanged. diff --git a/tests/test_large_eden_gc_ratchet.py b/tests/test_large_eden_gc_ratchet.py new file mode 100644 index 0000000000..dc05b3f3ee --- /dev/null +++ b/tests/test_large_eden_gc_ratchet.py @@ -0,0 +1,64 @@ +"""The supplemental relocation check must reject in-place-only collection.""" + +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from benchmarks.gc_ratchet import check_large_eden_relocation as relocation + + +class LargeEdenRelocationTests(unittest.TestCase): + def check(self, stderr, *, returncode=0, parity="pass", oracle_version=None): + if oracle_version is None: + oracle_version = "v" + (relocation.ratchet.REPO_ROOT / ".node-version").read_text().strip().lstrip("v") + with tempfile.TemporaryDirectory() as tmp: + binary = Path(tmp) / "probe" + binary.write_bytes(b"mock binary; never executed") + run = {"stdout": "checksum:1\n", "stderr": stderr, "returncode": returncode} + with mock.patch.object(relocation.ratchet, "run_once", return_value=run) as runner: + with mock.patch.object(relocation.ratchet, "_check_against_node", + return_value={"status": parity, "oracle_version": oracle_version}): + result = relocation.check_relocation(binary, Path("node")) + self.assertEqual(runner.call_count, 2) + for call in runner.call_args_list: + self.assertEqual(call.kwargs["extra_env"], { + "PERRY_GC_SCAVENGE_NURSERY_MB": "64", + "PERRY_GC_PROMOTE_IN_PLACE": "0", "PERRY_GC_DIAG": "1", + }) + return result + + def test_copied_survivors_pass_and_raw_runs_are_retained(self): + trace = "[gc-copy-minor] ran copied_objects=12 copied_bytes=768 promoted_objects=5\n" + result = self.check(trace) + self.assertEqual(result["status"], "pass") + self.assertEqual([run["stderr"] for run in result["runs"]], [trace, trace]) + + def test_promotion_in_place_does_not_prove_relocation(self): + result = self.check("[gc-copy-minor] ran in_place=true copied_objects=0 " + "copied_bytes=0 promoted_objects=310413 promoted_bytes=17694408\n") + self.assertEqual(result["status"], "fail") + self.assertTrue(any("copied_objects" in failure for failure in result["failures"])) + + def test_no_trace_fails(self): + self.assertEqual(self.check("")["status"], "fail") + + def test_crash_with_positive_trace_fails(self): + trace = "[gc-copy-minor] ran copied_objects=12 copied_bytes=768\n" + self.assertEqual(self.check(trace, returncode=-11)["status"], "fail") + + def test_wrong_or_unavailable_oracle_fails(self): + for parity in ("fail", "unchecked"): + with self.subTest(parity=parity): + self.assertEqual(self.check("[gc-copy-minor] ran copied_objects=12 " + "copied_bytes=768\n", parity=parity)["status"], "fail") + + def test_wrong_oracle_version_fails_even_with_matching_output(self): + result = self.check("[gc-copy-minor] ran copied_objects=12 copied_bytes=768\n", + oracle_version="v0.0.0") + self.assertEqual(result["status"], "fail") + self.assertTrue(any("expected Node" in failure for failure in result["failures"])) + + +if __name__ == "__main__": + unittest.main() From 155db5b3d7952f9562a494fcf7eee77d64e98668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 2 Oct 2026 12:38:32 +0200 Subject: [PATCH 10/17] fix(ci): link macOS stdlib provider frameworks --- tests/fixtures/issue_8075_provider_gc/stdlib-linker.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/fixtures/issue_8075_provider_gc/stdlib-linker.sh b/tests/fixtures/issue_8075_provider_gc/stdlib-linker.sh index 277b2b6544..720c5a615d 100755 --- a/tests/fixtures/issue_8075_provider_gc/stdlib-linker.sh +++ b/tests/fixtures/issue_8075_provider_gc/stdlib-linker.sh @@ -78,6 +78,10 @@ if [[ -n "$original_export_list" ]]; then fi if [[ "$saw_runtime_rlib" == true && "$host_os" == Darwin ]]; then + # The stdlib provider also links the runtime rlib, whose locale helpers + # call CoreFoundation and Objective-C. The runtime dylib's link flags do + # not propagate to this separate image. + arguments+=('-framework' 'CoreFoundation' '-framework' 'Foundation') arguments+=('-Wl,-rpath,@loader_path' '-Wl,-flat_namespace' '-Wl,-interposable') elif [[ "$saw_runtime_rlib" == true ]]; then # shellcheck disable=SC2016 # $ORIGIN must reach the ELF linker literally. From f21531cd28cdecb0add1b8bcedee7e2c27e38d4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 2 Oct 2026 12:38:59 +0200 Subject: [PATCH 11/17] changelog: record macOS provider framework link repair --- changelog.d/11762-macos-provider-frameworks.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/11762-macos-provider-frameworks.md diff --git a/changelog.d/11762-macos-provider-frameworks.md b/changelog.d/11762-macos-provider-frameworks.md new file mode 100644 index 0000000000..530b977c85 --- /dev/null +++ b/changelog.d/11762-macos-provider-frameworks.md @@ -0,0 +1 @@ +Link CoreFoundation and Foundation when building the macOS stdlib provider fixture with the runtime rlib, matching the existing runtime dylib build. This lets locale helpers resolve their framework and Objective-C dependencies before the provider GC gate executes. Linux and links without a runtime rlib retain their existing flags. From 9260ac4dbd5bebec913ab103b300439088d0b6b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 2 Oct 2026 14:05:29 +0200 Subject: [PATCH 12/17] fix(ci): export provider feature installer bootstrap symbols --- changelog.d/11762-macos-provider-frameworks.md | 2 ++ tests/fixtures/issue_8075_provider_gc/stdlib-linker.sh | 2 ++ .../issue_8075_provider_gc/stdlib-provider/src/lib.rs | 9 +++++++++ 3 files changed, 13 insertions(+) diff --git a/changelog.d/11762-macos-provider-frameworks.md b/changelog.d/11762-macos-provider-frameworks.md index 530b977c85..d23f87a03a 100644 --- a/changelog.d/11762-macos-provider-frameworks.md +++ b/changelog.d/11762-macos-provider-frameworks.md @@ -1 +1,3 @@ Link CoreFoundation and Foundation when building the macOS stdlib provider fixture with the runtime rlib, matching the existing runtime dylib build. This lets locale helpers resolve their framework and Objective-C dependencies before the provider GC gate executes. Linux and links without a runtime rlib retain their existing flags. + +Retain and export the stdlib feature-installation and registration entry points used by later-loaded apps. The macOS provider GC phase previously passed, but the following Response image failed to load because the compiled-feature installer was absent from the provider export list. diff --git a/tests/fixtures/issue_8075_provider_gc/stdlib-linker.sh b/tests/fixtures/issue_8075_provider_gc/stdlib-linker.sh index 720c5a615d..66646ea58a 100755 --- a/tests/fixtures/issue_8075_provider_gc/stdlib-linker.sh +++ b/tests/fixtures/issue_8075_provider_gc/stdlib-linker.sh @@ -26,6 +26,8 @@ stdlib_provider_exports=( js_response_get_headers js_response_new js_stdlib_init_dispatch + js_stdlib_install_compiled + js_stdlib_register_feature_installer js_stream_unwrap_handle ) diff --git a/tests/fixtures/issue_8075_provider_gc/stdlib-provider/src/lib.rs b/tests/fixtures/issue_8075_provider_gc/stdlib-provider/src/lib.rs index 3419235ba2..f6139de9f0 100644 --- a/tests/fixtures/issue_8075_provider_gc/stdlib-provider/src/lib.rs +++ b/tests/fixtures/issue_8075_provider_gc/stdlib-provider/src/lib.rs @@ -12,6 +12,15 @@ unsafe extern "C" { #[used] static PIN_STDLIB: extern "C" fn() -> i32 = perry_stdlib::common::js_stdlib_process_pending; +// Later-loaded apps register their generated feature installer and call the +// compiled-feature entry point. Keep both available in the provider image. +#[used] +static PIN_STDLIB_INSTALL_COMPILED: extern "C" fn() = + perry_stdlib::common::feature_hooks::js_stdlib_install_compiled; +#[used] +static PIN_STDLIB_REGISTER_FEATURE_INSTALLER: extern "C" fn(extern "C" fn()) = + perry_stdlib::common::feature_hooks::js_stdlib_register_feature_installer; + // A cdylib only retains Rust dependency code reachable from this wrapper. // Keep the exact Web Fetch/Streams surface used by #8038's later-loaded app; // otherwise the app links with dynamic lookups but dlopen fails on the first From b632a2f6cb6bcb983403b7fa59b075c783dff3bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 2 Oct 2026 19:09:13 +0200 Subject: [PATCH 13/17] fix(codegen): keep ConstFn packed keys out of the heap --- changelog.d/11680-constfn-static-key-bytes.md | 8 ++ .../src/codegen/static_constfn.rs | 20 +++-- .../src/codegen/static_constfn_tests.rs | 78 +++++++++++++++++++ 3 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 changelog.d/11680-constfn-static-key-bytes.md diff --git a/changelog.d/11680-constfn-static-key-bytes.md b/changelog.d/11680-constfn-static-key-bytes.md new file mode 100644 index 0000000000..c5c9093227 --- /dev/null +++ b/changelog.d/11680-constfn-static-key-bytes.md @@ -0,0 +1,8 @@ +### Fix ConstFn finalizer metadata allocating heap strings + +Emit packed property names for ConstFn finalizers as read-only byte constants. +Previously these metadata blobs entered the JavaScript string pool, allocating +and permanently rooting strings whose handles were never used. The Zod +enabled/disabled comparison exposed three extra strings totaling 168 heap bytes. +The finalizer still receives the same packed bytes and length; shape identity, +receiver validation, and closure rooting are unchanged. diff --git a/crates/perry-codegen/src/codegen/static_constfn.rs b/crates/perry-codegen/src/codegen/static_constfn.rs index 3a207aae25..ea8f5a197a 100644 --- a/crates/perry-codegen/src/codegen/static_constfn.rs +++ b/crates/perry-codegen/src/codegen/static_constfn.rs @@ -265,6 +265,19 @@ pub(crate) fn emit_final_entries( entries_symbol(prefix, *id), shape.constfn.len() )); + // Packed key names are finalizer metadata, not JavaScript strings. + // Putting them in StringPool would allocate and root a heap string + // for every distinct final layout, although only its bytes are used. + let packed = shape + .keys + .iter() + .map(|byte| format!("\\{byte:02X}")) + .collect::(); + module.add_named_string_constant( + &format!("{}_keys", entries_symbol(prefix, *id)), + shape.keys.len(), + &format!("c\"{packed}\""), + ); } } @@ -326,11 +339,8 @@ fn finalize_shape(ctx: &mut crate::expr::FnCtx<'_>, shape: &BirthShape, object: return object.to_string(); }; let entries = format!("@{}", entries_symbol(ctx.strings.module_prefix(), id)); - let packed = String::from_utf8(shape.keys.clone()).expect("UTF-8 property names"); - let key_idx = ctx.strings.intern(&packed); - let key = ctx.strings.entry(key_idx); - let global = format!("@{}", key.bytes_global); - let len = key.byte_len.to_string(); + let global = format!("{entries}_keys"); + let len = shape.keys.len().to_string(); use crate::types::{I32, I64, PTR}; ctx.block().call( I64, diff --git a/crates/perry-codegen/src/codegen/static_constfn_tests.rs b/crates/perry-codegen/src/codegen/static_constfn_tests.rs index 722647fdb6..05f6ff766b 100644 --- a/crates/perry-codegen/src/codegen/static_constfn_tests.rs +++ b/crates/perry-codegen/src/codegen/static_constfn_tests.rs @@ -109,6 +109,19 @@ fn final_literal_seed_and_lowering_share_symbols_and_stamp_after_stores_and_patc crate::stubs::static_shape_seed_ll(&[warm]), "cold and sidecar replay must have identical seed references" ); + std::env::set_var("PERRY_CONSTFN_SHAPE", "0"); + let off = String::from_utf8(crate::compile_module(&m, opts("executable")).unwrap()).unwrap(); + let ordinary_atoms = off.matches("call i64 @js_string_pool_atom").count(); + assert!( + ordinary_atoms > 0, + "the ordinary string pool must be exercised" + ); + assert_eq!( + ir.matches("call i64 @js_string_pool_atom").count(), + ordinary_atoms, + "packed finalizer metadata must not allocate JavaScript string-pool atoms" + ); + std::env::set_var("PERRY_CONSTFN_SHAPE", "1"); options.output_type = "dylib".into(); let unloadable = String::from_utf8(crate::compile_module(&m, options).unwrap()).unwrap(); assert!(!unloadable.contains("call i64 @js_object_finalize_constfn_static")); @@ -148,6 +161,71 @@ fn literal_proof_rejects_duplicates_and_rebindable_rest_bodies() { assert!(literal_final("p", &[("m".into(), rest)], 0).is_none()); } +#[cfg(feature = "llvm-inprocess")] +#[test] +fn final_key_bytes_survive_llvm_decoding_and_codegen_unit_splitting() { + use crate::types::{I32, PTR, VOID}; + + let shape = literal_final( + "packed_keys", + &[ + ("mé雪🦀".into(), closure(1, false)), + ("quote\"slash\\".into(), Expr::Number(1.0)), + ], + 0, + ) + .unwrap(); + let expected = "mé雪🦀\0quote\"slash\\\0".as_bytes(); + let entries = entries_symbol("packed_keys", 23); + let keys = format!("{entries}_keys"); + for target in [ + "x86_64-unknown-linux-gnu", + "x86_64-pc-windows-msvc", + "arm64-apple-macosx15.0.0", + ] { + let mut module = crate::module::LlModule::new(target); + module.add_external_global(&shape.constfn[0].symbol, PTR); + emit_final_entries(&mut module, "packed_keys", &[(shape.clone(), 23)]); + module.declare_function("consume", VOID, &[PTR, PTR, I32]); + // Both units use the same layout: ELF/COFF need one owner plus an + // external reference; Mach-O needs duplicate-safe definitions. + for name in ["first_factory", "second_factory"] { + let block = module + .define_function(name, VOID, vec![]) + .create_block("entry"); + block.call_void( + "consume", + &[ + (PTR, &format!("@{keys}")), + (PTR, &format!("@{entries}")), + (I32, &expected.len().to_string()), + ], + ); + block.ret_void(); + } + let units = module.render_codegen_units(2); + assert_eq!(units.len(), 2); + let context = inkwell::context::Context::create(); + let mut definitions = 0; + for unit in units { + let parsed = crate::inprocess::parse_ir_text(&context, &unit, "final_key_bytes") + .expect("packed metadata and all cross-unit references must parse"); + parsed.verify().expect("valid finalizer metadata unit"); + let global = parsed.get_global(&keys).expect("every consumer needs keys"); + assert!(global.is_constant()); + if let Some(initializer) = global.get_initializer() { + definitions += 1; + assert_eq!( + initializer.into_array_value().as_const_string().unwrap(), + expected, + "LLVM must decode exact UTF-8 bytes and one NUL per key" + ); + } + } + assert_eq!(definitions, if target.contains("apple") { 2 } else { 1 }); + } +} + fn empty_class() -> Class { Class { id: 7, From 2bf6352b505319a159ac77353267f1ba5d0d4cf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 2 Oct 2026 20:46:25 +0200 Subject: [PATCH 14/17] fix(ci): follow the shared shape mint and ratchet removed sites --- .../11680-shape-authority-inventories.md | 10 +++++ scripts/check_gc_header_constants.py | 6 --- scripts/shape_descriptor_census.py | 43 ++++++++++++++++--- scripts/shape_descriptor_census_baseline.json | 9 ++-- scripts/sso_unbox_baseline.txt | 4 +- 5 files changed, 52 insertions(+), 20 deletions(-) create mode 100644 changelog.d/11680-shape-authority-inventories.md diff --git a/changelog.d/11680-shape-authority-inventories.md b/changelog.d/11680-shape-authority-inventories.md new file mode 100644 index 0000000000..db3a6e52fc --- /dev/null +++ b/changelog.d/11680-shape-authority-inventories.md @@ -0,0 +1,10 @@ +Keep the one-shape source checks aligned with the completed migrations. Remove +the four header-offset callsites deleted with the legacy class-loop path, lower +the SSO debt inventory for the removed heap-only key reader, and retire the two +deleted class-guard constant registrations. + +The descriptor census now follows ConstFn's wrappers to the shared mint and +checks that the descriptor is published before both reverse indexes. Two +negative controls reverse those orderings and must fail. The original shape +census, header-constant check and its negative control, and SSO inventory pass; +no debt ceiling or runtime performance tolerance is raised. diff --git a/scripts/check_gc_header_constants.py b/scripts/check_gc_header_constants.py index 15b3b48a1c..30d073fbea 100755 --- a/scripts/check_gc_header_constants.py +++ b/scripts/check_gc_header_constants.py @@ -124,8 +124,6 @@ class into `@perry_class_header_image_*`; and "GC_FLAG_ARENA", "allocation-site copy of the baked header word"), # --- the class-field inline guard --------------------------------------- - ("crates/perry-codegen/src/expr/class_field_inline_guard.rs", "GC_TYPE_OBJECT", - "GC_TYPE_OBJECT", "guard: obj_type byte"), ("crates/perry-codegen/src/expr/class_field_inline_guard.rs", "GC_FLAG_FORWARDED_I8", "GC_FLAG_FORWARDED - 256", "guard: gc_flags 0x80 spelled as a signed i8"), @@ -136,10 +134,6 @@ class into `@perry_class_header_image_*`; and ("crates/perry-codegen/src/expr/put_value_store_ic.rs", "ADD_REFUSE_RESERVED", "OBJ_FLAG_HAS_DESCRIPTORS | OBJ_FLAG_STABLE_TOMBSTONES", "key-add hit: per-object flags refused before the stamp"), - ("crates/perry-codegen/src/expr/class_field_inline_guard.rs", - "OBJ_FLAG_READ_FAST_PATH_BLOCKED", - "OBJ_FLAG_ARRAY_DESCRIPTORS | OBJ_FLAG_HAS_DESCRIPTORS", - "guard: #5654 per-receiver descriptor veto (a COMPOSITE of two flags)"), ("crates/perry-codegen/src/expr/class_field_inline_guard.rs", "OBJ_FLAG_WRITE_FAST_PATH_BLOCKED", "OBJ_FLAG_ARRAY_DESCRIPTORS | OBJ_FLAG_HAS_DESCRIPTORS" diff --git a/scripts/shape_descriptor_census.py b/scripts/shape_descriptor_census.py index ae011b8c28..8126ad1907 100644 --- a/scripts/shape_descriptor_census.py +++ b/scripts/shape_descriptor_census.py @@ -514,12 +514,19 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: "again; the descriptor is the authoritative edge since #8112" ) - # The insert/reverse-index body lives in the `_with_holes` variant since - # the tombstone-delete work; `_with_generation` is a thin forwarding - # wrapper, and so is `_with_rep` since charter step 5 split the intern - # (`shape_descriptor_intern_with_rep`, which takes an exact summary) out of - # it. The authority ordering is checked where the writes are. - ensure = function_body(shapes, "shape_descriptor_intern_with_rep") + # ConstFn adds exact body facts to the same mint. Follow both forwarding + # wrappers and check ordering in the shared body that actually publishes + # the descriptor and its reverse indexes. + for wrapper, callee in ( + ("shape_descriptor_intern_with_rep", "shape_descriptor_intern_with_special"), + ("shape_descriptor_intern_with_special", "shape_descriptor_intern_with_special_mode"), + ): + require_code( + function_body(shapes, wrapper), + rf"\b{callee}\s*\(", + f"{wrapper} delegates to the shared shape mint", + ) + ensure = function_body(shapes, "shape_descriptor_intern_with_special_mode") # 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 @@ -533,7 +540,7 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: if ensure_append is None: raise CensusError( "shape descriptor authority surface missing: family append in " - "shape_descriptor_intern_with_rep" + "shape_descriptor_intern_with_special_mode" ) assert_before( ensure, @@ -541,6 +548,12 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: ensure_append, "by-id descriptor before reverse accelerator", ) + assert_before( + ensure, + "slab_mut().insert", + "facts_append_fresh", + "by-id descriptor before facts accelerator", + ) # The structural publish body (charter step 5: `publish_object_shape_from` # delegates to it with an all-Any rep). sync = function_body(shapes, "publish_object_shape_from_rep") @@ -1210,6 +1223,22 @@ def run_sabotage_selftests(sources: dict[str, str], baseline: dict[str, object]) lambda: assert_authority_surfaces(plan_fact_read), ) + # Both reverse accelerators must remain downstream of the actual mint, + # including after a wrapper refactor. Exercise each ordering separately. + for accelerator in ("family_append_fresh", "facts_append_fresh"): + inverted_mint = dict(sources) + mint_body = function_body( + inverted_mint[shapes_path], "shape_descriptor_intern_with_special_mode" + ) + inverted_body = swap_once(mint_body, "slab_mut().insert", accelerator) + inverted_mint[shapes_path] = inverted_mint[shapes_path].replace( + mint_body, inverted_body, 1 + ) + expect_rejected( + f"{accelerator} before descriptor publication", + lambda: assert_authority_surfaces(inverted_mint), + ) + inverted_publication = dict(sources) path = "crates/perry-runtime/src/object/shapes.rs" publication_body = function_body( diff --git a/scripts/shape_descriptor_census_baseline.json b/scripts/shape_descriptor_census_baseline.json index 52e6784241..938a7b326a 100644 --- a/scripts/shape_descriptor_census_baseline.json +++ b/scripts/shape_descriptor_census_baseline.json @@ -4,14 +4,13 @@ "crates/perry-codegen/src/expr/element_shape_guard.rs|let header_skip = crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 1, "crates/perry-codegen/src/expr/member_update.rs|let header_skip = crate::target_layout::object_header_size_bytes(": 1, "crates/perry-codegen/src/expr/method_site.rs|let header = crate::target_layout::object_header_size_bytes(ctx.target_triple) as i64;": 1, - "crates/perry-codegen/src/expr/property_get.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple)": 3, + "crates/perry-codegen/src/expr/property_get.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple)": 2, "crates/perry-codegen/src/expr/property_get/composed_ics.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 1, "crates/perry-codegen/src/expr/property_get/generic_dispatch.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 1, "crates/perry-codegen/src/expr/property_get/generic_dispatch.rs|let header_bytes = crate::target_layout::object_header_size_bytes(ctx.target_triple) as i64;": 1, - "crates/perry-codegen/src/expr/property_get/helpers.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 3, + "crates/perry-codegen/src/expr/property_get/helpers.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 2, "crates/perry-codegen/src/expr/property_get/helpers.rs|let header_skip = crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 1, - "crates/perry-codegen/src/expr/property_set.rs|crate::target_layout::object_header_size_bytes(": 3, - "crates/perry-codegen/src/expr/property_set/sloppy_class_field.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple)": 1, + "crates/perry-codegen/src/expr/property_set.rs|crate::target_layout::object_header_size_bytes(": 2, "crates/perry-codegen/src/expr/property_set/sloppy_class_field.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 2, "crates/perry-codegen/src/expr/proxy_reflect.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 2, "crates/perry-codegen/src/expr/put_value_store_ic.rs|let header_size = crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 2, @@ -63,7 +62,7 @@ "crates/perry-runtime/src/object/test_root_accessors.rs|keys_array|access|let inline = unsafe { (*st.object_hot.shape_inline_cache.get())[slot].keys_array as usize };": 1 }, "summary": { - "codegen_object_header_size_sites": 46, + "codegen_object_header_size_sites": 42, "raw_member_files": 13, "raw_member_sites": { "keys_array": 33 diff --git a/scripts/sso_unbox_baseline.txt b/scripts/sso_unbox_baseline.txt index 1b67324a11..ffff8922bc 100644 --- a/scripts/sso_unbox_baseline.txt +++ b/scripts/sso_unbox_baseline.txt @@ -8,11 +8,11 @@ heap-tag-only | perry-ext-events | 1 heap-tag-only | perry-ext-http | 2 heap-tag-only | perry-ext-nodemailer | 1 heap-tag-only | perry-ffi | 2 -heap-tag-only | perry-runtime | 54 +heap-tag-only | perry-runtime | 53 heap-tag-only | perry-stdlib | 8 mask-cast | perry-ext-events | 3 mask-cast | perry-ext-http | 2 mask-cast | perry-ext-ws | 1 mask-cast | perry-ffi | 1 -mask-cast | perry-runtime | 38 +mask-cast | perry-runtime | 37 mask-cast | perry-stdlib | 4 From 5c005000258c86d6b0c245ff93e22824e4c17b2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 2 Oct 2026 20:52:16 +0200 Subject: [PATCH 15/17] fix(runtime): scope ConstFn handles and isolate worker test observations --- changelog.d/11680-constfn-handle-scopes.md | 14 + .../src/object/constfn_key_add_tests.rs | 311 +++++++++++------- .../src/object/shapes_worker_seed.rs | 26 +- .../perry-runtime/src/object/static_shapes.rs | 31 +- .../src/object/static_shapes_tests.rs | 297 +++++++++-------- .../src/thread_constfn_transfer_tests.rs | 307 +++++++++-------- .../src/thread_literal_launch_tests.rs | 266 ++++++++++----- 7 files changed, 760 insertions(+), 492 deletions(-) create mode 100644 changelog.d/11680-constfn-handle-scopes.md diff --git a/changelog.d/11680-constfn-handle-scopes.md b/changelog.d/11680-constfn-handle-scopes.md new file mode 100644 index 0000000000..89274608bd --- /dev/null +++ b/changelog.d/11680-constfn-handle-scopes.md @@ -0,0 +1,14 @@ +Use scoped runtime handles in ConstFn finalization and its moving-GC, key-add, +worker-seed and transfer tests. Calls that need the receiver after a collection +reload it through `across_mut`; leaf and self-rooting entries receive scoped +arguments. This removes 154 new raw-handle debt sites without raising ceilings. + +Worker-launch tests now carry independently owned observations in their closure +captures instead of sharing asserted process globals. An escaped Arc protects +the launch even if a body runs too often or a test times out; workers retain an +owned observation for late preparation callbacks. The existing ON/OFF, ordering, +worker-identity, output and moving-GC assertions remain. + +Raw-handle, global-isolation, address-class and root-holder source checks pass, +along with formatting. The 34 existing tests still require execution on the +updated build; source checks do not establish runtime correctness. diff --git a/crates/perry-runtime/src/object/constfn_key_add_tests.rs b/crates/perry-runtime/src/object/constfn_key_add_tests.rs index 8ef8c8a555..461e4f96e9 100644 --- a/crates/perry-runtime/src/object/constfn_key_add_tests.rs +++ b/crates/perry-runtime/src/object/constfn_key_add_tests.rs @@ -1,4 +1,7 @@ //! Positive cache reuse and refusals for ordered ConstFn key-add publication. +//! Scoped reads/capture stores are leaf operations. The property setter roots +//! receiver, key, and value before its allocating tail; no borrowed pointer is +//! used after that call. Movement observations retain only old integer addresses. use super::*; use crate::{closure, gc, object, value}; @@ -30,15 +33,19 @@ fn cached_constfn_key_add_uses_current_factory_captures() { let key = scope.root_raw_mut_ptr(key("cached_cf_factory_method")); let a = scope.root_raw_mut_ptr(closure::js_closure_alloc(info(), 1)); let b = scope.root_raw_mut_ptr(closure::js_closure_alloc(info(), 1)); - closure::js_closure_set_capture_f64(a.get_raw_mut_ptr(), 0, 31.0); - closure::js_closure_set_capture_f64(b.get_raw_mut_ptr(), 0, 47.0); + a.with_mut_ptr(|a_ptr| closure::js_closure_set_capture_f64(a_ptr, 0, 31.0)); + b.with_mut_ptr(|b_ptr| closure::js_closure_set_capture_f64(b_ptr, 0, 47.0)); let first = scope.root_raw_mut_ptr(object::js_object_alloc(0, 4)); - object::js_object_set_field_by_name( - first.get_raw_mut_ptr(), - key.get_raw_mut_ptr(), - f64::from_bits(bits(a.get_raw_mut_ptr())), - ); - let target = shapes::object_shape_stamp(first.get_raw_mut_ptr()); + first.with_mut_ptr(|first_ptr| { + key.with_mut_ptr(|key_ptr| { + object::js_object_set_field_by_name( + first_ptr, + key_ptr, + f64::from_bits(a.with_mut_ptr(|a_ptr| bits(a_ptr))), + ) + }) + }); + let target = first.with_mut_ptr(|first_ptr| shapes::object_shape_stamp(first_ptr)); assert_eq!( shapes::shape_descriptor_by_id(target) .unwrap() @@ -46,28 +53,43 @@ fn cached_constfn_key_add_uses_current_factory_captures() { 1 ); let second = scope.root_raw_mut_ptr(object::js_object_alloc(0, 4)); - shapes::test_watch_cached_transition_stamps( - second.get_raw_mut_ptr::() as usize - ); - object::js_object_set_field_by_name( - second.get_raw_mut_ptr(), - key.get_raw_mut_ptr(), - f64::from_bits(bits(b.get_raw_mut_ptr())), - ); + second.with_mut_ptr::(|second_ptr| { + shapes::test_watch_cached_transition_stamps(second_ptr as usize) + }); + second.with_mut_ptr(|second_ptr| { + key.with_mut_ptr(|key_ptr| { + object::js_object_set_field_by_name( + second_ptr, + key_ptr, + f64::from_bits(b.with_mut_ptr(|b_ptr| bits(b_ptr))), + ) + }) + }); assert_eq!( shapes::test_cached_transition_stamps(), 1, "must install cached Any intermediate" ); shapes::test_reset_cached_transition_stamps(); - assert_eq!(shapes::object_shape_stamp(second.get_raw_mut_ptr()), target); - assert_eq!(slot(second.get_raw_mut_ptr()), b.get_raw_mut_ptr()); assert_eq!( - capture_body(slot(first.get_raw_mut_ptr()), closure::JsThis::UNDEFINED), + second.with_mut_ptr(|second_ptr| shapes::object_shape_stamp(second_ptr)), + target + ); + b.with_mut_ptr(|b_ptr| { + assert_eq!(second.with_mut_ptr(|second_ptr| slot(second_ptr)), b_ptr) + }); + assert_eq!( + capture_body( + first.with_mut_ptr(|first_ptr| slot(first_ptr)), + closure::JsThis::UNDEFINED + ), 31.0 ); assert_eq!( - capture_body(slot(second.get_raw_mut_ptr()), closure::JsThis::UNDEFINED), + capture_body( + second.with_mut_ptr(|second_ptr| slot(second_ptr)), + closure::JsThis::UNDEFINED + ), 47.0 ); } @@ -82,16 +104,24 @@ fn cached_constfn_key_add_refuses_wrong_body_unsafe_this_and_deprecation() { let key = scope.root_raw_mut_ptr(key("cached_cf_refused_method")); let closure = scope.root_raw_mut_ptr(closure::js_closure_alloc(info(), 1)); let first = scope.root_raw_mut_ptr(object::js_object_alloc(0, 4)); - let pred = shapes::object_shape_stamp(first.get_raw_mut_ptr()); - object::js_object_set_field_by_name( - first.get_raw_mut_ptr(), - key.get_raw_mut_ptr(), - f64::from_bits(bits(closure.get_raw_mut_ptr())), - ); - let hit = - object::transition_cache_lookup(pred, key.get_raw_mut_ptr()).expect("cached body edge"); + let pred = first.with_mut_ptr(|first_ptr| shapes::object_shape_stamp(first_ptr)); + first.with_mut_ptr(|first_ptr| { + key.with_mut_ptr(|key_ptr| { + object::js_object_set_field_by_name( + first_ptr, + key_ptr, + f64::from_bits(closure.with_mut_ptr(|closure_ptr| bits(closure_ptr))), + ) + }) + }); + let hit = key + .with_mut_ptr(|key_ptr| object::transition_cache_lookup(pred, key_ptr)) + .expect("cached body edge"); let receiver = scope.root_raw_mut_ptr(object::js_object_alloc(0, 4)); - assert_eq!(shapes::object_shape_stamp(receiver.get_raw_mut_ptr()), pred); + assert_eq!( + receiver.with_mut_ptr(|receiver_ptr| shapes::object_shape_stamp(receiver_ptr)), + pred + ); let wrong = scope.root_raw_mut_ptr(closure::js_closure_alloc( crate::fn_info!(other_body, 0; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)), 0, @@ -105,32 +135,45 @@ fn cached_constfn_key_add_refuses_wrong_body_unsafe_this_and_deprecation() { 1, )); for value in [ - bits(wrong.get_raw_mut_ptr()), - bits(unsafe_this.get_raw_mut_ptr()), - bits(unloadable.get_raw_mut_ptr()), + wrong.with_mut_ptr(|wrong_ptr| bits(wrong_ptr)), + unsafe_this.with_mut_ptr(|unsafe_this_ptr| bits(unsafe_this_ptr)), + unloadable.with_mut_ptr(|unloadable_ptr| bits(unloadable_ptr)), 9.0f64.to_bits(), value::TAG_UNDEFINED, ] { - assert!(admit_or_store(receiver.get_raw_mut_ptr(), pred, hit, value).is_none()); - assert_eq!(shapes::object_shape_stamp(receiver.get_raw_mut_ptr()), pred); + assert!(receiver + .with_mut_ptr(|receiver_ptr| admit_or_store(receiver_ptr, pred, hit, value)) + .is_none()); + assert_eq!( + receiver.with_mut_ptr(|receiver_ptr| shapes::object_shape_stamp(receiver_ptr)), + pred + ); assert_eq!( - object::js_object_get_field(receiver.get_raw_mut_ptr(), 0).bits(), + receiver + .with_mut_ptr(|receiver_ptr| object::js_object_get_field(receiver_ptr, 0)) + .bits(), value::TAG_UNDEFINED ); } assert!(shapes::shape_record_by_id(hit.2) .unwrap() .deprecate_special_to_any(hit.1)); - assert!(admit_or_store( - receiver.get_raw_mut_ptr(), - pred, - hit, - bits(closure.get_raw_mut_ptr()) - ) - .is_none()); - assert_eq!(shapes::object_shape_stamp(receiver.get_raw_mut_ptr()), pred); + assert!(receiver + .with_mut_ptr(|receiver_ptr| admit_or_store( + receiver_ptr, + pred, + hit, + closure.with_mut_ptr(|closure_ptr| bits(closure_ptr)) + )) + .is_none()); assert_eq!( - object::js_object_get_field(receiver.get_raw_mut_ptr(), 0).bits(), + receiver.with_mut_ptr(|receiver_ptr| shapes::object_shape_stamp(receiver_ptr)), + pred + ); + assert_eq!( + receiver + .with_mut_ptr(|receiver_ptr| object::js_object_get_field(receiver_ptr, 0)) + .bits(), value::TAG_UNDEFINED ); } @@ -170,83 +213,105 @@ fn moving_roundtrip(method_key: &str, expected_cached_stamps: u64) { let key = scope.root_raw_mut_ptr(key(method_key)); let a = scope.root_raw_mut_ptr(closure::js_closure_alloc(info(), 1)); let b = scope.root_raw_mut_ptr(closure::js_closure_alloc(info(), 1)); - closure::js_closure_set_capture_f64(a.get_raw_mut_ptr(), 0, 17.0); - closure::js_closure_set_capture_f64(b.get_raw_mut_ptr(), 0, 29.0); + a.with_mut_ptr(|a_ptr| closure::js_closure_set_capture_f64(a_ptr, 0, 17.0)); + b.with_mut_ptr(|b_ptr| closure::js_closure_set_capture_f64(b_ptr, 0, 29.0)); let first = scope.root_raw_mut_ptr(object::js_object_alloc(0, 4)); - object::js_object_set_field_by_name( - first.get_raw_mut_ptr(), - key.get_raw_mut_ptr(), - f64::from_bits(bits(a.get_raw_mut_ptr())), - ); - let target = shapes::object_shape_stamp(first.get_raw_mut_ptr()); + first.with_mut_ptr(|first_ptr| { + key.with_mut_ptr(|key_ptr| { + object::js_object_set_field_by_name( + first_ptr, + key_ptr, + f64::from_bits(a.with_mut_ptr(|a_ptr| bits(a_ptr))), + ) + }) + }); + let target = first.with_mut_ptr(|first_ptr| shapes::object_shape_stamp(first_ptr)); let second = scope.root_raw_mut_ptr(object::js_object_alloc(0, 4)); - let original_receiver = second.get_raw_mut_ptr::() as usize; - let original_closure = b.get_raw_mut_ptr::() as usize; + let original_receiver = second.with_mut_ptr::(|obj| obj as usize); + let original_closure = b.with_mut_ptr::(|c| c as usize); assert!(crate::arena::pointer_in_nursery(original_receiver)); assert!(crate::arena::pointer_in_nursery(original_closure)); - gc::gc_collect_minor(); + // Only scalar old-address observations cross collection. Both handles + // reload after the same real copying minor, before any dereference. + let ((_, moved_closure), moved_receiver) = second.across_mut::(|| { + b.across_mut::(|| gc::gc_collect_minor()) + }); assert_ne!( - second.get_raw_mut_ptr::() as usize, - original_receiver, + moved_receiver as usize, original_receiver, "pre-store receiver must move" ); assert_ne!( - b.get_raw_mut_ptr::() as usize, - original_closure, + moved_closure as usize, original_closure, "incoming closure root must refresh" ); assert_eq!( - field_rep_store::object_slot_rep(second.get_raw_mut_ptr(), 0), + second.with_mut_ptr(|second_ptr| field_rep_store::object_slot_rep(second_ptr, 0)), field_rep::REP_ANY ); assert_eq!( - object::js_object_get_field(second.get_raw_mut_ptr(), 0).bits(), + second + .with_mut_ptr(|second_ptr| object::js_object_get_field(second_ptr, 0)) + .bits(), value::TAG_UNDEFINED ); - shapes::test_watch_cached_transition_stamps( - second.get_raw_mut_ptr::() as usize - ); - object::js_object_set_field_by_name( - second.get_raw_mut_ptr(), - key.get_raw_mut_ptr(), - f64::from_bits(bits(b.get_raw_mut_ptr())), - ); + second.with_mut_ptr::(|second_ptr| { + shapes::test_watch_cached_transition_stamps(second_ptr as usize) + }); + second.with_mut_ptr(|second_ptr| { + key.with_mut_ptr(|key_ptr| { + object::js_object_set_field_by_name( + second_ptr, + key_ptr, + f64::from_bits(b.with_mut_ptr(|b_ptr| bits(b_ptr))), + ) + }) + }); assert_eq!( shapes::test_cached_transition_stamps(), expected_cached_stamps, "content keys reuse the cache; relocated pointer keys safely miss" ); shapes::test_reset_cached_transition_stamps(); - let before_receiver = second.get_raw_mut_ptr::() as usize; - let before_closure = slot(second.get_raw_mut_ptr()) as usize; - field_rep_store::assert_field_rep_lanes( - second.get_raw_mut_ptr(), - shapes::object_shape_record(second.get_raw_mut_ptr()), - 1, - ); - gc::gc_collect_minor(); + let before_receiver = second.with_mut_ptr::(|obj| obj as usize); + let before_closure = second.with_mut_ptr(|second_ptr| slot(second_ptr)) as usize; + second.with_mut_ptr(|second_ptr| { + field_rep_store::assert_field_rep_lanes( + second_ptr, + shapes::object_shape_record(second_ptr), + 1, + ) + }); + let (_, moved_receiver) = second.across_mut::(|| gc::gc_collect_minor()); field_rep_store::assert_field_rep_lanes( - second.get_raw_mut_ptr(), - shapes::object_shape_record(second.get_raw_mut_ptr()), + moved_receiver, + shapes::object_shape_record(moved_receiver), 1, ); assert_ne!( - second.get_raw_mut_ptr::() as usize, - before_receiver, + moved_receiver as usize, before_receiver, "post-store receiver must move" ); assert_ne!( - slot(second.get_raw_mut_ptr()) as usize, + slot(moved_receiver) as usize, before_closure, "SPECIAL current closure slot must rewrite" ); - assert_eq!(shapes::object_shape_stamp(second.get_raw_mut_ptr()), target); assert_eq!( - capture_body(slot(first.get_raw_mut_ptr()), closure::JsThis::UNDEFINED), + second.with_mut_ptr(|second_ptr| shapes::object_shape_stamp(second_ptr)), + target + ); + assert_eq!( + capture_body( + first.with_mut_ptr(|first_ptr| slot(first_ptr)), + closure::JsThis::UNDEFINED + ), 17.0 ); assert_eq!( - capture_body(slot(second.get_raw_mut_ptr()), closure::JsThis::UNDEFINED), + capture_body( + second.with_mut_ptr(|second_ptr| slot(second_ptr)), + closure::JsThis::UNDEFINED + ), 29.0 ); } @@ -262,20 +327,24 @@ fn cached_constfn_key_add_preserves_preceding_f64_lane() { let method_key = scope.root_raw_mut_ptr(key("cfmethod")); let a = scope.root_raw_mut_ptr(closure::js_closure_alloc(info(), 1)); let b = scope.root_raw_mut_ptr(closure::js_closure_alloc(info(), 1)); - closure::js_closure_set_capture_f64(a.get_raw_mut_ptr(), 0, 41.0); - closure::js_closure_set_capture_f64(b.get_raw_mut_ptr(), 0, 43.0); + a.with_mut_ptr(|a_ptr| closure::js_closure_set_capture_f64(a_ptr, 0, 41.0)); + b.with_mut_ptr(|b_ptr| closure::js_closure_set_capture_f64(b_ptr, 0, 43.0)); let first = scope.root_raw_mut_ptr(object::js_object_alloc(0, 4)); - object::js_object_set_field_by_name( - first.get_raw_mut_ptr(), - number_key.get_raw_mut_ptr(), - 3.0, - ); - object::js_object_set_field_by_name( - first.get_raw_mut_ptr(), - method_key.get_raw_mut_ptr(), - f64::from_bits(bits(a.get_raw_mut_ptr())), - ); - let target = shapes::object_shape_stamp(first.get_raw_mut_ptr()); + first.with_mut_ptr(|first_ptr| { + number_key.with_mut_ptr(|number_key_ptr| { + object::js_object_set_field_by_name(first_ptr, number_key_ptr, 3.0) + }) + }); + first.with_mut_ptr(|first_ptr| { + method_key.with_mut_ptr(|method_key_ptr| { + object::js_object_set_field_by_name( + first_ptr, + method_key_ptr, + f64::from_bits(a.with_mut_ptr(|a_ptr| bits(a_ptr))), + ) + }) + }); + let target = first.with_mut_ptr(|first_ptr| shapes::object_shape_stamp(first_ptr)); assert_eq!( shapes::shape_descriptor_by_id(target) .unwrap() @@ -283,33 +352,43 @@ fn cached_constfn_key_add_preserves_preceding_f64_lane() { 2 ); let second = scope.root_raw_mut_ptr(object::js_object_alloc(0, 4)); - object::js_object_set_field_by_name( - second.get_raw_mut_ptr(), - number_key.get_raw_mut_ptr(), - 7.0, - ); - shapes::test_watch_cached_transition_stamps( - second.get_raw_mut_ptr::() as usize - ); - object::js_object_set_field_by_name( - second.get_raw_mut_ptr(), - method_key.get_raw_mut_ptr(), - f64::from_bits(bits(b.get_raw_mut_ptr())), - ); + second.with_mut_ptr(|second_ptr| { + number_key.with_mut_ptr(|number_key_ptr| { + object::js_object_set_field_by_name(second_ptr, number_key_ptr, 7.0) + }) + }); + second.with_mut_ptr::(|second_ptr| { + shapes::test_watch_cached_transition_stamps(second_ptr as usize) + }); + second.with_mut_ptr(|second_ptr| { + method_key.with_mut_ptr(|method_key_ptr| { + object::js_object_set_field_by_name( + second_ptr, + method_key_ptr, + f64::from_bits(b.with_mut_ptr(|b_ptr| bits(b_ptr))), + ) + }) + }); assert_eq!(shapes::test_cached_transition_stamps(), 1); shapes::test_reset_cached_transition_stamps(); - assert_eq!(shapes::object_shape_stamp(second.get_raw_mut_ptr()), target); assert_eq!( - field_rep_store::object_slot_rep(second.get_raw_mut_ptr(), 0), + second.with_mut_ptr(|second_ptr| shapes::object_shape_stamp(second_ptr)), + target + ); + assert_eq!( + second.with_mut_ptr(|second_ptr| field_rep_store::object_slot_rep(second_ptr, 0)), field_rep::REP_F64 ); assert_eq!( - object::js_object_get_field(second.get_raw_mut_ptr(), 0).bits(), + second + .with_mut_ptr(|second_ptr| object::js_object_get_field(second_ptr, 0)) + .bits(), 7.0f64.to_bits() ); - let current = object::js_object_get_field(second.get_raw_mut_ptr(), 1) + let current = second + .with_mut_ptr(|second_ptr| object::js_object_get_field(second_ptr, 1)) .as_pointer::(); - assert_eq!(current, b.get_raw_mut_ptr()); + b.with_mut_ptr(|b_ptr| assert_eq!(current, b_ptr)); assert_eq!(capture_body(current, closure::JsThis::UNDEFINED), 43.0); } } diff --git a/crates/perry-runtime/src/object/shapes_worker_seed.rs b/crates/perry-runtime/src/object/shapes_worker_seed.rs index d3adfa0e56..7ec6210fd9 100644 --- a/crates/perry-runtime/src/object/shapes_worker_seed.rs +++ b/crates/perry-runtime/src/object/shapes_worker_seed.rs @@ -197,21 +197,25 @@ mod tests { ); let object = handles.root_raw_mut_ptr(object); assert_eq!( - unsafe { crate::object::shapes::object_shape_stamp(object.get_raw_mut_ptr()) }, + unsafe { + object.with_mut_ptr(|object_ptr| { + crate::object::shapes::object_shape_stamp(object_ptr) + }) + }, id, "the first outlined birth must retain the seeded id" ); - crate::object::js_object_set_field_by_name( - object.get_raw_mut_ptr(), - key.get_raw_const_ptr(), - 4.0, - ); + object.with_mut_ptr(|object_ptr| { + key.with_const_ptr(|key_ptr| { + crate::object::js_object_set_field_by_name(object_ptr, key_ptr, 4.0) + }) + }); assert_eq!( - crate::object::js_object_get_field_by_name( - object.get_raw_const_ptr(), - key.get_raw_const_ptr(), - ) - .as_number(), + object + .with_const_ptr(|object_ptr| key.with_const_ptr(|key_ptr| { + crate::object::js_object_get_field_by_name(object_ptr, key_ptr) + })) + .as_number(), 4.0 ); assert_eq!(shape_descriptor_by_id(id).expect("birth shape").rep, rep); diff --git a/crates/perry-runtime/src/object/static_shapes.rs b/crates/perry-runtime/src/object/static_shapes.rs index a45d7c0912..6e4c80f8a8 100644 --- a/crates/perry-runtime/src/object/static_shapes.rs +++ b/crates/perry-runtime/src/object/static_shapes.rs @@ -239,18 +239,18 @@ pub(crate) fn finalize_constfn_static( let scope = crate::gc::RuntimeHandleScope::new(); let root = scope.root_raw_mut_ptr(object as usize as *mut super::ObjectHeader); let Some(infos) = parse_constfn_static_entries(entries, entry_count) else { - return root.get_raw_mut_ptr::() as usize as u64; + return object; }; if packed.is_null() || packed_len == 0 || count == 0 || live < count { - return root.get_raw_mut_ptr::() as usize as u64; + return object; } // SAFETY: compiler-owned bytes for this call, containing exact key names. let packed = unsafe { std::slice::from_raw_parts(packed, packed_len as usize) }; - let obj = root.get_raw_mut_ptr::(); - let Some(current) = (unsafe { + let Some(current) = root.with_mut_ptr::(|obj| unsafe { finalized_constfn_facts(obj, packed, count, live, class_id, rep, &infos, rebuilt) }) else { - return obj as usize as u64; + // Validation only reads inline data and Rust-owned metadata; it cannot collect. + return object; }; // This mint does not canonicalize/allocate GC keys or enter JS. Its // summary/hash/slab path allocates only Rust-owned Box/Vec storage and @@ -258,16 +258,17 @@ pub(crate) fn finalize_constfn_static( // is therefore consumed without collection; the rooted object owns its // keys throughout. A collecting interner must root/reload the argument // inside the mint, not rely on the validation below. - let minted = shapes::final_shape_ensure_constfn( - current.keys as usize as *const ArrayHeader, - count, - live, - class_id, - rep, - &infos, - Some(requested).filter(|&id| id != 0), - ); - let obj = root.get_raw_mut_ptr::(); + let (minted, obj) = root.across_mut::(|| { + shapes::final_shape_ensure_constfn( + current.keys as usize as *const ArrayHeader, + count, + live, + class_id, + rep, + &infos, + Some(requested).filter(|&id| id != 0), + ) + }); // Reload and revalidate after the mint; no closure address spans it. if let Some(current) = unsafe { finalized_constfn_facts(obj, packed, count, live, class_id, rep, &infos, rebuilt) } diff --git a/crates/perry-runtime/src/object/static_shapes_tests.rs b/crates/perry-runtime/src/object/static_shapes_tests.rs index 29ce622e5a..cad492e529 100644 --- a/crates/perry-runtime/src/object/static_shapes_tests.rs +++ b/crates/perry-runtime/src/object/static_shapes_tests.rs @@ -511,36 +511,45 @@ fn constfn_finalizer_waits_for_stores_and_preserves_fresh_closures() { for capture in [11.0f64, 22.0] { let raw = alloc_constfn_plain_fixture(&[b"ltcf_final_m", b"ltcf_final_x"]); let object = scope.root_raw_mut_ptr(raw as usize as *mut crate::object::ObjectHeader); - let plain = - unsafe { object_shape_stamp(object.get_raw_mut_ptr::()) }; + let plain = unsafe { + object.with_mut_ptr::(|object_ptr| { + object_shape_stamp(object_ptr) + }) + }; assert_ne!(plain, requested, "allocation must not carry SPECIAL"); assert_eq!(shapes::shape_descriptor_by_id(plain).unwrap().rep, 0); assert_eq!(finalize(raw as usize as u64), raw as usize as u64); assert_eq!( - unsafe { object_shape_stamp(object.get_raw_mut_ptr::()) }, + unsafe { + object.with_mut_ptr::(|object_ptr| { + object_shape_stamp(object_ptr) + }) + }, plain, "unwritten method must refuse" ); let closure = crate::closure::js_closure_alloc(info, 1); let closure = scope.root_raw_mut_ptr(closure); unsafe { - crate::closure::js_closure_set_capture_bits( - closure.get_raw_mut_ptr::(), - 0, - capture.to_bits(), - ); - let obj = object.get_raw_mut_ptr::(); - crate::object::store_object_field_slot( - obj, - 0, - crate::JSValue::object_ptr( - closure.get_raw_mut_ptr::() as *mut u8, - ) - .bits(), - ); - crate::object::store_object_field_slot(obj, 1, 7.0f64.to_bits()); + closure.with_mut_ptr::(|closure_ptr| { + crate::closure::js_closure_set_capture_bits(closure_ptr, 0, capture.to_bits()) + }); + object.with_mut_ptr::(|obj| { + crate::object::store_object_field_slot( + obj, + 0, + closure + .with_mut_ptr::(|closure_ptr| { + crate::JSValue::object_ptr(closure_ptr as *mut u8) + }) + .bits(), + ); + crate::object::store_object_field_slot(obj, 1, 7.0f64.to_bits()); + }); } - let obj = finalize(object.get_raw_mut_ptr::() as usize as u64); + let obj = object.with_mut_ptr::(|object_ptr| { + finalize(object_ptr as usize as u64) + }); assert_eq!( unsafe { object_shape_stamp(obj as usize as *mut _) }, requested @@ -548,17 +557,19 @@ fn constfn_finalizer_waits_for_stores_and_preserves_fresh_closures() { objects.push(object); closures.push(closure); } - assert_ne!( - closures[0].get_raw_mut_ptr::(), - closures[1].get_raw_mut_ptr::() - ); + closures[0].with_mut_ptr::(|closures_0_ptr| { + closures[1].with_mut_ptr::(|closures_1_ptr| { + assert_ne!(closures_0_ptr, closures_1_ptr) + }) + }); for ((object, closure), capture) in objects.iter().zip(&closures).zip([11.0f64, 22.0]) { - let obj = object.get_raw_mut_ptr::(); - let slot = crate::object::js_object_get_field(obj, 0); - assert_eq!( - slot.bits() & crate::value::POINTER_MASK, - closure.get_raw_mut_ptr::() as usize as u64 - ); + let slot = object.with_mut_ptr(|obj| crate::object::js_object_get_field(obj, 0)); + closure.with_mut_ptr::(|closure_ptr| { + assert_eq!( + slot.bits() & crate::value::POINTER_MASK, + closure_ptr as usize as u64 + ) + }); assert_eq!( crate::closure::js_closure_get_capture_bits( (slot.bits() & crate::value::POINTER_MASK) as usize as *const _, @@ -581,27 +592,34 @@ fn constfn_finalizer_refuses_wrong_body_layout_and_rebindable_this() { // Establish that these exact birth facts promote with the supported body. // Otherwise every refusal below could be an unrelated kind/layout miss. let control = scope.root_raw_mut_ptr(alloc_constfn_plain_fixture(&[b"ltcf_refuse_m"])); - let birth = unsafe { shapes::object_shape_stamp(control.get_raw_mut_ptr()) }; + let birth = + unsafe { control.with_mut_ptr(|control_ptr| shapes::object_shape_stamp(control_ptr)) }; let closure = scope.root_raw_mut_ptr(crate::closure::js_closure_alloc(info, 0)); unsafe { - crate::object::store_object_field_slot( - control.get_raw_mut_ptr(), - 0, - crate::JSValue::object_ptr(closure.get_raw_mut_ptr::()).bits(), - ); + control.with_mut_ptr(|control_ptr| { + crate::object::store_object_field_slot( + control_ptr, + 0, + closure + .with_mut_ptr::(|closure_ptr| crate::JSValue::object_ptr(closure_ptr)) + .bits(), + ) + }); } - let promoted = js_object_finalize_constfn_static( - control.get_raw_mut_ptr::() as usize as u64, - SHAPE_ID_BASE + 0x7871, - packed.as_ptr(), - packed.len() as u32, - 1, - 1, - 0, - 3, - entries.as_ptr(), - 1, - ); + let promoted = control.with_mut_ptr::(|control_ptr| { + js_object_finalize_constfn_static( + control_ptr as usize as u64, + SHAPE_ID_BASE + 0x7871, + packed.as_ptr(), + packed.len() as u32, + 1, + 1, + 0, + 3, + entries.as_ptr(), + 1, + ) + }); assert_eq!( unsafe { shapes::object_shape_stamp(promoted as usize as *mut _) }, SHAPE_ID_BASE + 0x7871, @@ -628,31 +646,37 @@ fn constfn_finalizer_refuses_wrong_body_layout_and_rebindable_this() { let obj = scope.root_raw_mut_ptr(raw as usize as *mut crate::object::ObjectHeader); let closure = crate::closure::js_closure_alloc(body, caps); unsafe { - crate::object::store_object_field_slot( - obj.get_raw_mut_ptr::(), - 0, - crate::JSValue::object_ptr(closure as *mut u8).bits(), - ); + obj.with_mut_ptr::(|obj_ptr| { + crate::object::store_object_field_slot( + obj_ptr, + 0, + crate::JSValue::object_ptr(closure as *mut u8).bits(), + ) + }); } let before = unsafe { - shapes::object_shape_stamp(obj.get_raw_mut_ptr::()) + obj.with_mut_ptr::(|obj_ptr| { + shapes::object_shape_stamp(obj_ptr) + }) }; assert_eq!( before, birth, "{case}: refusal must begin with the admitted control birth" ); - let raw = js_object_finalize_constfn_static( - obj.get_raw_mut_ptr::() as usize as u64, - SHAPE_ID_BASE + 0x7871, - packed.as_ptr(), - packed.len() as u32, - count, - live, - class_id, - rep, - entries.as_ptr(), - 1, - ); + let raw = obj.with_mut_ptr::(|obj_ptr| { + js_object_finalize_constfn_static( + obj_ptr as usize as u64, + SHAPE_ID_BASE + 0x7871, + packed.as_ptr(), + packed.len() as u32, + count, + live, + class_id, + rep, + entries.as_ptr(), + 1, + ) + }); assert_eq!( unsafe { shapes::object_shape_stamp(raw as usize as *mut _) }, before, @@ -682,89 +706,97 @@ fn declared_class_final_mint_keeps_birth_ordinary_and_uses_each_current_closure( let keys = crate::object::js_build_class_keys_array(cid, 2, packed.as_ptr(), packed.len() as u32, 0); let keys = scope.root_raw_mut_ptr(keys as usize as *mut crate::array::ArrayHeader); - let ordinary = js_object_shape_id_for_class_keys_static( - keys.get_raw_mut_ptr::() as usize as u64, - 2, - 2, - cid, - SHAPE_ID_BASE + 0x7880, - 0, - ); - let final_id = js_object_final_shape_id_for_class_keys_static_constfn( - keys.get_raw_mut_ptr::() as usize as u64, - 2, - 2, - cid, - SHAPE_ID_BASE + 0x7881, - 3, - entries.as_ptr(), - 1, - ); + let ordinary = keys.with_mut_ptr::(|keys_ptr| { + js_object_shape_id_for_class_keys_static( + keys_ptr as usize as u64, + 2, + 2, + cid, + SHAPE_ID_BASE + 0x7880, + 0, + ) + }); + let final_id = keys.with_mut_ptr::(|keys_ptr| { + js_object_final_shape_id_for_class_keys_static_constfn( + keys_ptr as usize as u64, + 2, + 2, + cid, + SHAPE_ID_BASE + 0x7881, + 3, + entries.as_ptr(), + 1, + ) + }); assert_ne!(ordinary, final_id); assert_eq!(shapes::shape_descriptor_by_id(ordinary).unwrap().rep, 0); assert_eq!(shapes::shape_descriptor_by_id(final_id).unwrap().rep, 3); let mut closure_roots = Vec::new(); for capture in [31.0f64, 47.0] { - let obj = crate::object::js_object_alloc_class_inline_keys_stamped( - cid, - 0, - 2, - keys.get_raw_mut_ptr::(), - ordinary, - 0, - ); + let obj = keys.with_mut_ptr::(|keys_ptr| { + crate::object::js_object_alloc_class_inline_keys_stamped( + cid, 0, 2, keys_ptr, ordinary, 0, + ) + }); let object = scope.root_raw_mut_ptr(obj); assert_eq!( - unsafe { shapes::object_shape_stamp(object.get_raw_mut_ptr()) }, + unsafe { object.with_mut_ptr(|object_ptr| shapes::object_shape_stamp(object_ptr)) }, ordinary ); let closure = scope.root_raw_mut_ptr(crate::closure::js_closure_alloc(info, 1)); unsafe { - crate::closure::js_closure_set_capture_bits( - closure.get_raw_mut_ptr(), - 0, - capture.to_bits(), - ); - let object = object.get_raw_mut_ptr::(); - crate::object::store_object_field_slot( - object, - 0, - crate::JSValue::object_ptr(closure.get_raw_mut_ptr::()).bits(), - ); - crate::object::store_object_field_slot(object, 1, capture.to_bits()); + closure.with_mut_ptr(|closure_ptr| { + crate::closure::js_closure_set_capture_bits(closure_ptr, 0, capture.to_bits()) + }); + object.with_mut_ptr::(|object| { + crate::object::store_object_field_slot( + object, + 0, + closure + .with_mut_ptr::(|closure_ptr| { + crate::JSValue::object_ptr(closure_ptr) + }) + .bits(), + ); + crate::object::store_object_field_slot(object, 1, capture.to_bits()); + }); } let premise = shapes::shape_descriptor_by_id(ordinary).unwrap(); assert_eq!(premise.object_kind, shapes::ShapeObjectKind::Ordinary); assert_eq!(premise.proto_id, shapes::class_proto_id(cid)); - let wrong_proto = js_object_finalize_constfn_static( - object.get_raw_mut_ptr::() as usize as u64, - final_id, - packed.as_ptr(), - packed.len() as u32, - 2, - 2, - cid + 1, - 3, - entries.as_ptr(), - 1, - ); + let wrong_proto = object.with_mut_ptr::(|object_ptr| { + js_object_finalize_constfn_static( + object_ptr as usize as u64, + final_id, + packed.as_ptr(), + packed.len() as u32, + 2, + 2, + cid + 1, + 3, + entries.as_ptr(), + 1, + ) + }); assert_eq!( unsafe { shapes::object_shape_stamp(wrong_proto as usize as *mut _) }, ordinary, "a different class prototype must refuse without stamping" ); - let obj = js_object_finalize_constfn_static( - object.get_raw_mut_ptr::() as usize as u64, - final_id, - packed.as_ptr(), - packed.len() as u32, - 2, - 2, - cid, - 3, - entries.as_ptr(), - 1, - ); + let obj = object.with_mut_ptr::(|object_ptr| { + js_object_finalize_constfn_static( + object_ptr as usize as u64, + final_id, + packed.as_ptr(), + packed.len() as u32, + 2, + 2, + cid, + 3, + entries.as_ptr(), + 1, + ) + }); assert_eq!( unsafe { shapes::object_shape_stamp(obj as usize as *mut _) }, final_id @@ -779,8 +811,9 @@ fn declared_class_final_mint_keeps_birth_ordinary_and_uses_each_current_closure( ); closure_roots.push(closure); } - assert_ne!( - closure_roots[0].get_raw_mut_ptr::(), - closure_roots[1].get_raw_mut_ptr::() - ); + closure_roots[0].with_mut_ptr::(|closure_roots_0_ptr| { + closure_roots[1].with_mut_ptr::(|closure_roots_1_ptr| { + assert_ne!(closure_roots_0_ptr, closure_roots_1_ptr) + }) + }); } diff --git a/crates/perry-runtime/src/thread_constfn_transfer_tests.rs b/crates/perry-runtime/src/thread_constfn_transfer_tests.rs index 242e8a8963..23d498607a 100644 --- a/crates/perry-runtime/src/thread_constfn_transfer_tests.rs +++ b/crates/perry-runtime/src/thread_constfn_transfer_tests.rs @@ -50,15 +50,15 @@ unsafe fn wire() -> SerializedValue { let keys = scope.root_raw_mut_ptr(keys.arr() as *mut crate::array::ArrayHeader); let mut objects = Vec::new(); for n in [17.0f64, 29.0] { - let object = crate::object::alloc_plain::alloc_plain_record_inline_keys_stamped( - 2, - keys.get_raw_mut_ptr(), - BASE, - ); + let object = keys.with_mut_ptr(|keys_ptr| { + crate::object::alloc_plain::alloc_plain_record_inline_keys_stamped(2, keys_ptr, BASE) + }); let object = scope.root_raw_mut_ptr(object); - let birth = shapes::object_shape_descriptor(object.get_raw_mut_ptr()).unwrap(); + let birth = object + .with_mut_ptr(|object_ptr| shapes::object_shape_descriptor(object_ptr)) + .unwrap(); assert_eq!( - shapes::object_shape_stamp(object.get_raw_mut_ptr()), + object.with_mut_ptr(|object_ptr| shapes::object_shape_stamp(object_ptr)), BASE, "plain birth must use the installed carrier" ); @@ -69,29 +69,36 @@ unsafe fn wire() -> SerializedValue { "allocation must stay Any/F64" ); let c = scope.root_raw_mut_ptr(closure::js_closure_alloc(info(), 1)); - closure::js_closure_set_capture_bits(c.get_raw_mut_ptr(), 0, n.to_bits()); - crate::object::store_object_field_slot( - object.get_raw_mut_ptr(), - 0, - JSValue::object_ptr(c.get_raw_mut_ptr::()).bits(), - ); - crate::object::store_object_field_slot(object.get_raw_mut_ptr(), 1, n.to_bits()); + c.with_mut_ptr(|c_ptr| closure::js_closure_set_capture_bits(c_ptr, 0, n.to_bits())); + object.with_mut_ptr(|object_ptr| { + crate::object::store_object_field_slot( + object_ptr, + 0, + c.with_mut_ptr::(|c_ptr| JSValue::object_ptr(c_ptr)) + .bits(), + ) + }); + object.with_mut_ptr(|object_ptr| { + crate::object::store_object_field_slot(object_ptr, 1, n.to_bits()) + }); let entries = [static_shapes::ConstFnStaticEntry { slot: 0, info: info(), }]; - let obj = static_shapes::js_object_finalize_constfn_static( - object.get_raw_mut_ptr::() as usize as u64, - FINAL, - PACKED.as_ptr(), - PACKED.len() as u32, - 2, - 2, - 0, - REP, - entries.as_ptr(), - 1, - ) as usize as *mut crate::object::ObjectHeader; + let obj = object.with_mut_ptr::(|object_ptr| { + static_shapes::js_object_finalize_constfn_static( + object_ptr as usize as u64, + FINAL, + PACKED.as_ptr(), + PACKED.len() as u32, + 2, + 2, + 0, + REP, + entries.as_ptr(), + 1, + ) + }) as usize as *mut crate::object::ObjectHeader; assert_eq!( shapes::object_shape_stamp(obj), FINAL, @@ -131,7 +138,11 @@ fn replay(seed_first: bool, production_seed: bool) { let mut cells = Vec::new(); for (slot, expected) in [17.0, 29.0].into_iter().enumerate() { let object = JSValue::from_bits( - crate::array::js_array_get_f64(array.get_raw_mut_ptr(), slot as u32).to_bits(), + array + .with_mut_ptr(|array_ptr| { + crate::array::js_array_get_f64(array_ptr, slot as u32) + }) + .to_bits(), ) .as_pointer::(); let id = shapes::object_shape_stamp(object); @@ -249,7 +260,9 @@ fn constfn_transfer_final_slots_rewrite_the_actual_closures_on_moving_gc() { (0..2) .map(|i| { let obj = JSValue::from_bits( - crate::array::js_array_get_f64(array.get_raw_mut_ptr(), i).to_bits(), + array + .with_mut_ptr(|array_ptr| crate::array::js_array_get_f64(array_ptr, i)) + .to_bits(), ) .as_pointer::(); let method = crate::object::js_object_get_field(obj as *mut _, 0).bits(); @@ -291,10 +304,12 @@ fn constfn_transfer_does_not_resurrect_a_deprecated_final_body_fact() { JSValue::from_bits(bits).as_pointer::() as *mut crate::array::ArrayHeader, ); - let obj = - JSValue::from_bits(crate::array::js_array_get_f64(arr.get_raw_mut_ptr(), 0).to_bits()) - .as_pointer::() - as *mut crate::object::ObjectHeader; + let obj = JSValue::from_bits( + arr.with_mut_ptr(|arr_ptr| crate::array::js_array_get_f64(arr_ptr, 0)) + .to_bits(), + ) + .as_pointer::() + as *mut crate::object::ObjectHeader; assert_eq!(shapes::object_shape_stamp(obj), FINAL); crate::object::store_object_field_slot(obj, 0, TAG_TRUE); assert_eq!( @@ -389,72 +404,86 @@ fn constfn_worker_seed_owns_names_across_source_key_relocation() { unsafe { let keys = scope.root_raw_mut_ptr(crate::array::js_array_alloc_key_list(2, true)); let long = crate::string::js_string_from_bytes(LONG.as_ptr(), LONG.len() as u32); - store_thread_array_slot(keys.get_raw_mut_ptr(), 0, JSValue::string_ptr(long).bits()); - store_thread_array_slot( - keys.get_raw_mut_ptr(), - 1, - JSValue::try_short_string(SHORT) - .expect("short-key premise") - .bits(), - ); + keys.with_mut_ptr(|keys_ptr| { + store_thread_array_slot(keys_ptr, 0, JSValue::string_ptr(long).bits()) + }); + keys.with_mut_ptr(|keys_ptr| { + store_thread_array_slot( + keys_ptr, + 1, + JSValue::try_short_string(SHORT) + .expect("short-key premise") + .bits(), + ) + }); assert_eq!( - static_shapes::js_object_shape_id_for_class_keys_static( - keys.get_raw_mut_ptr::() as usize as u64, - 2, - 2, - CLASS, - BASE_ID, - NUMBER_REP, - ), + keys.with_mut_ptr::(|keys_ptr| { + static_shapes::js_object_shape_id_for_class_keys_static( + keys_ptr as usize as u64, + 2, + 2, + CLASS, + BASE_ID, + NUMBER_REP, + ) + }), BASE_ID ); - let object = - scope.root_raw_mut_ptr(crate::object::js_object_alloc_class_inline_keys_stamped( - CLASS, - 0, - 2, - keys.get_raw_mut_ptr(), - BASE_ID, - NUMBER_REP, - )); + let object = scope.root_raw_mut_ptr(keys.with_mut_ptr(|keys_ptr| { + crate::object::js_object_alloc_class_inline_keys_stamped( + CLASS, 0, 2, keys_ptr, BASE_ID, NUMBER_REP, + ) + })); let closure = scope.root_raw_mut_ptr(closure::js_closure_alloc(info(), 1)); - closure::js_closure_set_capture_bits(closure.get_raw_mut_ptr(), 0, 37.0f64.to_bits()); - crate::object::store_object_field_slot( - object.get_raw_mut_ptr(), - 0, - JSValue::object_ptr(closure.get_raw_mut_ptr::()).bits(), - ); - crate::object::store_object_field_slot(object.get_raw_mut_ptr(), 1, 29.0f64.to_bits()); + closure.with_mut_ptr(|closure_ptr| { + closure::js_closure_set_capture_bits(closure_ptr, 0, 37.0f64.to_bits()) + }); + object.with_mut_ptr(|object_ptr| { + crate::object::store_object_field_slot( + object_ptr, + 0, + closure + .with_mut_ptr::(|closure_ptr| JSValue::object_ptr(closure_ptr)) + .bits(), + ) + }); + object.with_mut_ptr(|object_ptr| { + crate::object::store_object_field_slot(object_ptr, 1, 29.0f64.to_bits()) + }); let entries = [static_shapes::ConstFnStaticEntry { slot: 0, info: info(), }]; assert_eq!( - static_shapes::js_object_final_shape_id_for_class_keys_static_constfn( - keys.get_raw_mut_ptr::() as usize as u64, + keys.with_mut_ptr::(|keys_ptr| { + static_shapes::js_object_final_shape_id_for_class_keys_static_constfn( + keys_ptr as usize as u64, + 2, + 2, + CLASS, + FINAL_ID, + REP, + entries.as_ptr(), + 1, + ) + }), + FINAL_ID, + "production mint must publish the real external final carrier" + ); + let finalized = object.with_mut_ptr::(|object_ptr| { + static_shapes::js_object_finalize_constfn_static( + object_ptr as usize as u64, + FINAL_ID, + NAMES.as_ptr(), + NAMES.len() as u32, 2, 2, CLASS, - FINAL_ID, REP, entries.as_ptr(), 1, - ), - FINAL_ID, - "production mint must publish the real external final carrier" - ); - let finalized = static_shapes::js_object_finalize_constfn_static( - object.get_raw_mut_ptr::() as usize as u64, - FINAL_ID, - NAMES.as_ptr(), - NAMES.len() as u32, - 2, - 2, - CLASS, - REP, - entries.as_ptr(), - 1, - ) as usize as *const crate::object::ObjectHeader; + ) + }) as usize as *const crate::object::ObjectHeader; assert_eq!(shapes::object_shape_stamp(finalized), FINAL_ID); let before = shapes::shape_descriptor_by_id(FINAL_ID).unwrap(); let before_heap_key = heap_key_at(before.keys); @@ -499,10 +528,9 @@ fn constfn_worker_seed_owns_names_across_source_key_relocation() { before_heap_key, "heap key bytes did not actually relocate" ); - assert_eq!( - after.keys, - keys.get_raw_mut_ptr::() as usize as u64 - ); + keys.with_mut_ptr::(|keys_ptr| { + assert_eq!(after.keys, keys_ptr as usize as u64) + }); assert_eq!(names_at(after.keys), vec![LONG.to_vec(), SHORT.to_vec()]); assert_eq!( shapes::final_shape_ensure_constfn( @@ -629,35 +657,45 @@ fn constfn_transfer_compiled_anon_header_preserves_actual_worker_wire() { let keys = scope.root_raw_mut_ptr(keys); let mut objects = Vec::new(); for number in [17.0f64, 29.0] { - let object = - scope.root_raw_mut_ptr(crate::object::js_object_alloc_class_inline_keys_stamped( + let object = scope.root_raw_mut_ptr(keys.with_mut_ptr(|keys_ptr| { + crate::object::js_object_alloc_class_inline_keys_stamped( ANON, 0, 2, - keys.get_raw_mut_ptr(), + keys_ptr, ANON_BASE, field_rep::REP_F64, - )); + ) + })); let cell = scope.root_raw_mut_ptr(closure::js_closure_alloc(arrow_info(), 1)); - closure::js_closure_set_capture_bits(cell.get_raw_mut_ptr(), 0, number.to_bits()); - crate::object::store_object_field_slot(object.get_raw_mut_ptr(), 0, number.to_bits()); - crate::object::store_object_field_slot( - object.get_raw_mut_ptr(), - 1, - JSValue::pointer(cell.get_raw_mut_ptr::()).bits(), - ); - let object = static_shapes::js_object_finalize_constfn_static( - object.get_raw_mut_ptr::() as usize as u64, - ANON_FINAL, - NAMES.as_ptr(), - NAMES.len() as u32, - 2, - 2, - 0, - ANON_REP, - entries.as_ptr(), - 1, - ) as usize as *mut crate::object::ObjectHeader; + cell.with_mut_ptr(|cell_ptr| { + closure::js_closure_set_capture_bits(cell_ptr, 0, number.to_bits()) + }); + object.with_mut_ptr(|object_ptr| { + crate::object::store_object_field_slot(object_ptr, 0, number.to_bits()) + }); + object.with_mut_ptr(|object_ptr| { + crate::object::store_object_field_slot( + object_ptr, + 1, + cell.with_mut_ptr::(|cell_ptr| JSValue::pointer(cell_ptr)) + .bits(), + ) + }); + let object = object.with_mut_ptr::(|object_ptr| { + static_shapes::js_object_finalize_constfn_static( + object_ptr as usize as u64, + ANON_FINAL, + NAMES.as_ptr(), + NAMES.len() as u32, + 2, + 2, + 0, + ANON_REP, + entries.as_ptr(), + 1, + ) + }) as usize as *mut crate::object::ObjectHeader; assert_eq!( (*object).class_id, ANON, @@ -693,30 +731,31 @@ fn constfn_transfer_compiled_anon_header_preserves_actual_worker_wire() { JSValue::from_bits(bits).as_pointer::() as *mut crate::object::ObjectHeader, ); - let object = object.get_raw_mut_ptr::(); - assert_eq!((*object).class_id, ANON); - assert_eq!( - shapes::object_shape_stamp(object), - ANON_FINAL, - "worker must republish actual final facts after validating current slots" - ); - let d = shapes::object_shape_descriptor(object).unwrap(); - assert_eq!(d.rep, ANON_REP); - assert_eq!(d.special_constfn_mask, 2); - assert_eq!( - d.constfn_infos(), - &[shapes::ConstFnSlotInfo { - slot: 1, - info: arrow_info() as usize as u64, - }] - ); - let fields = (object as *const u8) - .add(std::mem::size_of::()) - as *const u64; - assert_eq!(f64::from_bits(*fields), expected); - let cell = JSValue::from_bits(*fields.add(1)).as_pointer::(); - assert_eq!(capture_body(cell, closure::JsThis::UNDEFINED), expected); - cells.push(cell as usize); + object.with_mut_ptr::(|object| { + assert_eq!((*object).class_id, ANON); + assert_eq!( + shapes::object_shape_stamp(object), + ANON_FINAL, + "worker must republish actual final facts after validating current slots" + ); + let d = shapes::object_shape_descriptor(object).unwrap(); + assert_eq!(d.rep, ANON_REP); + assert_eq!(d.special_constfn_mask, 2); + assert_eq!( + d.constfn_infos(), + &[shapes::ConstFnSlotInfo { + slot: 1, + info: arrow_info() as usize as u64, + }] + ); + let fields = (object as *const u8) + .add(std::mem::size_of::()) + as *const u64; + assert_eq!(f64::from_bits(*fields), expected); + let cell = JSValue::from_bits(*fields.add(1)).as_pointer::(); + assert_eq!(capture_body(cell, closure::JsThis::UNDEFINED), expected); + cells.push(cell as usize); + }); } assert_ne!(cells[0], cells[1], "same body keeps distinct captures"); let mut contradiction = objects.remove(0); diff --git a/crates/perry-runtime/src/thread_literal_launch_tests.rs b/crates/perry-runtime/src/thread_literal_launch_tests.rs index 4d1ecf8905..4f0ea375ae 100644 --- a/crates/perry-runtime/src/thread_literal_launch_tests.rs +++ b/crates/perry-runtime/src/thread_literal_launch_tests.rs @@ -2,66 +2,136 @@ //! Run serially (RUST_TEST_THREADS=1), like the rest of perry-runtime's tests. //! Map/filter require at least two available CPUs; a caller fallback cannot pass. use super::*; -use std::cell::Cell; -use std::sync::atomic::{AtomicBool, AtomicU64}; +use std::cell::{Cell, RefCell}; +use std::sync::Arc; use std::time::{Duration, Instant}; -// Primitive observations only: no heap pointers or runtime registry. +// The no-argument preparation ABI cannot receive test state. Keep its evidence +// on the executing OS thread; the body transfers it to its captured owner. crate::perry_thread_local! { static IS_LAUNCHER: Cell = const { Cell::new(false) }; static PREPARED_AGENT: Cell> = const { Cell::new(None) }; static BODY_STARTED: Cell = const { Cell::new(false) }; + static PREPARE_COUNT: Cell = const { Cell::new(0) }; + static PREPARE_VIOLATIONS: Cell = const { Cell::new(0) }; + // Attached by the explicit body capture, retained until worker thread exit. + // It contains Rust-owned observations only, never a runtime heap pointer. + static WORKER_OBSERVATIONS: RefCell>> = const { RefCell::new(None) }; } -static LAUNCHER_AGENT: AtomicU64 = AtomicU64::new(0); -static EXPECT_PREPARED: AtomicBool = AtomicBool::new(false); -static PREPARE_CALLS: AtomicUsize = AtomicUsize::new(0); -static BODY_CALLS: AtomicUsize = AtomicUsize::new(0); -static VIOLATIONS: AtomicUsize = AtomicUsize::new(0); -fn note_worker() { - // The launcher's marker is thread-local: seeing it means the same OS thread. - if IS_LAUNCHER.with(Cell::get) { - VIOLATIONS.fetch_or(1, Ordering::SeqCst); - } - let agent = crate::agent::current_agent(); - if agent == crate::agent::PRIMARY_AGENT || agent == LAUNCHER_AGENT.load(Ordering::SeqCst) { - VIOLATIONS.fetch_or(2, Ordering::SeqCst); - } +struct Observations { + launcher_agent: u64, + expect_prepared: bool, + prepare_calls: AtomicUsize, + body_calls: AtomicUsize, + violations: AtomicUsize, +} + +fn worker_violations() -> usize { + // A launcher TLS marker proves this is the same OS thread, independently + // of whether a broken launch path happened to install another agent id. + usize::from(IS_LAUNCHER.with(Cell::get)) + | if crate::agent::current_agent() == crate::agent::PRIMARY_AGENT { + 2 + } else { + 0 + } } extern "C" fn prepare() { // Never assert/panic through an extern-C callback; report on the test thread. - note_worker(); + let mut violations = worker_violations(); if PREPARED_AGENT.with(Cell::get).is_some() { - VIOLATIONS.fetch_or(4, Ordering::SeqCst); + violations |= 4; } if BODY_STARTED.with(Cell::get) { - VIOLATIONS.fetch_or(8, Ordering::SeqCst); + violations |= 8; } + PREPARE_VIOLATIONS.with(|slot| slot.set(slot.get() | violations)); PREPARED_AGENT.with(|slot| slot.set(Some(crate::agent::current_agent()))); - PREPARE_CALLS.fetch_add(1, Ordering::SeqCst); + WORKER_OBSERVATIONS.with(|owner| { + if let Some(observations) = owner.borrow().as_ref() { + // A callback after a body must remain observable, including in the + // legacy OFF arm. Its owner was explicitly transferred by that body. + if crate::agent::current_agent() == observations.launcher_agent { + violations |= 2; + } + if !observations.expect_prepared { + violations |= 32; + } + observations.prepare_calls.fetch_add(1, Ordering::SeqCst); + observations + .violations + .fetch_or(violations, Ordering::SeqCst); + } else { + PREPARE_COUNT.with(|slot| slot.set(slot.get() + 1)); + } + }); +} + +// One escaped Arc reference keeps the observations alive for the entire launch, +// independent of how many times a broken runtime invokes the body. The caller +// reclaims it only after successful completion; timeout/assertion failures leak +// that reference so an outstanding worker can never observe freed state. +fn capture_observations( + closure: *mut ClosureHeader, + observations: &Arc, +) -> *const Observations { + let lease = Arc::into_raw(Arc::clone(observations)); + let address = lease as usize as u64; + // Two exact numeric u32 captures survive the real serializer. This address + // names Rust-owned atomics, never a GC object or a process-global lookup. + closure::js_closure_set_capture_f64(closure, 0, 7.0); + closure::js_closure_set_capture_f64(closure, 1, (address as u32) as f64); + closure::js_closure_set_capture_f64(closure, 2, (address >> 32) as f64); + lease } fn observe_body(closure: *const ClosureHeader) -> f64 { - note_worker(); + if closure.is_null() { + // No owner can be recovered; the expected body count will fail. + return 0.0; + } + let low = f64::from_bits(closure::js_closure_get_capture_bits(closure, 1)) as u32; + let high = f64::from_bits(closure::js_closure_get_capture_bits(closure, 2)) as u32; + let address = u64::from(low) | (u64::from(high) << 32); + // SAFETY: the escaped launch reference remains alive until completion. + // Bodies only borrow it, so even duplicate calls cannot consume its owner. + let observations_ptr = address as usize as *const Observations; + let observations = unsafe { &*observations_ptr }; + WORKER_OBSERVATIONS.with(|owner| { + // SAFETY: clone the live escaped reference without consuming it. The + // worker's owned clone also keeps any later prepare callback safe. + let owned = unsafe { + Arc::increment_strong_count(observations_ptr); + Arc::from_raw(observations_ptr) + }; + *owner.borrow_mut() = Some(owned); + }); + let mut violations = worker_violations() | PREPARE_VIOLATIONS.with(Cell::get); + if crate::agent::current_agent() == observations.launcher_agent { + violations |= 2; + } BODY_STARTED.with(|slot| slot.set(true)); let prepared = PREPARED_AGENT.with(Cell::get); - if EXPECT_PREPARED.load(Ordering::SeqCst) { + if observations.expect_prepared { if prepared != Some(crate::agent::current_agent()) { - VIOLATIONS.fetch_or(16, Ordering::SeqCst); + violations |= 16; } } else if prepared.is_some() { - VIOLATIONS.fetch_or(32, Ordering::SeqCst); - } - BODY_CALLS.fetch_add(1, Ordering::SeqCst); - if closure.is_null() { - VIOLATIONS.fetch_or(64, Ordering::SeqCst); - return 0.0; + violations |= 32; } + observations + .prepare_calls + .fetch_add(PREPARE_COUNT.with(|slot| slot.replace(0)), Ordering::SeqCst); + observations.body_calls.fetch_add(1, Ordering::SeqCst); let capture = f64::from_bits(closure::js_closure_get_capture_bits(closure, 0)); if capture != 7.0 { - VIOLATIONS.fetch_or(64, Ordering::SeqCst); + violations |= 64; } + observations + .violations + .fetch_or(violations, Ordering::SeqCst); capture } @@ -86,32 +156,41 @@ extern "C" fn filter_body( }) } -fn begin(with_literals: bool) { +fn begin(with_literals: bool) -> Arc { crate::gc::ensure_gc_initialized(); IS_LAUNCHER.with(|slot| slot.set(true)); PREPARED_AGENT.with(|slot| slot.set(None)); BODY_STARTED.with(|slot| slot.set(false)); - LAUNCHER_AGENT.store(crate::agent::current_agent(), Ordering::SeqCst); - EXPECT_PREPARED.store(with_literals, Ordering::SeqCst); - PREPARE_CALLS.store(0, Ordering::SeqCst); - BODY_CALLS.store(0, Ordering::SeqCst); - VIOLATIONS.store(0, Ordering::SeqCst); + PREPARE_COUNT.with(|slot| slot.set(0)); + PREPARE_VIOLATIONS.with(|slot| slot.set(0)); + WORKER_OBSERVATIONS.with(|owner| *owner.borrow_mut() = None); + Arc::new(Observations { + launcher_agent: crate::agent::current_agent(), + expect_prepared: with_literals, + prepare_calls: AtomicUsize::new(0), + body_calls: AtomicUsize::new(0), + violations: AtomicUsize::new(0), + }) } -fn finish(with_literals: bool, workers: usize) { +fn finish(observations: &Observations, workers: usize) { assert_eq!( - VIOLATIONS.load(Ordering::SeqCst), + observations.violations.load(Ordering::SeqCst), 0, "worker/order violation bitmask" ); assert_eq!( - BODY_CALLS.load(Ordering::SeqCst), + observations.body_calls.load(Ordering::SeqCst), workers, "body must actually run" ); assert_eq!( - PREPARE_CALLS.load(Ordering::SeqCst), - if with_literals { workers } else { 0 }, + observations.prepare_calls.load(Ordering::SeqCst), + if observations.expect_prepared { + workers + } else { + 0 + }, "one preparation per worker; legacy wrappers supply zero callbacks" ); assert_eq!( @@ -119,41 +198,54 @@ fn finish(with_literals: bool, workers: usize) { None, "caller must not prepare" ); + assert_eq!(PREPARE_COUNT.with(Cell::get), 0, "caller must not prepare"); + assert_eq!( + PREPARE_VIOLATIONS.with(Cell::get), + 0, + "caller preparation violation" + ); + assert!(!BODY_STARTED.with(Cell::get), "caller must not run a body"); IS_LAUNCHER.with(|slot| slot.set(false)); } fn run_spawn(with_literals: bool) { let _lock = crate::gc::global_side_table_test_lock(); - begin(with_literals); + let observations = begin(with_literals); let scope = crate::gc::RuntimeHandleScope::new(); let closure = scope.root_raw_mut_ptr(closure::js_closure_alloc( crate::fn_info!(spawn_body, 0; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)), - 1, + 3, )); - closure::js_closure_set_capture_f64(closure.get_raw_mut_ptr(), 0, 7.0); - let boxed = crate::value::js_nanbox_pointer(closure.get_raw_mut_ptr::() as i64); - let result = if with_literals { - js_thread_spawn_with_literals(boxed, prepare as *const () as usize as i64) - } else { - js_thread_spawn(boxed) - }; + let lease = closure.with_mut_ptr(|c| capture_observations(c, &observations)); + let result = closure.with_mut_ptr::(|closure| { + let boxed = crate::value::js_nanbox_pointer(closure as i64); + if with_literals { + js_thread_spawn_with_literals(boxed, prepare as *const () as usize as i64) + } else { + js_thread_spawn(boxed) + } + }); let promise = scope.root_raw_mut_ptr((result.to_bits() & POINTER_MASK) as *mut crate::promise::Promise); let deadline = Instant::now() + Duration::from_secs(5); - while crate::promise::js_promise_state(promise.get_raw_mut_ptr()) == 0 { + while promise.with_mut_ptr(|promise_ptr| crate::promise::js_promise_state(promise_ptr)) == 0 { js_thread_process_pending(); assert!(Instant::now() < deadline, "worker promise did not settle"); std::thread::sleep(Duration::from_millis(1)); } assert_eq!( - crate::promise::js_promise_state(promise.get_raw_mut_ptr()), + promise.with_mut_ptr(|promise_ptr| crate::promise::js_promise_state(promise_ptr)), 1 ); assert_eq!( - crate::promise::js_promise_value(promise.get_raw_mut_ptr()), + promise.with_mut_ptr(|promise_ptr| crate::promise::js_promise_value(promise_ptr)), 19.0 ); - finish(with_literals, 1); + finish(&observations, 1); + // SAFETY: spawn queues its result only after the body returns. A settled + // promise and the checks above establish that observation use has ended; + // the remaining worker teardown does not access closure captures. + unsafe { drop(Arc::from_raw(lease)) }; } fn run_parallel(with_literals: bool, filter: bool) { @@ -165,45 +257,51 @@ fn run_parallel(with_literals: bool, filter: bool) { >= 2, "requires two available CPUs to exercise actual map/filter OS workers" ); - begin(with_literals); + let observations = begin(with_literals); let scope = crate::gc::RuntimeHandleScope::new(); let array = scope.root_raw_mut_ptr(crate::array::js_array_alloc_with_length(2)); - crate::array::js_array_set_f64(array.get_raw_mut_ptr(), 0, 8.0); - crate::array::js_array_set_f64(array.get_raw_mut_ptr(), 1, 9.0); + array.with_mut_ptr(|array_ptr| crate::array::js_array_set_f64(array_ptr, 0, 8.0)); + array.with_mut_ptr(|array_ptr| crate::array::js_array_set_f64(array_ptr, 1, 9.0)); let info = if filter { crate::fn_info!(filter_body, 1; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)) } else { crate::fn_info!(map_body, 1; with_flags(crate::codegen_abi::FN_PERMANENT_IMAGE)) }; - let closure = scope.root_raw_mut_ptr(closure::js_closure_alloc(info, 1)); - closure::js_closure_set_capture_f64(closure.get_raw_mut_ptr(), 0, 7.0); - let array = crate::value::js_nanbox_pointer( - array.get_raw_mut_ptr::() as i64, - ); - let closure = - crate::value::js_nanbox_pointer(closure.get_raw_mut_ptr::() as i64); + let closure = scope.root_raw_mut_ptr(closure::js_closure_alloc(info, 3)); + let lease = closure.with_mut_ptr(|c| capture_observations(c, &observations)); + // Launch roots the closure before resolving the array and serializing its + // inputs. Keep raw arguments scoped to the entry point; inspect its rooted result. let callback = prepare as *const () as usize as i64; - let result = match (with_literals, filter) { - (true, false) => js_thread_parallel_map_with_literals(array, closure, callback), - (true, true) => js_thread_parallel_filter_with_literals(array, closure, callback), - (false, false) => js_thread_parallel_map(array, closure), - (false, true) => js_thread_parallel_filter(array, closure), - }; + let result = array.with_mut_ptr::(|array| { + closure.with_mut_ptr::(|closure| { + let array = crate::value::js_nanbox_pointer(array as i64); + let closure = crate::value::js_nanbox_pointer(closure as i64); + match (with_literals, filter) { + (true, false) => js_thread_parallel_map_with_literals(array, closure, callback), + (true, true) => js_thread_parallel_filter_with_literals(array, closure, callback), + (false, false) => js_thread_parallel_map(array, closure), + (false, true) => js_thread_parallel_filter(array, closure), + } + }) + }); let result = scope.root_raw_mut_ptr((result.to_bits() & POINTER_MASK) as *mut crate::array::ArrayHeader); - let result = result.get_raw_mut_ptr::(); - assert_eq!( - crate::array::js_array_get_length(result as i64), - if filter { 1 } else { 2 } - ); - assert_eq!( - crate::array::js_array_get_f64(result, 0), - if filter { 8.0 } else { 15.0 } - ); - if !filter { - assert_eq!(crate::array::js_array_get_f64(result, 1), 16.0); - } - finish(with_literals, 2); + result.with_mut_ptr::(|result| { + assert_eq!( + crate::array::js_array_get_length(result as i64), + if filter { 1 } else { 2 } + ); + assert_eq!( + crate::array::js_array_get_f64(result, 0), + if filter { 8.0 } else { 15.0 } + ); + if !filter { + assert_eq!(crate::array::js_array_get_f64(result, 1), 16.0); + } + }); + finish(&observations, 2); + // SAFETY: parallel map/filter join every scoped worker before returning. + unsafe { drop(Arc::from_raw(lease)) }; } #[test] From 07ee04e185347d0adae3f71d59ef0f7e8ee63de5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 2 Oct 2026 21:03:07 +0200 Subject: [PATCH 16/17] fix(codegen): account for shape proof use in guarded regions --- benchmarks/repsel_census/baseline.json | 9 + .../fixture_ptr_shape_straight_line.ts | 25 +++ changelog.d/11680-region-proof-consumption.md | 13 ++ crates/perry-codegen/src/expr/mod.rs | 28 ++- crates/perry-codegen/src/expr/slot_rep.rs | 11 + .../src/stmt/class_field_loop_tests.rs | 3 + crates/perry-codegen/src/stmt/loops.rs | 6 + .../src/stmt/ptr_shape_region_report_tests.rs | 197 ++++++++++++++++++ .../src/stmt/region_loop/bare.rs | 17 ++ .../src/stmt/region_loop/guard.rs | 23 +- .../perry-codegen/src/stmt/region_loop/mod.rs | 5 + .../stmt/region_loop/numeric_expression.rs | 1 + .../compiler_output_harness/repsel_census.py | 5 + 13 files changed, 329 insertions(+), 14 deletions(-) create mode 100644 benchmarks/repsel_census/fixtures/fixture_ptr_shape_straight_line.ts create mode 100644 changelog.d/11680-region-proof-consumption.md create mode 100644 crates/perry-codegen/src/stmt/ptr_shape_region_report_tests.rs diff --git a/benchmarks/repsel_census/baseline.json b/benchmarks/repsel_census/baseline.json index 3a60cc4f1a..83c386dc0d 100644 --- a/benchmarks/repsel_census/baseline.json +++ b/benchmarks/repsel_census/baseline.json @@ -1,6 +1,15 @@ { "schema_version": 1, "workloads": [ + { + "name": "fixture_ptr_shape_straight_line", + "role": "liveness", + "source": "benchmarks/repsel_census/fixtures/fixture_ptr_shape_straight_line.ts", + "floors": { + "ptr-shape": 1, + "ptr-shape-consumed": 1 + } + }, { "name": "fixture_ptr_shape", "role": "liveness", diff --git a/benchmarks/repsel_census/fixtures/fixture_ptr_shape_straight_line.ts b/benchmarks/repsel_census/fixtures/fixture_ptr_shape_straight_line.ts new file mode 100644 index 0000000000..92abbba7ff --- /dev/null +++ b/benchmarks/repsel_census/fixtures/fixture_ptr_shape_straight_line.ts @@ -0,0 +1,25 @@ +// The surviving guard-free numeric load/update sites need independent +// coverage now that P8 routes loop bodies through live shape guards. +// Keep the original loop fixtures unchanged: their floors cover that handoff. +class StraightCounter { + v: number; + w: number; + constructor() { + this.v = 0; + this.w = 1; + } + mix(): number { + return this.v * this.v + this.w * this.w; + } +} + +function straightLine(): number { + const c = new StraightCounter(); + // A self-reading store prevents scalar replacement. There is deliberately + // no loop, so region handoff cannot consume these accesses instead. + c.v = c.v + 1; + c.w++; + return c.v * c.w + c.mix(); +} + +console.log("ptr_shape_straight_line:" + straightLine()); diff --git a/changelog.d/11680-region-proof-consumption.md b/changelog.d/11680-region-proof-consumption.md new file mode 100644 index 0000000000..1d4f73d3a2 --- /dev/null +++ b/changelog.d/11680-region-proof-consumption.md @@ -0,0 +1,13 @@ +Report how pointer-shape proofs pass into the one-shape region path. Static +region suppliers explicitly use available receiver-class provenance, while the +live ShapeId guard remains the authority for offsets and field representation. +Consumption is recorded only when that supplier serves an emitted read or +write. Learned suppliers and type hints do not count as proof consumption; +refused unguarded routes name the region handoff. + +Preserve every existing promotion floor and fixture. Add a straight-line +fixture for the surviving guard-free load/update sites and five compiler tests +covering actual region accesses, removal of the static supplier, absence of a +selected proof, reporting OFF, and the surviving straight-line sites. +The Python census checks and formatting pass. Compiler execution, the rebuilt +census and knob isolation remain required before acceptance. diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 50aad6a0e3..af0a51c247 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -276,7 +276,7 @@ pub(crate) use slot_rep::{ deny_canonical_context, deny_canonical_i32, load_canonical_local_boxed, local_is_canonical_str, local_rep_is_canonical_i32, note_canonical_local, ptr_shape_context_rule_text, store_canonical_local_from_double, CanonicalI32Denial, SlotRep, PTR_SHAPE_NO_ACCESS_SITE, - PTR_SHAPE_SCALAR_REPLACED, + PTR_SHAPE_REGION_AUTHORITY, PTR_SHAPE_SCALAR_REPLACED, }; pub(crate) use dispatch::{lower_expr, lower_math_operand}; @@ -2840,8 +2840,9 @@ impl<'a> FnCtx<'a> { } /// The `Ptr` fact for `e` ignoring the context gate — the proof the - /// analysis actually produced, as opposed to the proof codegen is allowed - /// to act on. Report-only. + /// analysis actually produced, as opposed to permission for an unguarded + /// access. Used for reporting and for class provenance in a separately + /// shape-guarded region; never grants native-slot or numeric permission. fn ptr_shape_fact_ignoring_context( &self, e: &perry_hir::Expr, @@ -2853,6 +2854,20 @@ impl<'a> FnCtx<'a> { } } + /// Class provenance for a region's static supplier, never a license for + /// raw access. The supplier must validate the live ShapeId and obtain all + /// offsets/representations from that shape. Unlike the unguarded accessor, + /// this route is valid while region lowering owns representation authority. + /// Other unguarded-context denials do not invalidate a class hint either: + /// region eligibility still rejects unsupported bindings, and its guarded + /// loads re-read tagged roots. No native-slot permission is inherited here. + /// The collection OFF knob removes the fact itself; this accessor cannot + /// recreate it from a type annotation. + pub(crate) fn ptr_shape_region_class(&self, e: &perry_hir::Expr) -> Option { + self.ptr_shape_fact_ignoring_context(e) + .map(|fact| fact.class_name.clone()) + } + /// Record that a selected `Ptr` proof was dropped by the context /// gate (`repsel_context_allows_ptr_shape == false`). /// @@ -2889,7 +2904,7 @@ impl<'a> FnCtx<'a> { tier: crate::opt_report::Tier::CompilerLimitation, issue: Some(issue), detail: Some(format!( - "proven Ptr of class {}; every access site keeps the guard diamond", + "proven Ptr of class {}; this access cannot use the unguarded receiver route", fact.class_name )), }); @@ -2898,7 +2913,10 @@ impl<'a> FnCtx<'a> { /// Record that codegen COMMITTED to a `Ptr` lowering for `e`. /// /// Call from the taken branch of a site that has already decided to emit - /// the guard-free form — never from the accessor, which answers `Some` at + /// the guard-free form, or from an emitted region access whose static + /// supplier was selected using this fact's class provenance. The region's + /// ShapeId guard still owns its slot/representation authority. Never call + /// from the accessor, which answers `Some` at /// sites that then reject the fact on a class or numeric-field mismatch and /// emit the guarded diamond anyway. pub(crate) fn note_ptr_shape_consumed(&self, e: &perry_hir::Expr, site: &'static str) { diff --git a/crates/perry-codegen/src/expr/slot_rep.rs b/crates/perry-codegen/src/expr/slot_rep.rs index c72bb175c2..92643d4e8a 100644 --- a/crates/perry-codegen/src/expr/slot_rep.rs +++ b/crates/perry-codegen/src/expr/slot_rep.rs @@ -526,10 +526,21 @@ pub(crate) const PTR_SHAPE_SCALAR_REPLACED: &str = "scalar_replaced"; /// codegen had silently refused to apply. pub(crate) const PTR_SHAPE_NO_ACCESS_SITE: &str = "no_access_site"; +/// A region owns slot/representation authority; an older unguarded receiver +/// route must not bypass its live ShapeId and store admission. +pub(crate) const PTR_SHAPE_REGION_AUTHORITY: &str = "region_shape_authority"; + /// `(reason, issue)` for a rule that stopped a *selected* `Ptr` proof /// from being consumed by codegen. pub(crate) fn ptr_shape_context_rule_text(rule: &str) -> (&'static str, &'static str) { match rule { + PTR_SHAPE_REGION_AUTHORITY => ( + "the admitted loop/body region owns slot and representation authority: \ + its live ShapeId guard and store admission replace the unguarded \ + receiver route at this access. Class provenance consumed by an \ + emitted static-region access is reported separately", + "#10884 (P8 region handoff)", + ), MODULE_INIT_CONTEXT => ( "module-init / program-entry bodies set \ `repsel_context_allows_canonical_i32: false` (codegen/entry.rs), and \ diff --git a/crates/perry-codegen/src/stmt/class_field_loop_tests.rs b/crates/perry-codegen/src/stmt/class_field_loop_tests.rs index 8b4483cfcc..ec3955dd67 100644 --- a/crates/perry-codegen/src/stmt/class_field_loop_tests.rs +++ b/crates/perry-codegen/src/stmt/class_field_loop_tests.rs @@ -2,6 +2,9 @@ //! class increment shapes. Runtime, hostile-value, and cost acceptance remains //! separate and must use the unchanged original TypeScript fixtures. +#[path = "ptr_shape_region_report_tests.rs"] +mod ptr_shape_region_report_tests; + use crate::{compile_module, AppMetadata, CompileOptions}; use perry_hir::types::Type; use perry_hir::{ diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index 87e8fe6994..66eefdb5d9 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -6767,6 +6767,7 @@ pub(crate) fn lower_for( // array/storage tiers above retain first refusal. Restore the previous // context both when planning declines and when lowering fails. let saved_ptr_shape_context = ctx.repsel_context_allows_ptr_shape; + let saved_ptr_shape_denial = ctx.repsel_ptr_shape_context_denial; ctx.repsel_context_allows_ptr_shape = false; let lowered = (|| -> Result<()> { let region = super::region_loop::begin(ctx, condition, body, update)?; @@ -6774,6 +6775,10 @@ pub(crate) fn lower_for( // consume its pre-existing straight-line receiver facts as before. if region.is_none() { ctx.repsel_context_allows_ptr_shape = saved_ptr_shape_context; + } else if saved_ptr_shape_context { + // Planning alone is not a refusal. Only an admitted region owns + // the accesses lowered below, and its handoff must be visible. + ctx.repsel_ptr_shape_context_denial = Some(crate::expr::PTR_SHAPE_REGION_AUTHORITY); } let lowered = super::region_loop::lower_loop(ctx, region, &mut |ctx| { if i32_counter::lower(ctx, init, condition, update, body)? { @@ -6786,6 +6791,7 @@ pub(crate) fn lower_for( lowered })(); ctx.repsel_context_allows_ptr_shape = saved_ptr_shape_context; + ctx.repsel_ptr_shape_context_denial = saved_ptr_shape_denial; lowered } diff --git a/crates/perry-codegen/src/stmt/ptr_shape_region_report_tests.rs b/crates/perry-codegen/src/stmt/ptr_shape_region_report_tests.rs new file mode 100644 index 0000000000..72764c92ee --- /dev/null +++ b/crates/perry-codegen/src/stmt/ptr_shape_region_report_tests.rs @@ -0,0 +1,197 @@ +//! The P8 handoff consumes class provenance only when a real static-region +//! access uses it. A learned region must never be counted as that consumption. + +use super::*; +use crate::opt_report::{test_support::Session, Analysis, Outcome}; + +fn fixture() -> Module { + let mut m = method_calls_module(Expr::Integer(200), Vec::new(), false); + // A loop-local receiver forces the body-region handoff that stole the + // original census fixtures. The self-reading store prevents scalarization. + let mut receiver = m.init.remove(0); + if let Stmt::Let { + init: Some(Expr::New { args, .. }), + .. + } = &mut receiver + { + *args = vec![Expr::Integer(0)]; + } + let Stmt::For { body, .. } = &mut m.init[0] else { + panic!("fixture loop") + }; + body.insert(0, receiver); + m +} + +fn static_options(m: &Module) -> CompileOptions { + let births = crate::module_birth_shapes(m, ir_opts()).unwrap(); + let mut opts = ir_opts(); + opts.static_shape_ids = crate::assign_static_shape_ids(births.iter().map(|b| &b.shape)) + .into_iter() + .collect(); + assert!( + !opts.static_shape_ids.is_empty(), + "fixture needs a static supplier" + ); + opts +} + +#[test] +fn selected_class_provenance_is_consumed_by_real_shape_guarded_region_accesses() { + let m = fixture(); + let opts = static_options(&m); + let session = Session::start(); + let ir = String::from_utf8(compile_module(&m, opts).unwrap()).unwrap(); + let entries = session.entries(); + assert!( + entries.iter().any(|e| e.analysis == Analysis::PtrShape + && e.outcome == Outcome::Selected + && e.local_id == Some(1)), + "fixture must actually select its receiver: {entries:?}" + ); + assert!( + ir.contains("rloop.guard.static") && ir.contains("rloop.fast"), + "a reported access must retain the live shape guard: {ir}" + ); + for site in ["ptr_shape_region_get", "ptr_shape_region_set"] { + assert!( + entries.iter().any(|e| e.analysis == Analysis::PtrShape + && e.outcome == Outcome::Consumed + && e.local_id == Some(1) + && e.site.as_deref() == Some(site)), + "missing {site}: {entries:?}" + ); + } + assert!( + entries.iter().any(|e| e.outcome == Outcome::Unconsumed + && e.rule.as_deref() == Some(crate::expr::PTR_SHAPE_REGION_AUTHORITY)), + "the generic copy's unguarded-route refusal must be named: {entries:?}" + ); +} + +#[test] +fn removing_static_supplier_does_not_claim_ptr_shape_consumption_for_learned_accesses() { + let session = Session::start(); + // Sabotage precisely the class-fact consumer's prerequisite, leaving the + // same selected receiver and real region accesses alive. + let ir = emit(&fixture()); + let entries = session.entries(); + assert!(entries.iter().any(|e| e.analysis == Analysis::PtrShape + && e.outcome == Outcome::Selected + && e.local_id == Some(1))); + assert!(ir.contains("rloop.fast") && ir.contains("@js_region_loop_prime(")); + assert!( + !entries.iter().any(|e| e + .site + .as_deref() + .is_some_and(|s| s == "ptr_shape_region_get" || s == "ptr_shape_region_set")), + "learned words do not consume static class provenance: {entries:?}" + ); + assert!(entries.iter().any(|e| e.outcome == Outcome::Unconsumed + && e.rule.as_deref() == Some(crate::expr::PTR_SHAPE_REGION_AUTHORITY))); +} + +#[test] +fn shape_barrier_removes_proof_attribution_but_keeps_guarded_type_hint_route() { + let mut m = fixture(); + // A separate object's delete trips rule 5 without disturbing this loop's + // shape supplier. The same guard/bare instructions must not acquire a + // Ptr consumption row when no such proof was selected. + m.init + .push(Stmt::Expr(Expr::Delete(Box::new(Expr::PropertyGet { + object: Box::new(Expr::New { + class_name: "Counter".into(), + args: vec![Expr::Integer(0)], + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + }), + property: "value".into(), + byte_offset: 0, + })))); + let opts = static_options(&m); + let session = Session::start(); + let ir = String::from_utf8(compile_module(&m, opts).unwrap()).unwrap(); + let entries = session.entries(); + assert!(ir.contains("rloop.guard.static") && ir.contains("rloop.fast")); + assert!(!entries.iter().any(|e| e.analysis == Analysis::PtrShape + && e.outcome == Outcome::Selected + && e.local_id == Some(1))); + assert!( + !entries.iter().any(|e| e + .site + .as_deref() + .is_some_and(|s| s == "ptr_shape_region_get" || s == "ptr_shape_region_set")), + "a type hint is not a consumed containment proof: {entries:?}" + ); +} + +#[test] +fn region_consumption_reporting_off_emits_identical_ir_and_no_entries() { + let m = fixture(); + let opts = static_options(&m); + let enabled = { + let session = Session::start(); + let ir = compile_module(&m, opts.clone()).unwrap(); + assert!(session + .entries() + .iter() + .any(|e| e.site.as_deref() == Some("ptr_shape_region_get"))); + ir + }; + let session = Session::start_disabled(); + let disabled = compile_module(&m, opts).unwrap(); + assert!(session.entries().is_empty()); + assert_eq!( + enabled, disabled, + "reporting must not change the emitted program" + ); +} + +#[test] +fn straight_line_numeric_load_and_update_remain_live() { + let mut m = method_calls_module(Expr::Integer(200), Vec::new(), false); + m.init.pop(); + if let Stmt::Let { + init: Some(Expr::New { args, .. }), + .. + } = &mut m.init[0] + { + *args = vec![Expr::Integer(0)]; + } + m.init.push(bump_stmt(1, false)); + m.init.push(Stmt::Expr(Expr::PropertyUpdate { + object: Box::new(Expr::LocalGet(1)), + property: "value".into(), + op: BinaryOp::Add, + prefix: false, + strict: false, + })); + m.init.push(Stmt::Expr(Expr::Binary { + op: BinaryOp::Mul, + left: Box::new(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(1)), + property: "value".into(), + byte_offset: 0, + }), + right: Box::new(Expr::Integer(2)), + })); + let session = Session::start(); + let ir = emit(&m); + let entries = session.entries(); + assert!(!ir.contains("rloop.fast")); + for site in [ + "class_field_get_number.shape_proven_load", + "ptr_shape_update", + ] { + assert!( + entries.iter().any(|e| e.outcome == Outcome::Consumed + && e.local_id == Some(1) + && e.site.as_deref() == Some(site)), + "surviving site {site} must have independent coverage: {entries:?}" + ); + } + assert!(!entries + .iter() + .any(|e| e.rule.as_deref() == Some(crate::expr::PTR_SHAPE_REGION_AUTHORITY))); +} diff --git a/crates/perry-codegen/src/stmt/region_loop/bare.rs b/crates/perry-codegen/src/stmt/region_loop/bare.rs index 2192adbb9e..d3bad48c01 100644 --- a/crates/perry-codegen/src/stmt/region_loop/bare.rs +++ b/crates/perry-codegen/src/stmt/region_loop/bare.rs @@ -159,6 +159,19 @@ pub(super) fn note_emitted(ctx: &mut FnCtx<'_>) { } } +/// Count the selected receiver only at an emitted access served by the static +/// supplier its class proof selected. Learned words and type guesses do not +/// consume that proof, nor does merely constructing a guard. +fn note_ptr_shape_access(ctx: &FnCtx<'_>, r: Recv, site: &'static str) { + if ctx.region_loop_facts.last().is_some_and(|a| { + a.receivers + .iter() + .any(|rv| rv.recv == r && rv.uses_ptr_shape_class) + }) { + ctx.note_ptr_shape_consumed(&r.expr(), site); + } +} + /// The address a bare READ loads. In an all-inline copy (and for every store, /// whose key the runtime publishes only when inline) it is the inline slot. /// In a spill copy the key's field says where the value lives: `< 32` is an @@ -256,6 +269,7 @@ pub(crate) fn try_lower_bare_get(ctx: &mut FnCtx<'_>, e: &Expr) -> Result