Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog.d/9762-forward-const-tdz.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
### Fixes
- Preserve closure initializers that earlier closures capture, fixing false temporal-dead-zone errors in mutually recursive `const` functions. Genuine TDZ errors now name the source binding, including captured reads and updates. Fixes #9721.
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/codegen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ mod hoisted_callback_method_tests;
mod index_method_clone_tests;
mod indexed_method_artifacts;
mod ordinary_method_artifacts;
mod tdz_names;
// `pub(crate)` so `crate::linker` can read the inline-hot-small policy
// (`inline_hot_small_enabled` / `inline_hot_small_hint_threshold`).
pub(crate) mod helpers;
Expand Down Expand Up @@ -475,6 +476,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result<Vec<u8>>
// becomes part of every emitted global so multi-module programs
// don't collide on `.str.0.handle`.
let mut strings = StringPool::with_prefix(module_prefix.clone());
strings.tdz_binding_names = tdz_names::collect(hir);
// #5247: install per-module source-location context for the dynamic
// call-dispatch throw path, but only under `--debug-symbols` (which sets
// `opts.debug_locations` + `opts.module_source`). Off by default — no
Expand Down
184 changes: 184 additions & 0 deletions crates/perry-codegen/src/codegen/tdz_names.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
//! Preserve source names for checked reads of forward lexical boxes.
use std::collections::{HashMap, HashSet};

use perry_hir::{Expr, Function, Module, Stmt};

#[derive(Default)]
struct Names {
bindings: HashMap<u32, String>,
tdz: HashSet<u32>,
}

pub(super) fn collect(module: &Module) -> HashMap<u32, String> {
let mut names = Names::default();
names.stmts(&module.init);
for function in &module.functions {
names.function(function);
}
for class in &module.classes {
for function in class
.methods
.iter()
.chain(&class.static_methods)
.chain(class.getters.iter().map(|(_, f)| f))
.chain(class.setters.iter().map(|(_, f)| f))
.chain(class.constructor.iter())
.chain(class.computed_members.iter().map(|member| &member.function))
{
names.function(function);
}
for field in class.fields.iter().chain(&class.static_fields) {
for expr in field.init.iter().chain(&field.key_expr) {
names.expr(expr);
}
}
}
for global in &module.globals {
if let Some(init) = &global.init {
names.expr(init);
}
}
names.bindings.retain(|id, _| names.tdz.contains(id));
names.bindings
}

