From 9b11dbf68b14ae501959179037cbfeff057c22c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 5 Sep 2026 15:37:39 +0200 Subject: [PATCH] fix(codegen): retain typed-array owners across specialized calls --- .../9782-specialized-typedarray-lifetime.md | 5 + .../perry-codegen/src/lower_call/func_ref.rs | 17 +++ .../harness_self_tests.rs | 10 ++ .../src/native_root_coverage/mod.rs | 8 +- .../native_root_coverage/specialized_calls.rs | 101 ++++++++++++++++++ ...ap_9782_specialized_typedarray_last_use.ts | 22 ++++ 6 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 changelog.d/9782-specialized-typedarray-lifetime.md create mode 100644 crates/perry-codegen/src/native_root_coverage/specialized_calls.rs create mode 100644 test-files/test_gap_9782_specialized_typedarray_last_use.ts diff --git a/changelog.d/9782-specialized-typedarray-lifetime.md b/changelog.d/9782-specialized-typedarray-lifetime.md new file mode 100644 index 0000000000..ca25b4bd9e --- /dev/null +++ b/changelog.d/9782-specialized-typedarray-lifetime.md @@ -0,0 +1,5 @@ +Keep a typed array alive through a specialized function call even when preparing +that call is the caller's last use of the array. The raw-pointer calling +convention still avoids repeated type checks, while native GC roots retain the +owner until the callee returns. This fixes collected typed-array storage and +incorrect checksums in all five full-collection representation stress arms. diff --git a/crates/perry-codegen/src/lower_call/func_ref.rs b/crates/perry-codegen/src/lower_call/func_ref.rs index d69e121e22..2b41338717 100644 --- a/crates/perry-codegen/src/lower_call/func_ref.rs +++ b/crates/perry-codegen/src/lower_call/func_ref.rs @@ -145,6 +145,21 @@ fn try_emit_spec_static_call( raw_args_storage } + // TaPtr entries hoist raw header/data pointers and rely on caller roots. + // The caller's binding can otherwise die at its last use while preparing + // this call: native GC liveness follows SSA uses, not lexical scope. Keep + // the boxed owner live through the call, even when no later JS read exists. + fn keep_ta_owners_alive(ctx: &mut FnCtx<'_>, raw_plan: &[RawArg], lowered: &[String]) { + for entry in raw_plan { + if let RawArg::TaPtr(i) = entry { + let bits = ctx.block().bitcast_double_to_i64(&lowered[*i]); + ctx.block().emit_raw(format!( + "call void asm sideeffect \"\", \"r\"(i64 {bits}) \"gc-leaf-function\"" + )); + } + } + } + let check_descriptors = matches!(plan.dispatch, crate::codegen::SpecDispatch::Static); if !range_checked.is_empty() || (check_descriptors && plan.guards.iter().any(Option::is_some)) { // One diamond for the whole call: every range-checked slot's test is @@ -206,6 +221,7 @@ fn try_emit_spec_static_call( .map(|(ty, v)| (*ty, v.as_str())) .collect(); let fast_value = ctx.block().call(DOUBLE, &spec_name, &call_args); + keep_ta_owners_alive(ctx, &raw_plan, lowered); let after_fast = ctx.block().label.clone(); if !ctx.block().is_terminated() { ctx.block().br(&merge_label); @@ -263,6 +279,7 @@ fn try_emit_spec_static_call( .map(|(ty, v)| (*ty, v.as_str())) .collect(); let result = ctx.block().call(DOUBLE, &spec_name, &call_args); + keep_ta_owners_alive(ctx, &raw_plan, lowered); ctx.record_lowered_value( "Call", None, diff --git a/crates/perry-codegen/src/native_root_coverage/harness_self_tests.rs b/crates/perry-codegen/src/native_root_coverage/harness_self_tests.rs index 81c7f60f54..3f3cac5fe9 100644 --- a/crates/perry-codegen/src/native_root_coverage/harness_self_tests.rs +++ b/crates/perry-codegen/src/native_root_coverage/harness_self_tests.rs @@ -205,3 +205,13 @@ fn no_root_alloca_survives_the_statepoint_rewrite() { ); } } + +#[test] +fn the_statepoint_parser_accepts_quoted_specialized_callees() { + let name = format!("perry_fn_probe${}", "spec_ta4x256"); + let fixture = FIXTURE.replace("@js_array_alloc", &format!("@\"{name}\"")); + let line = fixture.lines().find(|line| line.contains(&name)).unwrap(); + let point = super::parse_statepoint(line); + assert_eq!(point.callee, name); + assert_eq!(point.live, vec!["%a", "%b"]); +} diff --git a/crates/perry-codegen/src/native_root_coverage/mod.rs b/crates/perry-codegen/src/native_root_coverage/mod.rs index e1624f5f4c..d4e6ba1852 100644 --- a/crates/perry-codegen/src/native_root_coverage/mod.rs +++ b/crates/perry-codegen/src/native_root_coverage/mod.rs @@ -98,6 +98,7 @@ use perry_hir::{Expr, Function, Module, ModuleInitKind, Param, Stmt}; mod harness_self_tests; mod mechanics; +mod specialized_calls; /// The two targets native roots ship on, one per object format and /// architecture. Pinned rather than host-derived — see the module docs. @@ -508,8 +509,11 @@ fn callee_after_elementtype(line: &str) -> Option { let group = group_after(line, "elementtype(")?; let after = line[line.find("elementtype(")? + "elementtype(".len() + group.len() + 1..].trim_start(); - let name: String = after - .strip_prefix('@')? + // LLVM quotes names containing the specialized-entry separator `$`. + let after_at = after.strip_prefix('@')?; + let name: String = after_at + .strip_prefix('"') + .unwrap_or(after_at) .chars() .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '$')) .collect(); diff --git a/crates/perry-codegen/src/native_root_coverage/specialized_calls.rs b/crates/perry-codegen/src/native_root_coverage/specialized_calls.rs new file mode 100644 index 0000000000..0454cef049 --- /dev/null +++ b/crates/perry-codegen/src/native_root_coverage/specialized_calls.rs @@ -0,0 +1,101 @@ +//! #9782: a raw typed-array argument still needs its boxed owner alive in +//! the caller while the specialized callee executes. + +use super::*; + +#[test] +fn a_last_use_typed_array_is_live_across_the_specialized_call() { + let mut module = bare_module("last_use_typed_array.ts"); + module.functions.push(Function { + id: 1, + name: "consume".to_string(), + type_params: Vec::new(), + params: vec![Param { + id: 10, + name: "array".to_string(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }], + return_type: Type::Number, + body: vec![ + Stmt::Expr(Expr::MapNew), + Stmt::Return(Some(Expr::IndexGet { + object: Box::new(Expr::LocalGet(10)), + index: Box::new(Expr::Integer(0)), + })), + ], + 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, + }); + module.init = vec![ + let_stmt( + 20, + "owner", + Expr::TypedArrayNew { + kind: perry_hir::TYPED_ARRAY_KIND_INT32, + arg: Some(Box::new(Expr::Integer(256))), + }, + ), + Stmt::Expr(Expr::Call { + callee: Box::new(Expr::FuncRef(1)), + args: vec![Expr::LocalGet(20)], + type_args: Vec::new(), + byte_offset: 0, + }), + ]; + + for target in NATIVE_TARGETS { + let ir = native_ir(&module, target, true); + let prefix = format!("perry_fn_last_use_typed_array_ts__consume${}", "spec_"); + let specialized = ir + .lines() + .filter(|line| line.starts_with("define ")) + .filter_map(|line| line.split_once('@')?.1.split_once('(').map(|p| p.0)) + .find(|name| name.starts_with(&prefix)) + .expect("fixture must select a raw typed-array entry"); + let points = statepoints_of(&ir, target, "main"); + for point in points.at(specialized) { + assert!( + !point.live.is_empty(), + "[{target}] raw argument lost its owner: {point:?}" + ); + } + + // Move the lifetime use (and its reloads) above the raw call. Merely + // deleting the asm leaves dead post-call SSA uses that RS4GC still + // considers live before the later dead-code elimination pass. + let mut lines: Vec<_> = ir.lines().collect(); + let call = lines + .iter() + .position(|line| line.contains(&format!("call double @{specialized}("))) + .expect("fixture must call its specialized entry"); + let end = lines + .iter() + .enumerate() + .skip(call + 1) + .find_map(|(i, line)| { + line.contains("call void asm sideeffect \"\", \"r\"(i64") + .then_some(i) + }) + .expect("post-call lifetime use must exist"); + let raw_call = lines.remove(call); + lines.insert(end, raw_call); + let broken = lines.join("\n"); + let broken_points = statepoints_of(&broken, target, "main"); + for point in broken_points.at(specialized) { + assert!( + point.live.is_empty(), + "[{target}] control still roots the owner: {point:?}" + ); + } + } +} diff --git a/test-files/test_gap_9782_specialized_typedarray_last_use.ts b/test-files/test_gap_9782_specialized_typedarray_last_use.ts new file mode 100644 index 0000000000..ec3e8c1ad8 --- /dev/null +++ b/test-files/test_gap_9782_specialized_typedarray_last_use.ts @@ -0,0 +1,22 @@ +// A specialized callee hoists the typed-array data pointer. Its caller must +// retain the array even when the call is the binding's last source-level use. +// Also run with PERRY_GC_HEAP_LIMIT=8 PERRY_GEN_GC=0 to force full collections. +let sink: any[] = []; +function sumDuringChurn(buf: Int32Array, count: number): number { + let sum = 0; + for (let i = 0; i < count; i++) { + sink.push({ i, text: "churn-" + i, pair: [i, i + 1] }); + if (sink.length > 4096) sink = []; + sum = (sum + buf[i & 255]) | 0; + } + return sum; +} +function localOwner(): number { + const local = new Int32Array(256); + for (let i = 0; i < 256; i++) local[i] = i; + return sumDuringChurn(local, 320000); +} +const top = new Int32Array(256); +for (let i = 0; i < 256; i++) top[i] = i; +console.log("module-last-use", sumDuringChurn(top, 320000)); +console.log("local-last-use", localOwner());