Skip to content

fix(runtime): Next.js standalone wall 45 — dynamic-parent capture corruption#5152

Merged
proggeramlug merged 2 commits into
mainfrom
feat/nextjs-wall-45
Jun 14, 2026
Merged

fix(runtime): Next.js standalone wall 45 — dynamic-parent capture corruption#5152
proggeramlug merged 2 commits into
mainfrom
feat/nextjs-wall-45

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

What

class Derived extends _base.default (the interop-ESM default-export base shape — wall 38) read the parent's captured module-level requires (this.__perry_cap_*) as garbage numbers/functions when accessed from inherited methods. In Next.js this surfaced as (0,_iserror.getProperError) on an undefined capture inside base-server's handleRequestImpl.

Two composing root causes

1. Argument truncation in call_vtable_method (the masking bug)

The runtime vtable dispatcher had explicit arity arms only for 0–9 params; the _ => catch-all invoked every higher-arity function as if it had exactly 10 params, silently dropping args 10+. A base class capturing 40 module-level requires compiles to a 41-param constructor (opts + 40 __perry_cap_*); run on a derived instance via run_class_constructor_on_this_flatcall_vtable_method, only the first ~9 captures were passed — the rest wrote uninitialized stack/register garbage.

Widened the catch-all to a fixed 64-arity transmute with undefined padding. Passing more args than the callee declares is safe on every target (caller-allocated, caller-cleaned arg area; the callee reads only its declared params). A debug_assert flags the rare class exceeding 64 so truncation surfaces in tests, not as silent corruption. This is the vtable-dispatch counterpart to the closure path's existing 16-arity fan-out.

2. Under-allocation + mis-layout of dynamic-parent instances

