diff --git a/TYPE_LOWERING.md b/TYPE_LOWERING.md index 4f2e316b4b..f35988f796 100644 --- a/TYPE_LOWERING.md +++ b/TYPE_LOWERING.md @@ -60,7 +60,7 @@ tracked production slices above. | `[~]` | Rich TypeFacts/effect/range/escape lattice | Array-kind, array-stability, noalias, effect, unknown-call, alias, aggregate identity exposure, materialization-hazard facts, and a first async/microtask escape fact now feed packed-f64 and cached-length proofs. Loop array-length consumers now emit accepted/rejected effect-fact artifacts, including explicit async/microtask rejection records when an `await` would make cached length or bounded-index lowering unsafe. Object facts, field-sensitive escape/range facts, broader async/microtask summaries, and wider consumer coverage remain incomplete. Evidence: `async_microtask_escape_is_tracked_as_effect_fact`, `loop_length_effect_artifact_records_consumed_preservation_fact`, `async_microtask_effect_blocks_length_and_bounds_proofs_with_artifact_reason`, `aggregate_array_identity_exposure_marks_materialization_hazard`, `indirect_array_alias_from_container_blocks_length_and_bounds_proofs`, `loop_local_array_alias_push_blocks_packed_f64_loop_and_artifacts`, `hir_facts` unit tests, and invalidation regressions in `crates/perry-codegen/tests/native_proof_regressions/invalidation.rs`. | | `[~]` | Late boxing only at true dynamic boundaries | Native fast paths reduce boxing in verified regions; straight-line numeric and boolean ordinary-expression slices now materialize `f64`/`i1` only at return/runtime compatibility boundaries. Ordinary bodies still frequently lower to JSValue/`double` early outside those proven slices. | | `[~]` | Treat async/generator lowering as allocation lowering | Compiler-private async/generator control locals now avoid generic JSValue boxes for the narrow closure-shared control state: `__gen_state` / `__gen_pending_type` use typed `i32` heap cells, and `__gen_done` / `__gen_executing` use typed boolean heap cells. This preserves closure lifetime/sharing semantics while keeping control reads, writes, and `__gen_state === const` dispatch comparisons in native `i32`/`i1`. The compiler-private iter-result scratch slot now has raw-`f64`, raw-`i32`, and raw-`i1` handoffs for proven numeric, Int32, and boolean payloads: proven numeric payloads store raw, annotation-only numeric payloads coerce through `js_number_coerce` before raw storage, proven Int32 payloads store raw `i32` while annotation-only Int32 values stay off the raw-i32 slot, proven boolean payloads store raw `i1`, annotation-only boolean payloads stay generic, numeric consumers read through `js_iter_result_get_value_f64`, Int32 consumers read through `js_iter_result_get_value_i32`, boolean consumers read through `js_iter_result_get_value_i1`, and runtime side flags prevent GC from scanning raw primitive bits as roots. Public await/PROMISE resolution values, `__gen_sent`, pending values, async captures, and externally visible async boundaries remain JSValue/generic. Evidence: `compiler_private_async_control_cells_use_primitive_heap_boxes`, `artifact_records_compiler_private_async_control_cells`, `compiler_private_async_iter_result_f64_slot_uses_typed_handoff`, `compiler_private_async_iter_result_annotated_numeric_payload_is_coerced_before_raw_slot`, `artifact_records_compiler_private_async_iter_result_f64_handoff`, `compiler_private_async_iter_result_i32_slot_uses_typed_handoff`, `compiler_private_async_iter_result_annotated_i32_payload_stays_off_raw_i32_slot`, `artifact_records_compiler_private_async_iter_result_i32_handoff`, `compiler_private_async_iter_result_i1_slot_uses_typed_handoff`, `compiler_private_async_iter_result_annotated_boolean_payload_stays_generic`, `artifact_records_compiler_private_async_iter_result_i1_handoff`, `test_promise_iter_result_raw_f64_slot_is_not_scanned_as_root`, `test_promise_iter_result_raw_i32_slot_is_not_scanned_as_root`, `test_promise_iter_result_raw_i1_slot_is_not_scanned_as_root`, `primitive_control_boxes_round_trip_and_reject_foreign_pointers`, `representation_lowering_helpers_have_lto_keepalive_anchors`, and `test_runtime_symbol_guard_roots_async_control_box_helpers`. | -| `[~]` | Typed internal function/method/closure paths plus generic trampolines | Ordinary functions now have conservative typed-f64 clones for straight-line numeric return bodies, a bounded typed-i1 clone path for fixed-arity boolean-only functions with straight-line boolean return bodies, a first numeric-predicate typed-i1 function shape whose internal clone takes `double` params and returns `i1`, a first `Int32` predicate typed-i1 function shape whose internal clone takes raw `i32` params and emits signed integer comparisons, a first straight-line `Int32... -> Int32` bitwise return clone whose internal clone takes and returns raw `i32`, and a first fixed-arity typed-string passthrough clone whose internal clone takes and returns raw string handles as `i64`. Eligible ordinary functions expose the original public symbol as a JSValue trampoline and move the generic implementation to an internal `__generic` body; same-module direct calls can target f64/i32/i1/string clones when their arguments are proven and guarded. Exact own instance methods now use the same public-symbol wrapper shape for the narrower method-eligible boolean/numeric/string slices: runtime vtables register the public JSValue trampoline, typed clones stay internal, numeric-predicate method clones use `i1(double, ...)` or typed-param-rep internal signatures, straight-line `Int32... -> Int32` bitwise method clones use `i32(...) -> i32`, string passthrough method clones use `i64(string...) -> i64 string`, and guarded direct compiled calls jump to the internal generic method body on typed-argument guard failure. Eligible local closures expose the original closure function pointer as a JSValue trampoline, keep the generic closure body under `__generic`, and keep typed clones internal; numeric-predicate closure clones use `i1(i64 closure, typed args...)` internal signatures, and string passthrough closure clones use `i64(i64 closure, i64 string...)` internal signatures with `js_nanbox_string` only at wrapper/direct-call boundaries, and straight-line `Int32... -> Int32` bitwise closure clones use `i32(i64 closure, i32...)` internal signatures with JSValue boxing only at wrapper/direct-call boundaries. Typed closure clones now always receive `i64 %this_closure`; immutable f64/i32/i1/string capture slots are loaded through that handle and converted to native reps before body lowering, with string capture guards emitted before raw clone entry. Built-in string methods/operators, dynamic string call sites, unsupported string method bodies, mutable captures, boxed captures, `this`/`new.target` captures, dynamic closure values, unsupported typed-i32 closure shapes and escaping/async closure shapes remain generic. Evidence: `typed_i32_return_function_uses_i32_params_return_and_public_wrapper`, `artifact_records_typed_i32_function_clone_selection`, `typed_i32_return_function_rejects_annotation_only_or_unsafe_shapes`, `typed_i32_method_clone_emits_internal_clone_and_guarded_direct_call`, `typed_i32_method_public_trampoline_dispatches_before_generic_body`, `artifact_records_typed_i32_method_clone_selection`, `typed_i32_method_clone_rejects_number_param_number_return_and_unsafe_add`, `typed_string_function_clone_emits_internal_clone_and_guarded_wrapper`, `artifact_records_typed_string_direct_call_selection`, `typed_string_function_clone_rejects_unsupported_string_shapes`, `typed_string_method_clone_emits_internal_clone_and_guarded_direct_call`, `artifact_records_typed_string_method_clone_selection`, `typed_string_method_clone_rejects_unsupported_string_shapes`, `artifact_records_typed_string_method_clone_rejection_reason`, `typed_string_method_clone_rejects_dynamic_receiver_direct_call_site`, `typed_string_closure_clone_emits_internal_clone_and_guarded_direct_call`, `typed_string_closure_clone_accepts_immutable_string_capture`, `artifact_records_typed_string_closure_clone_selection`, `typed_string_closure_clone_rejects_any_and_mutable_capture`, `typed_string_closure_clone_rejects_dynamic_callee_call_site`, `typed_i1_numeric_predicate_function_uses_f64_params_and_public_wrapper`, `typed_i1_i32_predicate_function_uses_i32_params_and_public_wrapper`, `typed_i1_numeric_predicate_method_uses_f64_params_and_guarded_direct_call`, `typed_i1_numeric_predicate_closure_uses_f64_params_and_guarded_direct_call`, `typed_f64_public_trampoline_dispatches_before_generic_body`, `typed_i1_public_trampoline_dispatches_before_generic_body`, `typed_f64_method_public_trampoline_dispatches_before_generic_body`, `typed_i1_method_public_trampoline_dispatches_before_generic_body`, `typed_f64_function_clone_*`, `typed_i1_function_clone_*`, `typed_f64_method_clone_*`, `typed_i1_method_clone_*`, `typed_f64_closure_clone_*`, `typed_i1_closure_clone_*`, and `typed_i32_closure_clone_*` tests in `crates/perry-codegen/tests/native_proof_regressions.rs`. | +| `[~]` | Typed internal function/method/closure paths plus generic trampolines | Ordinary functions now have conservative typed-f64 clones for straight-line numeric return bodies, a bounded typed-i1 clone path for fixed-arity boolean-only functions with straight-line boolean return bodies, a first numeric-predicate typed-i1 function shape whose internal clone takes `double` params and returns `i1`, a first `Int32` predicate typed-i1 function shape whose internal clone takes raw `i32` params and emits signed integer comparisons, a first straight-line `Int32... -> Int32` bitwise return clone whose internal clone takes and returns raw `i32`, and a first fixed-arity typed-string passthrough clone whose internal clone takes and returns raw string handles as `i64`. Eligible ordinary functions expose the original public symbol as a JSValue trampoline and move the generic implementation to an internal `$generic` body; same-module direct calls can target f64/i32/i1/string clones when their arguments are proven and guarded. Exact own instance methods now use the same public-symbol wrapper shape for the narrower method-eligible boolean/numeric/string slices: runtime vtables register the public JSValue trampoline, typed clones stay internal, numeric-predicate method clones use `i1(double, ...)` or typed-param-rep internal signatures, straight-line `Int32... -> Int32` bitwise method clones use `i32(...) -> i32`, string passthrough method clones use `i64(string...) -> i64 string`, and guarded direct compiled calls jump to the internal generic method body on typed-argument guard failure. Eligible local closures expose the original closure function pointer as a JSValue trampoline, keep the generic closure body under `$generic`, and keep typed clones internal; numeric-predicate closure clones use `i1(i64 closure, typed args...)` internal signatures, and string passthrough closure clones use `i64(i64 closure, i64 string...)` internal signatures with `js_nanbox_string` only at wrapper/direct-call boundaries, and straight-line `Int32... -> Int32` bitwise closure clones use `i32(i64 closure, i32...)` internal signatures with JSValue boxing only at wrapper/direct-call boundaries. Typed closure clones now always receive `i64 %this_closure`; immutable f64/i32/i1/string capture slots are loaded through that handle and converted to native reps before body lowering, with string capture guards emitted before raw clone entry. Built-in string methods/operators, dynamic string call sites, unsupported string method bodies, mutable captures, boxed captures, `this`/`new.target` captures, dynamic closure values, unsupported typed-i32 closure shapes and escaping/async closure shapes remain generic. Evidence: `typed_i32_return_function_uses_i32_params_return_and_public_wrapper`, `artifact_records_typed_i32_function_clone_selection`, `typed_i32_return_function_rejects_annotation_only_or_unsafe_shapes`, `typed_i32_method_clone_emits_internal_clone_and_guarded_direct_call`, `typed_i32_method_public_trampoline_dispatches_before_generic_body`, `artifact_records_typed_i32_method_clone_selection`, `typed_i32_method_clone_rejects_number_param_number_return_and_unsafe_add`, `typed_string_function_clone_emits_internal_clone_and_guarded_wrapper`, `artifact_records_typed_string_direct_call_selection`, `typed_string_function_clone_rejects_unsupported_string_shapes`, `typed_string_method_clone_emits_internal_clone_and_guarded_direct_call`, `artifact_records_typed_string_method_clone_selection`, `typed_string_method_clone_rejects_unsupported_string_shapes`, `artifact_records_typed_string_method_clone_rejection_reason`, `typed_string_method_clone_rejects_dynamic_receiver_direct_call_site`, `typed_string_closure_clone_emits_internal_clone_and_guarded_direct_call`, `typed_string_closure_clone_accepts_immutable_string_capture`, `artifact_records_typed_string_closure_clone_selection`, `typed_string_closure_clone_rejects_any_and_mutable_capture`, `typed_string_closure_clone_rejects_dynamic_callee_call_site`, `typed_i1_numeric_predicate_function_uses_f64_params_and_public_wrapper`, `typed_i1_i32_predicate_function_uses_i32_params_and_public_wrapper`, `typed_i1_numeric_predicate_method_uses_f64_params_and_guarded_direct_call`, `typed_i1_numeric_predicate_closure_uses_f64_params_and_guarded_direct_call`, `typed_f64_public_trampoline_dispatches_before_generic_body`, `typed_i1_public_trampoline_dispatches_before_generic_body`, `typed_f64_method_public_trampoline_dispatches_before_generic_body`, `typed_i1_method_public_trampoline_dispatches_before_generic_body`, `typed_f64_function_clone_*`, `typed_i1_function_clone_*`, `typed_f64_method_clone_*`, `typed_i1_method_clone_*`, `typed_f64_closure_clone_*`, `typed_i1_closure_clone_*`, and `typed_i32_closure_clone_*` tests in `crates/perry-codegen/tests/native_proof_regressions.rs`. | | `[~]` | Packed numeric array lowering/versioning with safe fallback | Guarded packed-f64 loop versioning and typed-feedback/runtime layout gates exist. Store-bearing shapes such as `arr[i] = arr[i] + number` and `arr[i] = Math.abs(arr[i])` now side-exit to the slow clone on store-guard failure instead of rejoining after boxed fallback; the unary math shape lowers the fast RHS to native `llvm.fabs.f64` only when the operand is the proven packed element, while coercive operands stay generic. `Int32[]` loops now have a packed-i32 versioning slice: read loops use the i32-specific layout guard, label fast/slow clones and artifacts as `packed_i32`, and materialize `arr[i]` as native `i32` inside the fast clone from the guarded raw numeric slot; a first store-bearing shape, `arr[i] = (arr[i] + i32_const) | 0`, keeps the RHS in the i32 lane, stores the exact f64 raw numeric slot after the packed-i32 store guard, and side-exits to the slow clone on guard failure. Release symbol guard coverage now roots/asserts the generated typed-feedback array helpers (`packed_f64_array_loop_guard`, `packed_i32_array_loop_guard`, numeric get/set guards, boxed fallbacks, numeric push, and companion array feedback helpers) so stale LTO/static archives fail before link. Dynamic fractional index fallback evidence now covers preserving the original runtime key for get/set and not truncating typed-array fractional numeric keys. Local alias mutation, length writes, unknown calls, materialization hazards, unsafe f64 stores to Int32 arrays, and unsafe store-then-read shapes still invalidate or reject the relevant cached-length/bounds/packed numeric proofs. The verifier now also gates the positive/fast-clone side of a packed-loop region as call-free: guard, load, and store records must stay region-local and carry no runtime-call materialization or dynamic fallback, while the dynamic side-exit/fallback records remain the only runtime-call boundary — proving Slice-3's "no unexpected runtime calls in positive hot regions" requirement. Broader effect summaries remain incomplete. Evidence: `verifier_accepts_call_free_packed_loop_region_record`, `verifier_rejects_packed_loop_region_runtime_materialization`, `verifier_rejects_packed_loop_region_escaping_region_local_state`, `verifier_rejects_packed_loop_region_dynamic_fallback_access_mode`, `packed_loop_positive_consumers_exclude_runtime_boundaries`, `packed_f64_loop_store_update_versions_with_side_exit`, `packed_f64_loop_unary_math_store_versions_with_side_exit`, `packed_f64_loop_rejects_coercive_unary_math_store_rhs`, `packed_i32_loop_read_materializes_integer_native_load_with_fallback`, `packed_i32_loop_store_update_versions_with_side_exit`, `packed_i32_loop_store_rejects_fractional_number_rhs`, `loop_local_array_alias_push_blocks_packed_i32_loop_and_artifacts`, packed-f64 invalidation regressions, `test_runtime_symbol_guard_roots_typed_feedback_array_helpers`, `typed_feedback_boxed_fallback_preserves_fractional_keys_for_array_like_receivers`, `typed_feedback_boxed_set_fallback_does_not_truncate_fractional_array_like_keys`, `dynamic_fractional_array_index`, and `scripts/check_runtime_symbols.sh target/release/libperry_runtime.a`. | | `[~]` | Fixed/unboxed class field layout and direct typed field access | Raw numeric class-field fast paths exist for proven fields. Numeric consumers now use a raw-f64 class-field get path that keeps the guarded fast load as native `f64` and coerces only the boxed runtime fallback before the numeric merge. Raw numeric class-field get/set artifacts now carry explicit exact-declared-receiver, guarded class-id/keys, raw-f64 slot-array, and pointer-free bitmap notes; raw numeric stores also emit `WriteBarrierElided` evidence because the slot is proven non-pointer. Unknown receivers and computed/dynamic-shape class bodies do not claim raw slot access in their source function. General fixed mixed layouts and runtime pointer bitmaps are not complete. Evidence: `typed_feedback_guards_direct_class_field_specialization`, `artifact_records_raw_numeric_class_field_f64_fast_paths_and_fallback_reasons`, and `raw_numeric_class_field_rejects_unknown_or_dynamic_shape_receiver`. | | `[~]` | Method/effect summaries for scalar replacement across simple method calls | Exact-receiver summaries exist for scalar-replaced class instances whose own method is fixed-arity and synchronous with either a numeric `return` over public numeric `this.field` reads/numeric params/arithmetic, a boolean comparison predicate over that same numeric subset, or a signed Int32 bitwise return over public Int32 fields/params/in-range literals. This lets `new Point(...).sum()` / `isAbove(n)` and narrow `new Flags(...).mix(i32)`-style calls inline against scalar field slots without heap allocation or method dispatch when arguments are proven in the current expression. Public `number`/`Int32` local arguments, plus arithmetic expressions over guarded numeric locals and literals, now use guarded fast paths: f64 summaries check `js_typed_f64_arg_guard`, Int32 bitwise summaries check `js_typed_i32_arg_guard`, and fallbacks materialize the scalar receiver before generic by-ID method dispatch. Schema-v15 artifacts give `scalar_method_summary` facts a structured `detail` field, so inline records distinguish `exact_receiver_summary` versus `guarded_numeric_args_fast_path`, while materialized fallbacks distinguish `generic_argument` versus `guarded_numeric_args_fallback`. Unproven `any` arguments/expressions, unsigned shifts, non-Int32 fields for Int32 summaries, and broader method shapes stay generic. Mutation/effect summaries, inherited/dynamic methods, field writes, `this` escape, accessors, dynamic property reads, nested/unknown calls, and broader non-numeric methods remain open. Evidence: `scalar_replaced_simple_method_call_inlines_summary_without_dispatch`, `artifact_records_scalar_replaced_method_summary_inline`, `scalar_replaced_boolean_method_predicate_inlines_without_dispatch_or_allocation`, `artifact_records_scalar_replaced_boolean_method_predicate_inline`, `scalar_replaced_int32_bitwise_method_inlines_without_dispatch_or_allocation`, `scalar_method_int32_bitwise_guards_public_int32_argument_and_preserves_fallback`, `scalar_method_int32_bitwise_rejects_unproven_or_unsigned_shapes`, `scalar_method_boolean_predicate_rejects_mutation_call_accessor_and_dynamic_property`, `scalar_method_boolean_predicate_rejects_unproven_numeric_arguments`, `scalar_method_boolean_predicate_rejects_unproven_numeric_argument_expressions`, `scalar_method_boolean_predicate_guards_public_numeric_arguments`, and `scalar_method_boolean_predicate_guards_public_numeric_argument_expressions`. | @@ -98,13 +98,13 @@ back to the internal generic body on guard failure. Same-module direct string clone directly, boxing only at the call boundary. Eligible own-instance methods use the same shape: the original method symbol is a JSValue wrapper registered in runtime vtables, and the generic -method body moves to an internal `__generic` symbol. A narrow set of direct +method body moves to an internal `$generic` symbol. A narrow set of direct compiled calls may still branch to the same internal typed-f64 or typed-i1 function/method clones after guards pass, and those direct-call guard failures target the generic body instead of re-entering the public wrapper. Eligible local closures use the same wrapper/body split: the stored closure function pointer remains the original public symbol, the generic closure body moves to -`__generic`, and internal raw-`double`/`i1` clones are called from the public +`$generic`, and internal raw-`double`/`i1` clones are called from the public wrapper or guarded direct local closure call sites. Those typed closure clones now take `i64 %this_closure` as their first internal parameter and can load immutable typed capture slots as native f64/i1 values. The new native facts are @@ -247,12 +247,12 @@ Compiler evidence for this branch covers: params and a safe string passthrough return. The internal clone uses raw `StringHeader*` handles as `i64`; the public JSValue wrapper uses `js_typed_string_arg_guard` / `js_typed_string_arg_to_raw`, boxes the raw - return with `js_nanbox_string`, and falls back to `__generic` if any guard + return with `js_nanbox_string`, and falls back to `$generic` if any guard fails. This is intentionally narrower than full `PerryStringRef` lowering: string methods, string operations, dynamic/computed strings, and non-passthrough returns stay generic. Same-module direct calls with proven string arguments can target the internal clone after guards and fall back to - `__generic` without recursing through the public wrapper. Evidence: + `$generic` without recursing through the public wrapper. Evidence: `typed_string_arg_guard_is_non_throwing_and_string_only`, `typed_string_function_clone_emits_internal_clone_and_guarded_wrapper`, and `artifact_records_typed_string_direct_call_selection`. @@ -296,9 +296,9 @@ Compiler evidence for this branch covers: - a first bounded local-closure typed-f64 clone path for statically-known fixed-arity numeric closures with a single simple numeric return expression. The stored public closure function pointer now guards/unboxes JSValue args, - calls the internal typed clone, and falls back to `__generic`; direct local + calls the internal typed clone, and falls back to `$generic`; direct local closure calls first pass the existing closure identity/arity guard, then a - numeric argument guard, and fall back to `__generic` or `js_closure_callN` at + numeric argument guard, and fall back to `$generic` or `js_closure_callN` at dynamic boundaries. The typed clone uses `i64 %this_closure` and can load immutable numeric captures from closure slots before lowering the body. Mutable/boxed captures, rest/default/`arguments`, async/generator, `this`, @@ -310,7 +310,7 @@ Compiler evidence for this branch covers: closure function pointer now guards/unboxes per typed parameter rep, calls the internal `i1` clone, and boxes the `i1` result at the ABI edge; direct local closure calls first pass the existing closure identity/arity guard, then - exact boolean or numeric argument guards, and fall back to `__generic` or + exact boolean or numeric argument guards, and fall back to `$generic` or `js_closure_callN` at dynamic boundaries. The typed clone uses `i64 %this_closure` and can load immutable boolean/f64 captures from closure slots before lowering the body. Mutable/boxed captures, `any` params, @@ -324,7 +324,7 @@ Compiler evidence for this branch covers: `js_typed_string_arg_to_raw`, calls an internal raw-`i64 StringHeader*` clone, and boxes with `js_nanbox_string` only at the ABI edge. Direct local closure calls first pass the existing closure identity/arity guard, then the string - argument and immutable-capture guards, and fall back to `__generic` or + argument and immutable-capture guards, and fall back to `$generic` or `js_closure_callN` at dynamic boundaries. `any` params, mutable/boxed captures, non-passthrough bodies, rest/default/`arguments`, async/generator, `this`, `new.target`, and unknown closure values stay generic. Evidence: @@ -772,7 +772,7 @@ slices for fixed-arity `Int32` params and straight-line bitwise-preserving in the current f64/i32/i1 body subset. Eligible ordinary functions now get a public `double`/NaN-box wrapper under the original symbol plus an internal generic body fallback. Eligible own instance methods and local closures now use the same -public wrapper plus internal `__generic` body split. Ineligible method and +public wrapper plus internal `$generic` body split. Ineligible method and closure body lowering still defines generic `double` parameters and `double` returns, with closures additionally taking `i64 this_closure`. Native fact collection now runs for these bodies, so selected regions inside them can use diff --git a/benchmarks/repsel_census/README.md b/benchmarks/repsel_census/README.md index 6791cde17c..a1b7c9a38f 100644 --- a/benchmarks/repsel_census/README.md +++ b/benchmarks/repsel_census/README.md @@ -101,7 +101,7 @@ still worked. It does not any more, and the version above is the one to copy. Byte-identical objects mean the promotions the report counted as wins changed nothing. `07_object_create` and `12_binary_trees` are byte-identical today. -`09_method_calls` differs, but only by two `__pshape` clones with **zero call +`09_method_calls` differs, but only by two `$pshape` clones with **zero call sites** — which is why the census reports its consumption as 0 and the object A/B alone would have been misleading. diff --git a/changelog.d/7441-unforgeable-clone-suffixes.md b/changelog.d/7441-unforgeable-clone-suffixes.md new file mode 100644 index 0000000000..56530109c3 --- /dev/null +++ b/changelog.d/7441-unforgeable-clone-suffixes.md @@ -0,0 +1,20 @@ +Fixed #6927: user members whose names spell a generated clone suffix +(`foo__generic`, `add__typed_f64`, `tick__pshape`, `A__dup1`, …) no longer +collide with the compiler's clone symbols. The failure had silently worsened +since the issue was filed: `deduped_function_refs` (first-define-wins, added +for minified same-name classes) swallowed the duplicate definition, so the +user function's public entry was usurped by its sibling's clone and indirect +calls executed the wrong body (`const g = add__typed_f64; g(2, 3)` returned +`add(2, 3)`). Generated suffixes now use a reserved `$` separator +(`{public}$generic`, `$typed_*`, `$spec_`, `$pshape`, `$dupN`); +`sanitize`/`sanitize_member` output is strictly `[A-Za-z0-9_]`, so no +user-derived symbol can compose a clone symbol, by construction, for every +current and future clone kind — including the cross-component shapes (class +`C__foo` + method `pshape`) that member-name checks cannot see. The #6925 +`__pshape` collision prune is dead under this invariant and was reduced to +its registry-presence filter (`prune_unregistered_clones`); both symbol +reachability ratchets track the new spellings with unchanged allowlists. +Regression coverage: `codegen/clone_suffix_tests.rs` (emitted-IR contract, +per-PR visible), `helpers::sanitize_tests` (no-`$` mangling invariant), and +gap test `test_gap_6927_clone_suffix_user_members.ts` (direct + indirect +calls across the family, byte-identical to Node). diff --git a/crates/perry-codegen/src/codegen/clone_suffix_tests.rs b/crates/perry-codegen/src/codegen/clone_suffix_tests.rs new file mode 100644 index 0000000000..9af37e7eb9 --- /dev/null +++ b/crates/perry-codegen/src/codegen/clone_suffix_tests.rs @@ -0,0 +1,183 @@ +//! Issue #6927 — generated clone symbols must be unforgeable by user names. +//! +//! Clone/body symbols used to be `{public}__` (`__generic`, +//! `__typed_f64`, …). A user function literally named `add__typed_f64` +//! composed the SAME LLVM symbol as `add`'s typed clone, and +//! `deduped_function_refs` (first-define-wins, added for minified same-name +//! classes) silently dropped one of the two definitions: the user function's +//! public entry was usurped by `add`'s clone, so every indirect call through +//! its registered wrapper executed `add`'s body instead — a SILENT wrong +//! result, not a loud verifier error. (Witnessed on v0.5.1280: +//! `const g = add__typed_f64; g(2, 3)` returned 5 instead of 6.) +//! +//! The fix reserves `$` as the generated-suffix separator (`{public}$generic`, +//! `{public}$typed_f64`, `{public}$dupN`, the spec-ABI and proven-`this` +//! suffixes): `sanitize`/`sanitize_member` output is strictly `[A-Za-z0-9_]`, +//! so no user-derived public symbol can ever equal a generated one. These +//! tests pin the emitted-IR side of that contract; the mangling side is pinned +//! by `helpers::sanitize_tests`. + +use crate::{compile_module, CompileOptions}; +use perry_hir::types::Type; +use perry_hir::{BinaryOp, Expr, Function, Module, ModuleInitKind, Param, Stmt}; + +fn ir_opts() -> CompileOptions { + CompileOptions { + emit_ir_only: true, + output_type: "executable".to_string(), + ..CompileOptions::default() + } +} + +fn number_param(id: u32, name: &str) -> Param { + Param { + id, + name: name.to_string(), + ty: Type::Number, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + } +} + +/// A typed-ABI clone candidate: straight-line `return a b` over two +/// `number` params, so codegen emits the public trampoline plus `$typed_f64` +/// and `$generic` bodies. +fn candidate_fn(id: u32, name: &str, op: BinaryOp) -> Function { + Function { + id, + name: name.to_string(), + type_params: Vec::new(), + params: vec![number_param(10, "a"), number_param(11, "b")], + return_type: Type::Number, + body: vec![Stmt::Return(Some(Expr::Binary { + op, + left: Box::new(Expr::LocalGet(10)), + right: Box::new(Expr::LocalGet(11)), + }))], + is_async: false, + is_generator: false, + is_strict: true, + was_plain_async: false, + was_unrolled: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + } +} + +fn module_with(functions: Vec) -> Module { + Module { + name: "clone_suffix.ts".to_string(), + imports: Vec::new(), + exports: Vec::new(), + classes: Vec::new(), + interfaces: Vec::new(), + type_aliases: Vec::new(), + enums: Vec::new(), + globals: Vec::new(), + functions, + script_global_functions: Vec::new(), + references_global_this: false, + annexb_global_undefined_names: Vec::new(), + init: Vec::new(), + exported_native_instances: Vec::new(), + exported_func_return_native_instances: Vec::new(), + exported_objects: Vec::new(), + exported_functions: Vec::new(), + widgets: Vec::new(), + uses_fetch: false, + uses_webassembly: false, + extern_funcs: Vec::new(), + init_was_unrolled: false, + has_top_level_await: false, + init_kind: ModuleInitKind::Eager, + async_step_closures: std::collections::HashSet::new(), + closure_display_names: std::collections::HashMap::new(), + class_display_names: std::collections::HashMap::new(), + closure_source_text: std::collections::HashMap::new(), + async_generator_funcs: std::collections::HashSet::new(), + gen_param_prologue_len: std::collections::HashMap::new(), + } +} + +/// `define` lines whose declared symbol is exactly `name`. +fn define_count(ir: &str, name: &str) -> usize { + let needle = format!("@{name}("); + ir.lines() + .filter(|l| l.starts_with("define") && l.contains(&needle)) + .count() +} + +/// Slice out the body of the `define`d function whose signature line declares +/// exactly `name`. +fn function_body<'a>(ir: &'a str, name: &str) -> &'a str { + let needle = format!("@{name}("); + let start = ir + .match_indices("define") + .find(|(i, _)| { + let line_end = ir[*i..].find('\n').map(|n| i + n).unwrap_or(ir.len()); + ir[*i..line_end].contains(&needle) + }) + .map(|(i, _)| i) + .unwrap_or_else(|| panic!("no define for @{name} in module IR")); + let end = ir[start..].find("\n}").expect("unterminated function") + start; + &ir[start..end] +} + +/// The #6927 witness family: `add` plus user functions whose names are the +/// OLD (forgeable) spellings of `add`'s clone symbols. Every public entry and +/// every clone must be a distinct symbol, each defined exactly once, with each +/// body reached from its own trampoline. +#[test] +fn user_members_named_like_clone_suffixes_keep_their_own_symbols() { + let ir = String::from_utf8( + compile_module( + &module_with(vec![ + candidate_fn(1, "add", BinaryOp::Add), + candidate_fn(2, "add__typed_f64", BinaryOp::Mul), + candidate_fn(3, "add__generic", BinaryOp::Sub), + ]), + ir_opts(), + ) + .unwrap(), + ) + .expect("LLVM IR should be UTF-8"); + + // Publics and clones are all distinct symbols, each defined exactly once. + // Pre-fix, `add`'s clone was literally `perry_fn_clone_suffix_ts__add__typed_f64` + // — the user function's public symbol — and first-define-wins dedup + // silently dropped the user function's own entry. + for name in [ + "perry_fn_clone_suffix_ts__add", + "perry_fn_clone_suffix_ts__add__typed_f64", + "perry_fn_clone_suffix_ts__add__generic", + "perry_fn_clone_suffix_ts__add$typed_f64", + "perry_fn_clone_suffix_ts__add$generic", + "perry_fn_clone_suffix_ts__add__typed_f64$typed_f64", + "perry_fn_clone_suffix_ts__add__typed_f64$generic", + "perry_fn_clone_suffix_ts__add__generic$typed_f64", + "perry_fn_clone_suffix_ts__add__generic$generic", + ] { + assert_eq!( + define_count(&ir, name), + 1, + "@{name} must be defined exactly once" + ); + } + + // Each trampoline routes to ITS OWN clones — `add`'s fast arm computes + // a + b, the user `add__typed_f64`'s computes a * b. + let add_public = function_body(&ir, "perry_fn_clone_suffix_ts__add"); + assert!(add_public.contains("@perry_fn_clone_suffix_ts__add$typed_f64(")); + assert!(add_public.contains("@perry_fn_clone_suffix_ts__add$generic(")); + let user_public = function_body(&ir, "perry_fn_clone_suffix_ts__add__typed_f64"); + assert!(user_public.contains("@perry_fn_clone_suffix_ts__add__typed_f64$typed_f64(")); + assert!(user_public.contains("@perry_fn_clone_suffix_ts__add__typed_f64$generic(")); + + let add_clone = function_body(&ir, "perry_fn_clone_suffix_ts__add$typed_f64"); + assert!(add_clone.contains("fadd"), "add's clone body is a + b"); + let user_clone = function_body(&ir, "perry_fn_clone_suffix_ts__add__typed_f64$typed_f64"); + assert!(user_clone.contains("fmul"), "add__typed_f64's clone body is a * b"); +} diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index e32aef92db..0565e45fa2 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -1318,7 +1318,7 @@ mod tests { let _shadow = crate::codegen::helpers::NativeRootsPin::shadow(); let ir = one_capture_closure_ir(); // The public `perry_closure_*` symbol can be a typed trampoline over a - // straight-line `__typed_f64` clone; the real body is the one that + // straight-line `$typed_f64` clone; the real body is the one that // carries a shadow frame. (The typed clone lowers arithmetic-only, // loop-free, call-free statements — `lower_typed_f64_body_*` bails on // anything else — so it contains no safepoint and its `%this_closure` diff --git a/crates/perry-codegen/src/codegen/func_registry.rs b/crates/perry-codegen/src/codegen/func_registry.rs index bad861ab88..dafc6ebcca 100644 --- a/crates/perry-codegen/src/codegen/func_registry.rs +++ b/crates/perry-codegen/src/codegen/func_registry.rs @@ -24,8 +24,11 @@ pub(crate) struct FuncRegistry { /// Resolve user function names + signatures up front. Names are scoped by /// module prefix; distinct functions that mangle to the same symbol get a -/// numeric `__dupN` suffix (exported functions reserve their canonical name -/// first and never get suffixed). +/// numeric `$dupN` suffix (exported functions reserve their canonical name +/// first and never get suffixed). The `$` separator keeps the uniquifier in +/// the reserved generated-suffix namespace (issue #6927): `sanitize` output +/// is `[A-Za-z0-9_]`-only, so a user function literally named `A__dup1` +/// cannot collide with the disambiguated symbol of a duplicate `A`. pub(crate) fn build_func_registry(hir: &HirModule, module_prefix: &str) -> FuncRegistry { let mut func_names: HashMap = HashMap::new(); let mut func_signatures: HashMap = HashMap::new(); @@ -34,8 +37,8 @@ pub(crate) fn build_func_registry(hir: &HirModule, module_prefix: &str) -> FuncR // Distinct functions can mangle to the same symbol: minified code reuses // short names (`function A`) across scopes, and perry lambda-lifts nested // functions to module level, so two module functions can share a name — clang - // then rejects the duplicate `define perry_fn___A`. Disambiguate with a - // numeric suffix, keyed by the mangled symbol. Exported functions are + // then rejects the duplicate `define perry_fn___A`. Disambiguate with + // a numeric suffix, keyed by the mangled symbol. Exported functions are // referenced cross-module by their canonical `scoped_fn_name` and are unique // per module, so they reserve that name first and never get suffixed. let mut used_fn_symbols: HashMap = HashMap::new(); @@ -56,7 +59,7 @@ pub(crate) fn build_func_registry(hir: &HirModule, module_prefix: &str) -> FuncR let s = if *n == 0 { base.clone() } else { - format!("{base}__dup{n}") + format!("{base}$dup{n}") }; *n += 1; s diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index 98663ec2ec..858fa9fa4a 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -666,6 +666,13 @@ pub(super) fn scoped_method_name( /// a digit, so prefix with `_` if the first character would be one (this /// happens with module names like `05_fibonacci.ts`). /// +/// The output alphabet being strictly `[A-Za-z0-9_]` is a load-bearing +/// invariant (issue #6927): `$` is reserved for compiler-generated clone / +/// uniquifier suffixes (`$generic`, `$typed_*`, `$dupN`, the spec-ABI and +/// proven-`this` suffixes), so a user-derived symbol component can never +/// forge a generated symbol. Never emit `$` from this function or from +/// [`sanitize_member`]. +/// /// NOTE: this mapping is *lossy* — every special character collapses to `_`, /// so distinct inputs can share an output. That is fine for the module-prefix /// and static-field components (whose values are recorded once and re-derived @@ -713,6 +720,9 @@ pub(super) fn sanitize(name: &str) -> String { /// /// Must be applied at BOTH the definition site and every reference site for a /// given symbol component, or the symbols desync and the linker fails. +/// +/// Like [`sanitize`], the output is strictly `[A-Za-z0-9_]` — `$` is reserved +/// for generated suffixes and must never appear here (issue #6927). pub(super) fn sanitize_member(name: &str) -> String { let is_plain = name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'); if is_plain { @@ -1543,6 +1553,42 @@ pub(super) fn emit_namespace_populator( blk.call_void("js_gc_register_global_root", &[(I64, &addr_i64)]); } +#[cfg(test)] +mod sanitize_tests { + use super::{sanitize, sanitize_member, scoped_fn_name, scoped_method_name}; + + /// Issue #6927: the generated-clone namespace (`{public}$`) is + /// unforgeable ONLY because these two functions never emit `$`. If either + /// ever lets a `$` through, a user member could compose a public symbol + /// equal to a generated clone symbol and silently usurp it + /// (`deduped_function_refs` keeps the first definition). + #[test] + fn sanitize_never_emits_the_reserved_generated_suffix_separator() { + for hostile in [ + "foo$generic", + "$dup1", + "a$b$c", + "foo__generic", // old forgeable spelling — plain, passes through, harmless now + "#$", + "℘$typed_f64", + ] { + assert!( + !sanitize(hostile).contains('$'), + "sanitize({hostile:?}) leaked a `$`: {:?}", + sanitize(hostile) + ); + assert!( + !sanitize_member(hostile).contains('$'), + "sanitize_member({hostile:?}) leaked a `$`: {:?}", + sanitize_member(hostile) + ); + } + // And therefore no composed public symbol contains one either. + assert!(!scoped_fn_name("m", "add$typed_f64").contains('$')); + assert!(!scoped_method_name("m", "C$x", "foo$generic").contains('$')); + } +} + #[cfg(test)] mod resolve_target_triple_tests { use super::resolve_target_triple; diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index fd6a95695d..afb6d40987 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -54,6 +54,8 @@ mod method; mod method_registry; mod module_globals_emit; #[cfg(test)] +mod clone_suffix_tests; +#[cfg(test)] mod number_exactness_tests; mod opts; mod spec_abi; @@ -1916,13 +1918,13 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> ); // Representation-selection Phase 5a: now that the method registry exists, - // drop any proven-`this` clone whose composed symbol would collide with a - // symbol a real user member already owns (issue #6927 tracks the - // family-wide fix for every generated-clone suffix). Pruning HERE — before - // `emit_module_artifacts` reads `cross_module.pshape_methods` for both - // emission and call-site routing — keeps the two in lockstep, so a call - // site can never route to a clone the emission loop declined to produce. - crate::collectors::prune_colliding_clones(&mut cross_module.pshape_methods, &method_names); + // drop any proven-`this` clone whose pair never made it into it. (Symbol + // collisions with user members are impossible since #6927's reserved-`$` + // clone namespace, so registry presence is all that is checked.) Pruning + // HERE — before `emit_module_artifacts` reads `cross_module.pshape_methods` + // for both emission and call-site routing — keeps the two in lockstep, so + // a call site can never route to a clone the emission loop cannot produce. + crate::collectors::prune_unregistered_clones(&mut cross_module.pshape_methods, &method_names); // Resolve user function names + signatures up front. See // `func_registry::build_func_registry`. @@ -1975,7 +1977,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // `closure.rs` filters module globals OUT of `closure_captures`, so the // closure is `alloc_singleton` with no capture slots. But advertising the // local's type to the typed-ABI closure specialization - // (`__typed_f64`/i32/…) made it read `js_closure_get_capture_bits(this, + // (`$typed_f64`/i32/…) made it read `js_closure_get_capture_bits(this, // 0)` — an UNSET slot (0) — while the generic variant correctly loads the // global; the dispatcher picked the typed body, so every closure returned // 0. Repro (bisected to #5466 representation lowering): @@ -2337,7 +2339,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> } // Representation-selection Phase 2: emit full-body specialized entries - // (`{public}__spec_...`, internal linkage) before the public bodies. Same + // (`{public}$spec_...`, internal linkage) before the public bodies. Same // real `compile_function`, parameterized on the plan's rep tuple. for f in &hir.functions { let Some(plan) = cross_module.spec_abi_functions.get(&f.id).cloned() else { diff --git a/crates/perry-codegen/src/codegen/number_exactness_tests.rs b/crates/perry-codegen/src/codegen/number_exactness_tests.rs index 4df248fcfb..c3308451d0 100644 --- a/crates/perry-codegen/src/codegen/number_exactness_tests.rs +++ b/crates/perry-codegen/src/codegen/number_exactness_tests.rs @@ -309,7 +309,7 @@ fn straight_line_number_function_takes_the_typed_f64_clone() { let ir = emitted_ir(vec![add_fn()]); assert!(!ir.contains("add_i64"), "{NO_I64_BODY}:\n{ir}"); assert!( - ir.contains("__typed_f64"), + ir.contains("$typed_f64"), "the typed-f64 clone must now be reachable for a plain `a + b`:\n{ir}" ); } diff --git a/crates/perry-codegen/src/codegen/spec_abi.rs b/crates/perry-codegen/src/codegen/spec_abi.rs index fa3a724eac..75ed895d72 100644 --- a/crates/perry-codegen/src/codegen/spec_abi.rs +++ b/crates/perry-codegen/src/codegen/spec_abi.rs @@ -70,7 +70,7 @@ pub(crate) fn spec_abi_max() -> usize { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SpecDispatch { /// Tier A: every non-Boxed slot proven BY CONSTRUCTION at the call site — - /// direct `call @{name}__spec_...` with raw args, no guard. + /// direct `call @{name}$spec_...` with raw args, no guard. Static, /// Tier B: reps proven only by declared types — the call site keeps the /// runtime-guarded diamond (guard → spec entry / boxed fallback → phi). @@ -93,12 +93,15 @@ pub(crate) fn spec_rep_llvm_ty(rep: SpecParamRep) -> LlvmType { } } -/// Specialized-entry symbol: `{public}__spec_