Skip to content

fix(classes): value-super chain-walk skipped intermediate user ctors above a fetch builtin — Next.js middleware 500s#6547

Open
proggeramlug wants to merge 3 commits into
PerryTS:mainfrom
proggeramlug:fix/fetch-super-chain-walk-skips-user-ctor
Open

fix(classes): value-super chain-walk skipped intermediate user ctors above a fetch builtin — Next.js middleware 500s#6547
proggeramlug wants to merge 3 commits into
PerryTS:mainfrom
proggeramlug:fix/fetch-super-chain-walk-skips-user-ctor

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Stacked on #6545 (first commit here is that PR's commit; review the second commit only — fetch_globals.rs + tests).

Problem

js_fetch_or_value_super's stale-alias recovery walked the instance's whole parent chain for a recorded Request/Response parent. For class Hint extends NextRequest extends Request — with the parent imported from another module — the Hint's super(input, init) was classified as a direct native-Request super: the runtime constructed the native handle, attached it to this, and returned, never invoking NextRequest's constructor body. Its symbol-keyed internal state (this[INTERNALS] = {...}) never existed, so every getter on it read undefined.<prop>.

Impact

Next.js middleware's exact shape (NextRequestHint extends NextRequest extends Request in next/dist/server/web): with the server finally booting (#6522 / #6527 / #6537 / #6545), every request 500'd with Cannot read properties of undefined (reading 'nextUrl') plus an unhandled-rejection pair per request. With this fix the error is gone (the remaining render blocker is #6546, an unrelated Flight/typeof miscompile).

Runtime probes that pinned it: the failing receiver was the correct Hint instance (sourcePage set by its own ctor body, __perry_fetch_handle__ attached) but the parent's PRB-CTOR probe never printed and globalThis markers set by the parent ctor stayed unset — the intermediate constructor genuinely never ran.

Fix

A parent value that resolves to a user constructor (ClassRef, per-evaluation class object, or closure) always takes the value-super dispatch — its own super() leg attaches the native handle when it reaches the builtin. The chain-walk fallback keeps covering only its original case: a stale/unresolvable alias of the builtin itself (class X extends GlobalRequest with the alias var dead at super time, the @hono/node-server shape).

Test

test-files/test_gap_cross_module_fetch_subclass_super.ts (+ helper module) — the two-level cross-module chain constructed from both a native Request instance and a string URL, plus the direct-subclass control. Byte-identical to node --experimental-strip-types; pre-fix the hint's getters threw.

https://claude.ai/code/session_01KD4GTmiwZjRrxShzQydJpF

Summary by CodeRabbit

  • Bug Fixes
    • Fixed cross-module class capture collisions so captured values stay correctly isolated and resolve consistently during construction and overrides.
    • Improved shared-mutable capture rewriting so reads/writes update through the shared storage cell.
    • Corrected super() dispatch for cross-module Request subclasses, including user-constructor handling.
    • Improved class specialization and constructor capture writeback consistency.
    • Fixed JSON replacer pretty-printing to keep pointers valid across callbacks.
  • Tests
    • Added regression coverage for cross-module class capture IDs and cross-module fetch/Request subclass super() construction.

Ralph Küpper added 2 commits July 17, 2026 23:39
…e base/derived id collision

Class captures are stashed on each instance as `this.__perry_cap_<id>`
fields and rebound in member prologues. Local ids restart per module, and
`super()` runs the PARENT constructor's stashes on the SHARED instance —
so a derived-class method rebinding its own capture id K silently read
the PARENT MODULE's captured local K whenever the parent chain crossed
modules and a parent capture shared the id.

This is how a Next.js 16 standalone server died at boot: base-server.js
captures `_path = _interop_require_wildcard(require("path"))` and
next-server.js captures `_fs = _interop_require_default(require("fs"))`
under the same local id 13. `Server`'s ctor calls the virtual
`this.getBuildId()` before NextNodeServer's stashes exist; the override's
`_fs` rebind found base-server's `_path` wildcard (`_fs.default ===
path-namespace`), `readFileSync` dispatched on `path`, returned
undefined, and `.trim()` threw — 100% deterministic, before Ready.

Fix: the field name embeds a stable per-module salt
(`__perry_cap_<id>m<salt>`, new `perry_hir::cap_fields`). Same-module
inheritance keeps sharing parent stashes (equal salt ⇒ equal names);
cross-module chains are isolated. Only `synthesize_class_captures`
constructs names now; every consumer that needs the outer id back
(ctor-arg alignment, factory specialization, capture writeback, the
shared-mutable passes) parses the leading digits via
`cap_field_outer_id` instead of assuming the whole suffix is numeric —
the shared-mutable passes match by parsed id, so they need no salt.

Test: test_gap_cross_module_class_capture_ids.ts — base/derived in
separate modules with colliding capture ids and a virtual call from the
base ctor (the exact Next.js shape); byte-identical to node
--experimental-strip-types. Pre-existing (unrelated) failure noted while
sweeping: test_gap_2159_defineproperty_class_prototype fails identically
on pristine main.

Claude-Session: https://claude.ai/code/session_01KD4GTmiwZjRrxShzQydJpF
…er ctor above a fetch builtin

`js_fetch_or_value_super`'s stale-alias recovery walked the instance's
WHOLE parent chain for a recorded Request/Response parent. For
`class Hint extends NextRequest extends Request` (parent imported from
another module), the Hint's `super(input, init)` was classified as a
DIRECT native-Request super: the runtime constructed the native handle,
attached it, and returned — never invoking NextRequest's constructor
body. Its symbol-keyed internal state (`this[INTERNALS] = {...}`) never
existed, so every getter on it read `undefined.<prop>`.

This is Next.js middleware's exact shape (NextRequestHint extends
NextRequest extends Request in next/dist/server/web): with the server
finally booting, every request 500'd with "Cannot read properties of
undefined (reading 'nextUrl')" plus an unhandled-rejection pair per
request.

Fix: a parent value that resolves to a USER constructor (ClassRef,
per-evaluation class object, or closure) always takes the value-super
dispatch below — its own `super()` leg attaches the native handle when
it reaches the builtin. The chain-walk fallback keeps covering its
original case only: a stale/unresolvable alias of the builtin itself
(`class X extends GlobalRequest` with the alias var dead at super time).

Test: test_gap_cross_module_fetch_subclass_super.ts — the two-level
cross-module chain constructed from both a native Request instance and a
string URL, plus the direct-subclass control; byte-identical to
node --experimental-strip-types. Pre-fix the hint's getters threw.

Claude-Session: https://claude.ai/code/session_01KD4GTmiwZjRrxShzQydJpF
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Salted class capture naming

Layer / File(s) Summary
Capture naming contract
crates/perry-hir/src/cap_fields.rs, crates/perry-hir/src/lib.rs, crates/perry-hir/src/lower/context.rs
Adds salted capture-field naming, legacy-compatible outer-ID parsing, module export, and module-derived salt retrieval.
Class capture synthesis
crates/perry-hir/src/lower_decl/class_captures.rs
Uses salted names for synthesized fields, rebinding lets, propagation mappings, constructor parameters, and constructor stashes.
Capture ID consumers
crates/perry-hir/src/lower/shared_mutable_capture.rs, crates/perry-codegen/src/lower_call/*, crates/perry-transform/src/inline/factory_specialize.rs
Updates shared-mutable rewriting, writeback, constructor argument validation, and factory specialization to recover outer IDs through the shared parser.
Cross-module capture regression
test-files/_helpers/cross_module_cap_ids_base.ts, test-files/test_gap_cross_module_class_capture_ids.ts
Adds cross-module base and derived classes that verify capture fields remain distinct across modules.

Fetch super dispatch

Layer / File(s) Summary
User constructor dispatch
crates/perry-runtime/src/object/global_this/fetch_globals.rs
Detects user constructors from NaN-boxed tags and skips builtin parent-chain recovery for them.
Cross-module fetch regression
test-files/_helpers/cross_module_fetch_super_base.ts, test-files/test_gap_cross_module_fetch_subclass_super.ts
Adds cross-module Request subclass coverage for native Request inputs, string URLs, and constructor state getters.

GC-safe JSON replacers

Layer / File(s) Summary
Replacer pointer rooting
crates/perry-runtime/src/json/replacer.rs
Roots object and array inputs plus replacer closures, re-derives iteration pointers after callbacks, and passes rooted holders to replacer calls.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers the fetch bug, but it omits other changed areas and doesn't match the required Summary/Changes/Related issue/Test plan structure. Add explicit Summary, Changes, Related issue, and Test plan sections covering all PR commits, or split unrelated changes into separate PRs.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly names the user-constructor value-super bug and the Next.js middleware impact.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@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

🤖 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/global_this/fetch_globals.rs`:
- Around line 666-686: Move the `parent_is_user_ctor` computation into the
`identify_global_builtin_constructor(parent_val).or_else` closure so pointer
checks run only after builtin identification fails. Extract the shared
tag-and-pointer classification into a local `is_user_ctor` helper, use it for
the deferred check, and replace the duplicated `usable` logic later in the
function with that helper.
🪄 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: 585a042b-c82a-483e-a6bd-4e28d7a703e8

📥 Commits

Reviewing files that changed from the base of the PR and between 520894f and cdbc38c.

📒 Files selected for processing (13)
  • crates/perry-codegen/src/lower_call/capture_writeback.rs
  • crates/perry-codegen/src/lower_call/new_ctor_args.rs
  • crates/perry-hir/src/cap_fields.rs
  • crates/perry-hir/src/lib.rs
  • crates/perry-hir/src/lower/context.rs
  • crates/perry-hir/src/lower/shared_mutable_capture.rs
  • crates/perry-hir/src/lower_decl/class_captures.rs
  • crates/perry-runtime/src/object/global_this/fetch_globals.rs
  • crates/perry-transform/src/inline/factory_specialize.rs
  • test-files/_helpers/cross_module_cap_ids_base.ts
  • test-files/_helpers/cross_module_fetch_super_base.ts
  • test-files/test_gap_cross_module_class_capture_ids.ts
  • test-files/test_gap_cross_module_fetch_subclass_super.ts

Comment on lines +666 to +686
let parent_is_user_ctor = {
let bits = parent_val.to_bits();
const TAG_MASK: u64 = 0xFFFF_0000_0000_0000;
const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000;
const INT32_TAG: u64 = 0x7FFE_0000_0000_0000;
const PTR_MASK: u64 = 0x0000_FFFF_FFFF_FFFF;
match bits & TAG_MASK {
INT32_TAG => true, // ClassRef
POINTER_TAG => {
let p = (bits & PTR_MASK) as usize;
crate::closure::is_closure_ptr(p)
|| super::super::class_registry::is_class_object_ptr(p as *const u8)
}
_ => false,
}
};
let kind = super::super::class_registry::identify_global_builtin_constructor(parent_val)
.or_else(|| {
if parent_is_user_ctor {
return None;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Defer the parent_is_user_ctor check to avoid memory reads on the hot path.

Computing parent_is_user_ctor unconditionally performs pointer dereferences (via is_closure_ptr and is_class_object_ptr) on every value-super call. Move this logic inside the .or_else closure so it only executes when identify_global_builtin_constructor fails.

Additionally, this exact logic duplicates the usable check further down in this function (around line 797). Consider extracting it into a shared helper closure to DRY the code.

⚡ Proposed fix
+    let is_user_ctor = |val: f64| -> bool {
+        let bits = val.to_bits();
+        const TAG_MASK: u64 = 0xFFFF_0000_0000_0000;
+        const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000;
+        const INT32_TAG: u64 = 0x7FFE_0000_0000_0000;
+        const PTR_MASK: u64 = 0x0000_FFFF_FFFF_FFFF;
+        match bits & TAG_MASK {
+            INT32_TAG => true, // ClassRef
+            POINTER_TAG => {
+                let p = (bits & PTR_MASK) as usize;
+                crate::closure::is_closure_ptr(p)
+                    || super::super::class_registry::is_class_object_ptr(p as *const u8)
+            }
+            _ => false,
+        }
+    };
-    let parent_is_user_ctor = {
-        let bits = parent_val.to_bits();
-        const TAG_MASK: u64 = 0xFFFF_0000_0000_0000;
-        const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000;
-        const INT32_TAG: u64 = 0x7FFE_0000_0000_0000;
-        const PTR_MASK: u64 = 0x0000_FFFF_FFFF_FFFF;
-        match bits & TAG_MASK {
-            INT32_TAG => true, // ClassRef
-            POINTER_TAG => {
-                let p = (bits & PTR_MASK) as usize;
-                crate::closure::is_closure_ptr(p)
-                    || super::super::class_registry::is_class_object_ptr(p as *const u8)
-            }
-            _ => false,
-        }
-    };
     let kind = super::super::class_registry::identify_global_builtin_constructor(parent_val)
         .or_else(|| {
-            if parent_is_user_ctor {
+            if is_user_ctor(parent_val) {
                 return None;
             }

You can then replace the duplicated logic around line 797 with the same closure:

let usable = is_user_ctor(parent_val);
🤖 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/global_this/fetch_globals.rs` around lines
666 - 686, Move the `parent_is_user_ctor` computation into the
`identify_global_builtin_constructor(parent_val).or_else` closure so pointer
checks run only after builtin identification fails. Extract the shared
tag-and-pointer classification into a local `is_user_ctor` helper, use it for
the deferred check, and replace the duplicated `usable` logic later in the
function with that helper.

`stringify_array_with_replacer_pretty` / `stringify_object_with_replacer_
pretty` held raw pointers (the array/object being walked, its keys array,
the element/field base pointers, and the replacer ClosureHeader) across
`call_replacer` / `apply_to_json_keyed` — arbitrary JS that allocates. A
minor GC mid-loop can PROMOTE (move) any of those objects; the stale
`elements` base then yields garbage f64s whose NaN-boxed "pointers"
faulted in whatever shape-probe touched them first.

This was the first of the gscmaster ~10-render crash sites: React Flight
serializes the RSC payload via JSON.stringify(model, resolveModelToJSON)
deep-recursively; the crash fired the first time a GC landed mid-
serialization (deterministically request 12 at -O0), with varying
victims (temporal::dispatch::get_property, url::search_params …)
depending on which probe consumed the garbage pointer.

Fix: each recursion frame roots the container and the replacer in a
RuntimeHandleScope (rewritten on evacuation/promotion) and re-derives
every raw pointer from the roots on each loop iteration, after the
callbacks have run.

Verified: at -O0 the crash stack moved off json::replacer entirely (the
next unrooted site in the async-iterator path is tracked separately) —
pre-fix the segfault backtrace ran through
stringify_array_with_replacer_pretty × 14 frames.

Claude-Session: https://claude.ai/code/session_01KD4GTmiwZjRrxShzQydJpF
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Pushed an additional commit on this branch: fix(gc): root the JSON.stringify replacer recursion across JS callbacks — the first root-caused site of the long-standing ~10-render GC crash (React Flight's JSON.stringify(model, replacer) recursion held raw element/keys/replacer pointers across JS callbacks; a mid-serialization GC promotion left them dangling). Verified at -O0: the crash stack moved off json::replacer entirely. Can split into its own PR if preferred.

https://claude.ai/code/session_01KD4GTmiwZjRrxShzQydJpF

@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.

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/json/replacer.rs (1)

450-454: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Re-derive replacer across JS callbacks to prevent dangling pointers.

While obj_root and arr_root are correctly re-derived for the holder, the replacer pointer derived at the top of the loop survives apply_to_json_keyed (which executes arbitrary user JS via toJSON). As noted in the codebase comments, a minor GC mid-callback can promote the closure, leaving replacer dangling when passed to call_replacer.

Both stringifier loops must re-derive replacer immediately before use. (Note: it must also be re-derived before the recursive dispatch_pointer_with_replacer calls on lines 488 and 594, as call_replacer itself executes JS).

  • crates/perry-runtime/src/json/replacer.rs#L450-L454: Re-derive replacer = replacer_root.get_raw_const_ptr::<crate::ClosureHeader>() before passing it to call_replacer.
  • crates/perry-runtime/src/json/replacer.rs#L578-L583: Re-derive replacer = replacer_root.get_raw_const_ptr::<crate::ClosureHeader>() before passing it to call_replacer in the array path.

(As an additional note for follow-up: the unchanged stringify_array_with_array_replacer function at line 1252 suffers from a similar hazard. It derives elements outside its loop and calls toJSON inside, which will dangle elements on the next iteration. It needs the same RuntimeHandleScope rooting applied here.)

🤖 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/json/replacer.rs` around lines 450 - 454, Re-derive
the rooted replacer pointer immediately before each call to call_replacer in
both stringifier loops: crates/perry-runtime/src/json/replacer.rs lines 450-454
and 578-583. In each site, refresh replacer from replacer_root after callbacks
and before use, and ensure the same refreshed pointer is available before
recursive dispatch_pointer_with_replacer calls. Do not modify
stringify_array_with_array_replacer; it is follow-up context only.
🤖 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.

Outside diff comments:
In `@crates/perry-runtime/src/json/replacer.rs`:
- Around line 450-454: Re-derive the rooted replacer pointer immediately before
each call to call_replacer in both stringifier loops:
crates/perry-runtime/src/json/replacer.rs lines 450-454 and 578-583. In each
site, refresh replacer from replacer_root after callbacks and before use, and
ensure the same refreshed pointer is available before recursive
dispatch_pointer_with_replacer calls. Do not modify
stringify_array_with_array_replacer; it is follow-up context only.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3582fb12-cf13-4e92-bf64-6c133a617657

📥 Commits

Reviewing files that changed from the base of the PR and between cdbc38c and 885ae72.

📒 Files selected for processing (1)
  • crates/perry-runtime/src/json/replacer.rs

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