impl Names {
fn function(&mut self, function: &Function) {
self.stmts(&function.body);
for param in &function.params {
if let Some(default) = &param.default {
self.expr(default);
}
}
}

fn expr(&mut self, expr: &Expr) {
if let Expr::Closure { body, .. } = expr {
self.stmts(body);
}
perry_hir::walker::walk_expr_children(expr, &mut |child| self.expr(child));
}

fn stmts(&mut self, stmts: &[Stmt]) {
for stmt in stmts {
match stmt {
Stmt::Let { id, name, init, .. } => {
self.bindings.insert(*id, name.clone());
if let Some(init) = init {
self.expr(init);
}
}
Stmt::PreallocateTdzBoxes(ids) => self.tdz.extend(ids),
Stmt::Expr(expr) | Stmt::Throw(expr) => self.expr(expr),
Stmt::Return(expr) => {
if let Some(expr) = expr {
self.expr(expr);
}
}
Stmt::If {
condition,
then_branch,
else_branch,
} => {
self.expr(condition);
self.stmts(then_branch);
if let Some(branch) = else_branch {
self.stmts(branch);
}
}
Stmt::While { condition, body } | Stmt::DoWhile { condition, body } => {
self.expr(condition);
self.stmts(body);
}
Stmt::For {
init,
condition,
update,
body,
} => {
if let Some(init) = init {
self.stmts(std::slice::from_ref(init));
}
for expr in condition.iter().chain(update) {
self.expr(expr);
}
self.stmts(body);
}
Stmt::Labeled { body, .. } => self.stmts(std::slice::from_ref(body)),
Stmt::Try {
body,
catch,
finally,
} => {
self.stmts(body);
if let Some(catch) = catch {
self.stmts(&catch.body);
}
if let Some(finally) = finally {
self.stmts(finally);
}
}
Stmt::Switch {
discriminant,
cases,
} => {
self.expr(discriminant);
for case in cases {
if let Some(test) = &case.test {
self.expr(test);
}
self.stmts(&case.body);
}
}
Stmt::Break
| Stmt::Continue
| Stmt::LabeledBreak(_)
| Stmt::LabeledContinue(_)
| Stmt::PreallocateBoxes(_)
| Stmt::ReleaseBoxes(_) => {}
}
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use perry_hir::types::Type;

#[test]
fn collects_nested_lexical_names_without_naming_ordinary_boxes() {
let local = |id, name: &str| Stmt::Let {
id,
name: name.into(),
ty: Type::Any,
mutable: true,
init: None,
};
let mut hir = Module::new("names");
hir.init = vec![
Stmt::PreallocateBoxes(vec![0]),
Stmt::PreallocateTdzBoxes(vec![1]),
local(0, "ordinary"),
local(1, "later"),
Stmt::Expr(Expr::Closure {
func_id: 0,
params: Vec::new(),
return_type: Type::Any,
body: vec![Stmt::PreallocateTdzBoxes(vec![2]), local(2, "nested")],
captures: Vec::new(),
mutable_captures: Vec::new(),
captures_this: false,
captures_new_target: false,
enclosing_class: None,
is_arrow: true,
is_async: false,
is_generator: false,
is_strict: true,
}),
];
let mut names = super::collect(&hir).into_values().collect::<Vec<_>>();
names.sort();
assert_eq!(names, ["later", "nested"]);
}
}
26 changes: 26 additions & 0 deletions crates/perry-codegen/src/codegen/trusted_box_callback_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,9 +186,18 @@ fn select(closures: Vec<(u32, Expr)>, direct: impl IntoIterator<Item = u32>) ->
}

fn emit(direct_literal: bool) -> String {
emit_with_tdz(direct_literal, false)
}

fn emit_with_tdz(direct_literal: bool, tdz: bool) -> String {
let mut module = Module::new("trusted_box_callback.ts");
module.init_kind = ModuleInitKind::Eager;
module.functions = vec![consume_function(), outer_function(direct_literal)];
if tdz {
module.functions[1]
.body
.insert(0, Stmt::PreallocateTdzBoxes(vec![COUNT]));
}
module.init.push(Stmt::Expr(Expr::Call {
callee: Box::new(Expr::FuncRef(3)),
args: Vec::new(),
Expand Down Expand Up @@ -260,6 +269,23 @@ fn named_block_body<'a>(function: &'a str, prefix: &str) -> String {
.join("\n")
}

#[test]
fn named_tdz_reads_reach_public_and_trusted_callbacks() {
let ir = emit_with_tdz(true, true);
let public = function_body(&ir, "perry_closure_trusted_box_callback_ts__99");
let trusted = function_body(
&ir,
"perry_closure_trusted_box_callback_ts__99$trusted_boxes",
);
assert!(public.contains("@js_box_get_bits_named("), "{public}");
let cold = named_block_body(&trusted, "trusted_box.tdz");
assert!(cold.contains("@js_box_get_bits_trusted_named("), "{cold}");
assert!(
ir.contains("c\"count\\00\""),
"binding name must be in the string pool"
);
}

#[test]
fn direct_arrow_gets_a_private_body_but_keeps_the_public_validation_path() {
let ir = emit(true);
Expand Down
62 changes: 39 additions & 23 deletions crates/perry-codegen/src/expr/literals_vars.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,41 @@ use super::{
TrustedBoxCapturePtr,
};

/// Only TDZ-capable source bindings need a named accessor. Ordinary boxes
/// retain their existing ABI; trusted inline loads pass the name only on
/// their cold TDZ arm. Names come from permanent, GC-rooted string globals.
fn emit_box_read(ctx: &mut FnCtx<'_>, id: u32, ptr: &str, trusted: bool) -> String {
let base = if trusted {
"js_box_get_bits_trusted"
} else {
"js_box_get_bits"
};
if let Some(name) = ctx.strings.tdz_binding_names.get(&id).cloned() {
let index = ctx.strings.intern(&name);
let global = format!("@{}", ctx.strings.entry(index).handle_global);
let name = ctx.block().load(DOUBLE, &global);
ctx.block().call(
I64,
&format!("{base}_named"),
&[(I64, ptr), (DOUBLE, &name)],
)
} else {
ctx.block().call(I64, base, &[(I64, ptr)])
}
}

/// Load the current value from a compiler-proven raw box capture.
///
/// The exact-arrow resolver has already validated `capture.ptr`, so the hot
/// path is a direct cell load. Preserve lexical TDZ behavior with a cold call
/// to the existing trusted accessor only for the reserved sentinel; that
/// helper owns both ReferenceError construction and Perry's internal TDZ
/// suppression window semantics.
fn load_trusted_box_capture_bits(ctx: &mut FnCtx<'_>, capture: &TrustedBoxCapturePtr) -> String {
fn load_trusted_box_capture_bits(
ctx: &mut FnCtx<'_>,
id: u32,
capture: &TrustedBoxCapturePtr,
) -> String {
let bits = ctx.block().load(I64, &capture.ptr);
let is_tdz = ctx.block().icmp_eq(I64, &bits, crate::nanbox::TAG_TDZ_I64);
let slow_idx = ctx.new_block("trusted_box.tdz");
Expand All @@ -47,9 +74,7 @@ fn load_trusted_box_capture_bits(ctx: &mut FnCtx<'_>, capture: &TrustedBoxCaptur
// before entering that observable cold arm, just like a PIC miss or
// dynamic `+` fallback.
crate::expr::emit_versioned_loop_callback_deopt(ctx);
let slow_bits = ctx
.block()
.call(I64, "js_box_get_bits_trusted", &[(I64, &capture.bits)]);
let slow_bits = emit_box_read(ctx, id, &capture.bits, true);
let slow_end = ctx.block().label.clone();
ctx.block().br(&merge_label);

Expand Down Expand Up @@ -474,22 +499,16 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// and deref via js_box_get_bits.
if ctx.boxed_vars.contains(id) {
if let Some(capture) = ctx.trusted_box_capture_ptrs.get(id).cloned() {
let bits = load_trusted_box_capture_bits(ctx, &capture);
let bits = load_trusted_box_capture_bits(ctx, *id, &capture);
let value = ctx.block().bitcast_i64_to_double(&bits);
demote_extracted_string_binding(ctx, *id, &value);
return Ok(value);
}
let closure_ptr =
super::current_closure_ptr_value(ctx, "captured boxed local")?;
let getter = if ctx.trusted_box_captures {
"js_box_get_bits_trusted"
} else {
"js_box_get_bits"
};
let box_ptr = load_closure_capture_bits_inline(ctx, &closure_ptr, capture_idx);
let blk = ctx.block();
let bits = blk.call(I64, getter, &[(I64, &box_ptr)]);
let value = blk.bitcast_i64_to_double(&bits);
let bits = emit_box_read(ctx, *id, &box_ptr, ctx.trusted_box_captures);
let value = ctx.block().bitcast_i64_to_double(&bits);
demote_extracted_string_binding(ctx, *id, &value);
return Ok(value);
}
Expand Down Expand Up @@ -519,8 +538,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
if let Some(slot) = ctx.locals.get(id).cloned() {
let blk = ctx.block();
let box_ptr = blk.load(I64, &slot);
let bits = blk.call(I64, "js_box_get_bits", &[(I64, &box_ptr)]);
let value = blk.bitcast_i64_to_double(&bits);
let bits = emit_box_read(ctx, *id, &box_ptr, false);
let value = ctx.block().bitcast_i64_to_double(&bits);
demote_extracted_string_binding(ctx, *id, &value);
return Ok(value);
}
Expand Down Expand Up @@ -959,7 +978,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// nested user frame `coerce_old`/`step_new` may enter.
if ctx.boxed_vars.contains(id) {
if let Some(capture) = ctx.trusted_box_capture_ptrs.get(id).cloned() {
let old_bits = load_trusted_box_capture_bits(ctx, &capture);
let old_bits = load_trusted_box_capture_bits(ctx, *id, &capture);
let old = ctx.block().bitcast_i64_to_double(&old_bits);
if needs_numeric_coerce && ctx.versioned_loop_deopt_context.is_some() {
let is_number = crate::stmt::emit_js_value_is_number(ctx, &old);
Expand Down Expand Up @@ -1016,11 +1035,6 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
}
let closure_ptr =
super::current_closure_ptr_value(ctx, "captured boxed local update")?;
let getter = if ctx.trusted_box_captures {
"js_box_get_bits_trusted"
} else {
"js_box_get_bits"
};
let setter = if ctx.trusted_box_captures {
"js_box_set_bits_trusted_no_barrier"
} else {
Expand All @@ -1032,7 +1046,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
"js_closure_get_capture_bits",
&[(I64, &closure_ptr), (I32, &idx_str)],
);
let old_bits = blk.call(I64, getter, &[(I64, &box_ptr)]);
let old_bits = emit_box_read(ctx, *id, &box_ptr, ctx.trusted_box_captures);
let blk = ctx.block();
let old = blk.bitcast_i64_to_double(&old_bits);
let old = coerce_old(blk, &old);
let new = step_new(blk, &old);
Expand Down Expand Up @@ -1086,7 +1101,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
if let Some(slot) = ctx.locals.get(id).cloned() {
let blk = ctx.block();
let box_ptr = blk.load(I64, &slot);
let old_bits = blk.call(I64, "js_box_get_bits", &[(I64, &box_ptr)]);
let old_bits = emit_box_read(ctx, *id, &box_ptr, false);
let blk = ctx.block();
let old = blk.bitcast_i64_to_double(&old_bits);
let old = coerce_old(blk, &old);
let new = step_new(blk, &old);
Expand Down
7 changes: 6 additions & 1 deletion crates/perry-codegen/src/gc_call_effects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -872,7 +872,12 @@ mod tests {
/// symbol can be admitted; this one cannot.
#[test]
fn the_tdz_capable_box_getter_stays_a_safepoint() {
for name in ["js_box_get_bits", "js_box_get_bits_trusted"] {
for name in [
"js_box_get_bits",
"js_box_get_bits_trusted",
"js_box_get_bits_named",
"js_box_get_bits_trusted_named",
] {
assert_eq!(
classify_direct_callee(name),
GcCallEffect::Unknown,
Expand Down
Loading
Loading