Skip to content
Merged
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
7 changes: 7 additions & 0 deletions changelog.d/8392-async-box-frame-cost.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
## Restore async frame release selectivity

Compiler-private async step closures no longer register their complete boxed
activation frame as escaped closure edges. Their queued and running lifetime is
already covered by the async activation token, so terminal cells can publish
as soon as that token drains. User closures created inside the step keep the
exact per-cell capture tracking added for #8213.
68 changes: 64 additions & 4 deletions crates/perry-codegen/src/expr/closure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,66 @@
//! `lower_expr`'s outer dispatch.

use anyhow::Result;
use perry_hir::Expr;
use perry_hir::{Expr, Stmt};

use crate::type_analysis::compute_auto_captures;
use crate::types::{DOUBLE, I32, I64, PTR};

use super::{lower_expr, nanbox_pointer_inline, FnCtx};

/// Whether this is the compiler-private step closure for a lowered plain
/// async activation. `ReleaseBoxes` is emitted only in that closure's
/// terminal arms; user-authored closures can never contain it.
///
/// Queued and running instances of this closure are already covered by the
/// activation token's refcount. Counting its boxed capture slots as escaping
/// GC-closure edges would make every cell in the complete activation frame
/// wait for a full collection, even when no user closure can observe it.
fn is_plain_async_step_body(stmts: &[Stmt]) -> bool {
stmts.iter().any(|stmt| match stmt {
Stmt::ReleaseBoxes(_) => true,
Stmt::If {
then_branch,
else_branch,
..
} => {
is_plain_async_step_body(then_branch)
|| else_branch.as_deref().is_some_and(is_plain_async_step_body)
}
Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => is_plain_async_step_body(body),
Stmt::For { init, body, .. } => {
init.as_deref()
.is_some_and(|stmt| is_plain_async_step_body(std::slice::from_ref(stmt)))
|| is_plain_async_step_body(body)
}
Stmt::Try {
body,
catch,
finally,
} => {
is_plain_async_step_body(body)
|| catch
.as_ref()
.is_some_and(|catch| is_plain_async_step_body(&catch.body))
|| finally.as_deref().is_some_and(is_plain_async_step_body)
}
Stmt::Switch { cases, .. } => cases
.iter()
.any(|case| is_plain_async_step_body(&case.body)),
Stmt::Labeled { body, .. } => is_plain_async_step_body(std::slice::from_ref(body.as_ref())),
Stmt::Let { .. }
| Stmt::Expr(_)
| Stmt::Return(_)
| Stmt::Break
| Stmt::Continue
| Stmt::LabeledBreak(_)
| Stmt::LabeledContinue(_)
| Stmt::Throw(_)
| Stmt::PreallocateBoxes(_)
| Stmt::PreallocateTdzBoxes(_) => false,
})
}

pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
match expr {
Expr::Closure {
Expand Down Expand Up @@ -324,16 +377,23 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// The captured-singleton helper writes captures internally. Boxed
// slots still take the dedicated, idempotent setter afterward so
// their lifetime edges are declared; fresh closures need every
// slot initialized here.
// slot initialized here. The compiler-private plain-async step
// closure is different: its activation refcount already covers
// every queued/running instance, so declaring its whole boxed
// frame as escaped would delay every terminal cell until a full
// GC. User closures nested inside it still take the dedicated
// setter and therefore preserve #8213's escaped-cell lifetime.
let is_plain_async_step = is_plain_async_step_body(body);
let boxed_capture_slots = auto_captures
.iter()
.map(|cap_id| ctx.boxed_vars.contains(cap_id))
.collect::<Vec<_>>();
let blk = ctx.block();
for (idx, val_bits) in captured_value_bits.iter().enumerate() {
if !captured_singleton || boxed_capture_slots[idx] {
let track_box_capture = boxed_capture_slots[idx] && !is_plain_async_step;
if !captured_singleton || track_box_capture {
let idx_str = idx.to_string();
let setter = if boxed_capture_slots[idx] {
let setter = if track_box_capture {
"js_closure_set_box_capture_ptr"
} else {
"js_closure_set_capture_bits"
Expand Down
57 changes: 57 additions & 0 deletions crates/perry-codegen/tests/release_boxes_lowering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,63 @@ fn release_boxes_lowers_through_closure_captures() {
"release must lower inside the step closure (missing `{call}`):\n{ir}"
);
}
assert!(
!ir.contains("call void @js_closure_set_box_capture_ptr("),
"the compiler-private step closure is covered by the activation refcount, \
so its complete frame must not become escaped GC-closure edges:\n{ir}"
);
}

/// #8213 still requires exact lifetime tracking for a user closure created by
/// the generated step. Only the nested user closure should declare an edge;
/// the step closure's own complete-frame captures are activation-owned.
#[test]
fn escaped_user_closure_inside_step_keeps_its_box_capture_edge() {
let mut body = activation_frame();
body.push(Stmt::Expr(Expr::Closure {
func_id: 900,
params: Vec::new(),
return_type: Type::Any,
body: vec![
Stmt::Expr(Expr::Closure {
func_id: 901,
params: Vec::new(),
return_type: Type::Any,
body: vec![Stmt::Return(Some(Expr::LocalGet(SENT)))],
captures: vec![SENT],
mutable_captures: Vec::new(),
captures_this: false,
captures_new_target: false,
enclosing_class: None,
is_arrow: true,
is_strict: false,
is_async: false,
is_generator: false,
}),
Stmt::ReleaseBoxes(vec![STATE, DONE, SENT]),
Stmt::Return(Some(Expr::Undefined)),
],
captures: vec![STATE, DONE, SENT],
mutable_captures: vec![STATE, DONE, SENT],
captures_this: false,
captures_new_target: false,
enclosing_class: None,
is_arrow: false,
is_strict: false,
is_async: false,
is_generator: false,
}));
body.push(Stmt::Return(Some(Expr::Undefined)));

let ir = ir_for_fn_body("release_nested_user_capture", body);
let tracked_edges = ir
.lines()
.filter(|line| line.contains("call void @js_closure_set_box_capture_ptr("))
.count();
assert_eq!(
tracked_edges, 1,
"only the nested user closure should retain the released box cell:\n{ir}"
);
}

/// A release with no visible cell must be a silent skip, not an error: the
Expand Down
Loading