Because Derived's parent resolves dynamically (extends_name is the unresolved "default", so class_field_global_index's parent walk bails), codegen sized the instance for Derived's OWN fields only (max(1,8) slots) and laid inherited fields at the wrong indices. New runtime allocator js_object_alloc_class_dynamic_parent resolves the runtime-registered parent edge (js_register_class_parent_dynamic) + the parent's keys-array (js_build_class_keys_array) — both established at module init, before any new X() — and allocates the merged field_count = parent + own, keys = [parent keys] ++ [own keys] (parent first, matching the slot order the parent's compiled code expects). new.rs routes extends_expr classes to it; merged keys are shape-cached per class.

Verification

  • Wall-45 repro (40-capture base extended via _mod.default): all 40 captures read as object from inherited methods (was garbage for captures 10+). Same-module direct new Base() unaffected throughout.
  • Wall 38/42 repros green (base ctor runs, super.method() dispatches).
  • perry-runtime tests: 1035 passed / 0 failed single-threaded (the 3 parallel failures are a pre-existing PoisonError pollution cascade in untouched modules — all pass isolated).

Part of the Next.js standalone bring-up (#793 umbrella; continues #5125).

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Fixed parameter handling for functions with more than 9 arguments; missing parameters now correctly evaluate to undefined instead of incorrect values.
  • Performance

    • Optimized object allocation for subclasses with dynamically-determined parent classes, improving memory layout and allocation efficiency.

@coderabbitai

coderabbitai Bot commented Jun 14, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 07ef7e51-648d-46ab-8449-68cc21e5288a

📥 Commits

Reviewing files that changed from the base of the PR and between fa2dbf1 and 9828605.

📒 Files selected for processing (4)
  • crates/perry-codegen/src/lower_call/new.rs
  • crates/perry-codegen/src/runtime_decls/strings.rs
  • crates/perry-runtime/src/object/alloc.rs
  • crates/perry-runtime/src/object/class_registry.rs
🚧 Files skipped from review as they are similar to previous changes (4)
  • crates/perry-codegen/src/runtime_decls/strings.rs
  • crates/perry-codegen/src/lower_call/new.rs
  • crates/perry-runtime/src/object/class_registry.rs
  • crates/perry-runtime/src/object/alloc.rs

📝 Walkthrough

Walkthrough

Adds js_object_alloc_class_dynamic_parent, a runtime allocator that merges parent and own field layouts for subclasses with dynamically-resolved parents. The codegen lower_new emits this allocator when extends_expr is present, and the vtable dispatcher's catch-all arm is widened from a truncated path to a 64-argument transmute with arg_or_undefined padding.

Dynamic-parent subclass allocation and vtable arity fixes

Layer / File(s) Summary
Vtable dispatcher catch-all arm widened to 64 parameters
crates/perry-runtime/src/object/class_registry.rs
The default arm for param_count > 9 is replaced with a debug_assert!(param_count <= 64) guard, a transmute to a 64-argument function signature, and arg_or_undefined calls filling slots 10 through 63 with TAG_UNDEFINED padding.
Runtime allocator for dynamic-parent subclasses
crates/perry-runtime/src/object/alloc.rs
js_object_alloc_class_dynamic_parent resolves the parent's registered keys array, builds or reuses a shape-cached merged keys array in parent-first order, allocates an object with merged field capacity, initializes all slots to undefined, and registers the merged array. A #[used] keepalive static prevents dead-stripping.
Codegen branch and runtime declaration
crates/perry-codegen/src/runtime_decls/strings.rs, crates/perry-codegen/src/lower_call/new.rs
declare_phase_b_strings declares js_object_alloc_class_dynamic_parent with (class_id, own_field_count, own_packed_keys, own_packed_keys_len) -> I64. lower_new adds an early branch for extends_expr.is_some() that interns packed non-computed field keys and emits the call.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • PerryTS/perry#5112: Both PRs modify lower_new in crates/perry-codegen/src/lower_call/new.rs, adding separate special-case allocation branches for different class construction scenarios.

Poem

A bunny hops through parent chains so wide,
No field left unknown, no slot left aside,
Sixty-four args? No problem at all!
Merged keys are cached before the first call.
🐇 The registry knows where every parent hides~

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed Title clearly identifies the primary fix: addressing Next.js standalone wall 45 dynamic-parent capture corruption, directly matching the core issue described in the PR.
Description check ✅ Passed Description comprehensively covers the problem, root causes, solution, and verification, but omits the required test plan checklist items that are part of the template.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/nextjs-wall-45

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/perry-runtime/src/object/class_registry.rs (1)

4298-4435: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enforce the 64-argument ceiling in release builds too.

debug_assert! disappears in optimized builds, so a ctor/method with 65+ params still enters this arm and is invoked with only 64 actual args. That reopens the same truncation/garbage-read corruption this patch is fixing, just above a higher threshold. Make this a hard invariant at runtime, or reject oversized registrations upstream.

Possible fix
-            debug_assert!(
+            assert!(
                 param_count as usize <= 64,
                 "call_vtable_method: param_count {} exceeds fixed dispatch arity 64",
                 param_count
             );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/object/class_registry.rs` around lines 4298 - 4435,
Replace the debug_assert! that checks param_count in the call_vtable_method
function with a runtime assertion that will execute even in release builds.
Change debug_assert! to assert! to enforce the 64-argument ceiling as a hard
invariant, ensuring that methods with 65+ parameters cannot bypass this check in
optimized builds and cause memory corruption from reading garbage data or
out-of-bounds memory.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-runtime/src/object/alloc.rs`:
- Around line 427-507: The merged path in this function derives field_count from
the merged_arr.length (via merged_len), but it should instead compute
field_count by adding the registered parent field count (parent_fc) and
own_field_count parameters. Currently, the registered field counts (parent_fc
extracted from registered_class_keys_array and own_field_count passed as a
parameter) are retrieved but never used in the merged path, causing the
allocated object to be sized based on visible key count rather than logical slot
count. Replace the line that sets field_count based on merged_len with a
calculation that adds parent_fc and own_field_count to ensure the object
allocation matches the slot indexing that codegen will perform.

---

Outside diff comments:
In `@crates/perry-runtime/src/object/class_registry.rs`:
- Around line 4298-4435: Replace the debug_assert! that checks param_count in
the call_vtable_method function with a runtime assertion that will execute even
in release builds. Change debug_assert! to assert! to enforce the 64-argument
ceiling as a hard invariant, ensuring that methods with 65+ parameters cannot
bypass this check in optimized builds and cause memory corruption from reading
garbage data or out-of-bounds memory.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 698f97a2-923b-4549-a67f-b37b23ece83e

📥 Commits

Reviewing files that changed from the base of the PR and between f493cd3 and fa2dbf1.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • CLAUDE.md
  • Cargo.toml
  • crates/perry-codegen/src/lower_call/new.rs
  • crates/perry-codegen/src/runtime_decls/strings.rs
  • crates/perry-runtime/src/object/alloc.rs
  • crates/perry-runtime/src/object/class_registry.rs

Comment on lines +427 to +507
let parent_cid = crate::object::get_parent_class_id(class_id).unwrap_or(0);
let parent_keys = if parent_cid != 0 {
registered_class_keys_array(parent_cid)
} else {
None
};
let Some((parent_arr, _parent_fc)) = parent_keys else {
// No dynamic parent layout available — own-only fallback keeps the
// prior baseline (correct for parentless / builtin-parent classes).
return js_object_alloc_class_with_keys(
class_id,
parent_cid,
own_field_count,
own_packed_keys,
own_packed_keys_len,
);
};
let parent_len = unsafe { (*parent_arr).length };

// Cache the merged keys-array per class. The shape id is namespaced away
// from the own-only shape (`+ 2_000_000`) so it can't collide with the
// `js_build_class_keys_array` / `js_object_alloc_class_with_keys` shapes.
let shape_id = class_id.wrapping_mul(10007).wrapping_add(2_000_000);
let cached = shape_cache_get(shape_id);
let (merged_arr, field_count) = if !cached.is_null() {
(cached, unsafe { (*cached).length })
} else {
let own_keys: Vec<&[u8]> = if own_packed_keys.is_null() || own_packed_keys_len == 0 {
Vec::new()
} else {
let bytes = unsafe {
std::slice::from_raw_parts(own_packed_keys, own_packed_keys_len as usize)
};
bytes.split(|&b| b == 0).filter(|s| !s.is_empty()).collect()
};
let merged_len = parent_len as usize + own_keys.len();
let arr = crate::array::js_array_alloc_with_length_longlived(merged_len as u32);
let dst = unsafe { (arr as *mut u8).add(8) as *mut f64 };
let src = unsafe { (parent_arr as *mut u8).add(8) as *const f64 };
unsafe {
for i in 0..parent_len as usize {
let bits = (*src.add(i)).to_bits();
*dst.add(i) = f64::from_bits(bits);
crate::array::note_array_slot_layout_only(arr, i, bits);
}
for (j, key_bytes) in own_keys.iter().enumerate() {
let str_ptr = crate::string::js_string_from_bytes_longlived(
key_bytes.as_ptr(),
key_bytes.len() as u32,
);
let nanboxed = f64::from_bits(
crate::value::STRING_TAG | (str_ptr as u64 & crate::value::POINTER_MASK),
);
let idx = parent_len as usize + j;
*dst.add(idx) = nanboxed;
crate::array::note_array_slot_layout_only(arr, idx, nanboxed.to_bits());
}
}
shape_cache_insert(shape_id, arr);
(arr, merged_len as u32)
};

let header_size = std::mem::size_of::<ObjectHeader>();
let alloc_field_count = std::cmp::max(field_count as usize, 8);
let fields_size = alloc_field_count * std::mem::size_of::<JSValue>();
let total_size = header_size + fields_size;
let ptr = arena_alloc_gc(total_size, 8, crate::gc::GC_TYPE_OBJECT) as *mut ObjectHeader;
unsafe {
(*ptr).object_type = crate::error::OBJECT_TYPE_REGULAR;
(*ptr).class_id = class_id;
(*ptr).parent_class_id = parent_cid;
(*ptr).field_count = field_count;
let fields_ptr = (ptr as *mut u8).add(header_size) as *mut JSValue;
for i in 0..alloc_field_count {
// GC_STORE_AUDIT(INIT): freshly allocated object field slot is initialized pointer-free.
ptr::write(fields_ptr.add(i), JSValue::undefined());
}
set_object_keys_array(ptr, merged_arr);
crate::gc::layout_init_pointer_free(ptr as *mut u8);
}
remember_class_keys_array(class_id, field_count, merged_arr);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use the registered field counts to size the merged instance.

The merged path throws away both parent_fc and own_field_count and instead derives field_count from keys_array.length. That only works while visible key count exactly matches logical slot count. If those ever diverge, this allocator under-sizes the object/header while codegen still indexes slots by parent_fc + own_field_count, recreating the layout mismatch this function is meant to fix.

Possible fix
-    let Some((parent_arr, _parent_fc)) = parent_keys else {
+    let Some((parent_arr, parent_fc)) = parent_keys else {
         // No dynamic parent layout available — own-only fallback keeps the
         // prior baseline (correct for parentless / builtin-parent classes).
         return js_object_alloc_class_with_keys(
             class_id,
             parent_cid,
@@
-    let (merged_arr, field_count) = if !cached.is_null() {
-        (cached, unsafe { (*cached).length })
+    let field_count = parent_fc.saturating_add(own_field_count);
+    let merged_arr = if !cached.is_null() {
+        cached
     } else {
@@
-        let merged_len = parent_len as usize + own_keys.len();
+        let merged_len = parent_len as usize + own_keys.len();
+        debug_assert_eq!(merged_len as u32, field_count);
         let arr = crate::array::js_array_alloc_with_length_longlived(merged_len as u32);
@@
-        (arr, merged_len as u32)
+        arr
     };
+    debug_assert_eq!(unsafe { (*merged_arr).length }, field_count);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/object/alloc.rs` around lines 427 - 507, The merged
path in this function derives field_count from the merged_arr.length (via
merged_len), but it should instead compute field_count by adding the registered
parent field count (parent_fc) and own_field_count parameters. Currently, the
registered field counts (parent_fc extracted from registered_class_keys_array
and own_field_count passed as a parameter) are retrieved but never used in the
merged path, causing the allocated object to be sized based on visible key count
rather than logical slot count. Replace the line that sets field_count based on
merged_len with a calculation that adds parent_fc and own_field_count to ensure
the object allocation matches the slot indexing that codegen will perform.

Ralph Küpper added 2 commits June 14, 2026 21:34
…ruption (v0.5.1169)

class Derived extends _base.default read inherited __perry_cap_* captures as
garbage from inherited methods. Two composing roots:

1. call_vtable_method truncated any >9-param call to 10 args (the _ => catch-all
   invoked higher-arity fns as if they had 10 params), so a base ctor with
   opts + 40 capture params lost captures 10+ when run via the runtime dispatch
   path (run_class_constructor_on_this_flat). Widened to a 64-arity transmute
   with undefined padding (overshooting args is caller-cleaned/safe), with a
   debug_assert backstop. Mirrors the closure path's 16-arity fan-out.

2. Dynamic-parent instances were sized for own fields only and laid inherited
   fields out at wrong slot indices. New js_object_alloc_class_dynamic_parent
   resolves the runtime-registered parent edge + keys-array and allocates the
   merged [parent keys] ++ [own keys] layout; new.rs routes extends_expr classes
   to it. Merged keys shape-cached per class.

Repro: 40-capture base extended via _mod.default now reads all captures as
object from inherited methods. Wall 38/42 green. perry-runtime 1035/0
single-threaded.
@proggeramlug proggeramlug force-pushed the feat/nextjs-wall-45 branch from fa2dbf1 to 9828605 Compare June 14, 2026 19:34
@proggeramlug proggeramlug merged commit 0dfac03 into main Jun 14, 2026
15 checks passed
@proggeramlug proggeramlug deleted the feat/nextjs-wall-45 branch June 14, 2026 19:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant