Skip to content

fix(fetch): Headers.forEach/entries on app.fetch() Response no longer SIGSEGVs (#5432)#5442

Merged
proggeramlug merged 1 commit into
mainfrom
worktree-fix-5432-headers-foreach
Jun 19, 2026
Merged

fix(fetch): Headers.forEach/entries on app.fetch() Response no longer SIGSEGVs (#5432)#5442
proggeramlug merged 1 commit into
mainfrom
worktree-fix-5432-headers-foreach

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Closes #5432.

Problem

Calling .forEach() on the Headers of the Response returned by Hono's app.fetch() SIGSEGVs in js_array_forEach; .entries() silently yields 0. A plain new Response().headers.forEach() works. This breaks every server built on Hono (and any WinterCG app.fetch handler — itty-router, Cloudflare Workers) on the first request.

Root cause

const res = await app.fetch(req) is a member-call .fetch(), which was never registered as a fetch Response. With res unregistered, the static array-method fold rewrote res.headers.forEach(cb) into Expr::ArrayForEach, and codegen dispatched js_array_forEach directly on the Headers handle id (0x40000 band). Dereferencing handle - 8 as a GcHeader reads unmapped low memory → SIGSEGV. .entries() folded identically (silent 0). [...res.headers] used the iterator path that already routes handles correctly — which is exactly why only .forEach/.entries diverged.

Fix (two layers)

  1. HIR (functional fix). A narrow fetch_call_response_locals set records locals initialized from a member-call .fetch(...), consulted only by the is_fetch_headers guard in array_only_methods.rs so res.headers.<m>() bails the array-method fold and routes through the Headers FFI. This deliberately avoids register_native_instance, which hijacks every method/property on the local — that broader approach regressed a Bookshelf/Backbone-style repo.fetch() returning a real array (.map/.forEach mis-routed). The narrow set only affects <name>.headers.<method>().

  2. Runtime (defense-in-depth). normalize_array_receiver and clean_arr_ptr now reject the small-handle band via addr_class predicates before any GcHeader deref (the stale 0x1008 floor was the crash site). Any stray handle reaching an array helper becomes a safe empty-result no-op across platforms instead of a crash — matching the addr_class.rs band-map contract (fix(runtime): honor JSON reviver prototype lookups #4665/fix(runtime): for-of/spread over a Headers handle no longer segfaults #4800).

Validation

  • Real hono ^4.10.0 repro from the issue: now prints forEach count: 1, no crash.
  • New hono-free regression test test-files/test_issue_5432_fetch_headers_foreach.ts (models app.fetch() returning a native Response), byte-for-byte matching node --experimental-strip-types.
  • Regression guard: repo.fetch() returning number[] still does .map/.forEach correctly.
  • cargo test -p perry-runtime -p perry-hir green (1 unrelated documented test-isolation flake, passes in isolation); array gap-test spot-check unaffected (the 2 diffs are pre-existing TypedArray.sort arg-validation / toLocaleString gaps, not in changed code paths).

Per repo convention, version bump + CHANGELOG are left to the maintainer.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Fixed crashes when iterating Fetch API Response headers via .forEach() and .entries() (including cases created through member-call fetch patterns).
    • Improved detection of fetch-derived header receivers so header-specific handling is used reliably.
    • Added additional runtime safety checks to prevent invalid header access.
  • Tests
    • Added a regression test covering Response.headers iteration after fetch()-style member calls.

@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

Pull request was closed or merged during review

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: 90107ce7-332e-412b-b625-dbfc4230cf93

📥 Commits

Reviewing files that changed from the base of the PR and between b9b7e93 and 3a429cf.

📒 Files selected for processing (8)
  • crates/perry-hir/src/destructuring/helpers.rs
  • crates/perry-hir/src/destructuring/mod.rs
  • crates/perry-hir/src/destructuring/var_decl.rs
  • crates/perry-hir/src/lower/context.rs
  • crates/perry-hir/src/lower/expr_call/array_only_methods.rs
  • crates/perry-hir/src/lower/lowering_context.rs
  • crates/perry-runtime/src/array/header.rs
  • test-files/test_issue_5432_fetch_headers_foreach.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • crates/perry-hir/src/lower/context.rs
  • crates/perry-hir/src/lower/expr_call/array_only_methods.rs
  • test-files/test_issue_5432_fetch_headers_foreach.ts
  • crates/perry-runtime/src/array/header.rs
  • crates/perry-hir/src/lower/lowering_context.rs
  • crates/perry-hir/src/destructuring/var_decl.rs

📝 Walkthrough

Walkthrough

Fixes a SIGSEGV in js_array_forEach when calling Response.headers.forEach() on a fetch-API response. The HIR lowering now tracks locals bound from .fetch(...) calls and routes their .headers.* usage to the Headers FFI path instead of array static methods. The runtime array header traversal gains two handle-band rejection guards to prevent dereference of fetch handle IDs as GC objects. A regression test is added.

Changes

Fetch Response Headers forEach/entries Fix

Layer / File(s) Summary
LoweringContext field and initialization
crates/perry-hir/src/lower/lowering_context.rs, crates/perry-hir/src/lower/context.rs
Adds fetch_call_response_locals: HashSet<String> field (with inline docs) to LoweringContext and initializes it to an empty set in with_class_id_start.
Fetch-call detection helpers
crates/perry-hir/src/destructuring/helpers.rs, crates/perry-hir/src/destructuring/mod.rs
Adds get_fetch_module and is_member_fetch_call functions that pattern-match .fetch(...) and await .fetch(...) expressions; exports them via module re-exports.
Fetch-call detection and is_fetch_headers guard routing
crates/perry-hir/src/destructuring/var_decl.rs, crates/perry-hir/src/lower/expr_call/array_only_methods.rs
Records .fetch(...)/await .fetch(...) binding names into ctx.fetch_call_response_locals during variable-declarator lowering; expands is_fetch_headers to also accept identifiers in that set, preventing incorrect array-static-method dispatch for res.headers.* calls.
Runtime handle-band guards and regression test
crates/perry-runtime/src/array/header.rs, test-files/test_issue_5432_fetch_headers_foreach.ts
clean_arr_ptr early-returns null for handle-band payloads; normalize_array_receiver rejects above-handle-band addresses before GC header reads. New test exercises Headers.forEach() and Headers.entries() on a local App.fetch() response.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐇 A fetch came back with headers in hand,
But forEach crashed — a null in no-man's land!
The handle-band check now guards the gate,
And is_fetch_headers routes responses straight.
No more SIGSEGV, the bunny hops free,
res.headers.forEach works perfectly! 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically identifies the fix: Headers.forEach/entries on app.fetch() Response no longer crashes, directly addressing the critical SIGSEGV issue being resolved.
Description check ✅ Passed The PR description comprehensively covers the problem, root cause, two-layer fix, and validation approach. All key sections are present and well-articulated, meeting template requirements.
Linked Issues check ✅ Passed The PR fully addresses issue #5432 by implementing both the functional HIR fix (fetch_call_response_locals tracking) and runtime defense-in-depth (handle band rejection), validating against real Hono scenarios and regression tests.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing #5432: tracking locals from member-call .fetch() patterns, updating is_fetch_headers logic, adding handle band rejection, and providing regression test coverage.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ 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 worktree-fix-5432-headers-foreach

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

🤖 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-hir/src/destructuring/var_decl.rs`:
- Around line 936-963: The `is_member_fetch_call` function only marks direct
`.fetch(...)` calls, but it does not handle simple variable aliases. When a
variable is initialized with another variable that was already marked as a fetch
response local (like `const alias = res`), the alias should also be added to
`ctx.fetch_call_response_locals`. Add a check after the `is_member_fetch_call`
condition: if `init_expr` is a simple identifier reference and that identifier
is already in `ctx.fetch_call_response_locals`, then insert the current `name`
into the set as well. This ensures aliases to fetch responses maintain the
tracking through the scope.
🪄 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: ae417dd8-1c10-49f4-b1b1-e02ce07e975f

📥 Commits

Reviewing files that changed from the base of the PR and between ffb7ca9 and b9b7e93.

📒 Files selected for processing (6)
  • crates/perry-hir/src/destructuring/var_decl.rs
  • crates/perry-hir/src/lower/context.rs
  • crates/perry-hir/src/lower/expr_call/array_only_methods.rs
  • crates/perry-hir/src/lower/lowering_context.rs
  • crates/perry-runtime/src/array/header.rs
  • test-files/test_issue_5432_fetch_headers_foreach.ts

Comment on lines +936 to +963
// #5432: `const res = app.fetch(req)` / `await app.fetch(req)` —
// a member-call `.fetch(...)` is the Fetch-API server-handler
// convention (Hono `app.fetch`, itty-router, Cloudflare
// Workers) and yields a native fetch Response. Record it in a
// narrow set (NOT `register_native_instance`, which would hijack
// every method on `res`) so only `res.headers.<m>()` bails the
// array-method fold. See `fetch_call_response_locals`.
fn is_member_fetch_call(expr: &ast::Expr) -> bool {
let call = match expr {
ast::Expr::Await(a) => match a.arg.as_ref() {
ast::Expr::Call(c) => c,
_ => return false,
},
ast::Expr::Call(c) => c,
_ => return false,
};
if let ast::Callee::Expr(callee) = &call.callee {
if let ast::Expr::Member(member) = callee.as_ref() {
if let ast::MemberProp::Ident(prop) = &member.prop {
return prop.sym.as_ref() == "fetch";
}
}
}
false
}
if is_member_fetch_call(init_expr) {
ctx.fetch_call_response_locals.insert(name.clone());
}

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

Propagate fetch-response local tracking across simple aliases.

fetch_call_response_locals is only set for direct .fetch(...) initializers. A follow-up alias (const alias = res) is not tracked, so alias.headers.forEach/entries can still miss the intended headers path.

Proposed patch
                 if is_member_fetch_call(init_expr) {
                     ctx.fetch_call_response_locals.insert(name.clone());
+                } else if let ast::Expr::Ident(src_ident) = init_expr.as_ref() {
+                    if ctx
+                        .fetch_call_response_locals
+                        .contains(src_ident.sym.as_ref())
+                    {
+                        ctx.fetch_call_response_locals.insert(name.clone());
+                    }
                 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// #5432: `const res = app.fetch(req)` / `await app.fetch(req)` —
// a member-call `.fetch(...)` is the Fetch-API server-handler
// convention (Hono `app.fetch`, itty-router, Cloudflare
// Workers) and yields a native fetch Response. Record it in a
// narrow set (NOT `register_native_instance`, which would hijack
// every method on `res`) so only `res.headers.<m>()` bails the
// array-method fold. See `fetch_call_response_locals`.
fn is_member_fetch_call(expr: &ast::Expr) -> bool {
let call = match expr {
ast::Expr::Await(a) => match a.arg.as_ref() {
ast::Expr::Call(c) => c,
_ => return false,
},
ast::Expr::Call(c) => c,
_ => return false,
};
if let ast::Callee::Expr(callee) = &call.callee {
if let ast::Expr::Member(member) = callee.as_ref() {
if let ast::MemberProp::Ident(prop) = &member.prop {
return prop.sym.as_ref() == "fetch";
}
}
}
false
}
if is_member_fetch_call(init_expr) {
ctx.fetch_call_response_locals.insert(name.clone());
}
// `#5432`: `const res = app.fetch(req)` / `await app.fetch(req)` —
// a member-call `.fetch(...)` is the Fetch-API server-handler
// convention (Hono `app.fetch`, itty-router, Cloudflare
// Workers) and yields a native fetch Response. Record it in a
// narrow set (NOT `register_native_instance`, which would hijack
// every method on `res`) so only `res.headers.<m>()` bails the
// array-method fold. See `fetch_call_response_locals`.
fn is_member_fetch_call(expr: &ast::Expr) -> bool {
let call = match expr {
ast::Expr::Await(a) => match a.arg.as_ref() {
ast::Expr::Call(c) => c,
_ => return false,
},
ast::Expr::Call(c) => c,
_ => return false,
};
if let ast::Callee::Expr(callee) = &call.callee {
if let ast::Expr::Member(member) = callee.as_ref() {
if let ast::MemberProp::Ident(prop) = &member.prop {
return prop.sym.as_ref() == "fetch";
}
}
}
false
}
if is_member_fetch_call(init_expr) {
ctx.fetch_call_response_locals.insert(name.clone());
} else if let ast::Expr::Ident(src_ident) = init_expr.as_ref() {
if ctx
.fetch_call_response_locals
.contains(src_ident.sym.as_ref())
{
ctx.fetch_call_response_locals.insert(name.clone());
}
}
🤖 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-hir/src/destructuring/var_decl.rs` around lines 936 - 963, The
`is_member_fetch_call` function only marks direct `.fetch(...)` calls, but it
does not handle simple variable aliases. When a variable is initialized with
another variable that was already marked as a fetch response local (like `const
alias = res`), the alias should also be added to
`ctx.fetch_call_response_locals`. Add a check after the `is_member_fetch_call`
condition: if `init_expr` is a simple identifier reference and that identifier
is already in `ctx.fetch_call_response_locals`, then insert the current `name`
into the set as well. This ensures aliases to fetch responses maintain the
tracking through the scope.

…ough Headers FFI (#5432)

`const res = await app.fetch(req)` — the Fetch-API / WinterCG server-handler
shape used by Hono, itty-router, and Cloudflare Workers — is a member-call
`.fetch()`, which was never registered as a fetch Response. That left `res`
unregistered, so the static array-method fold rewrote `res.headers.forEach(cb)`
into `Expr::ArrayForEach` and codegen dispatched `js_array_forEach` directly on
the Headers handle id (0x40000 band). Dereferencing `handle - 8` as a GcHeader
read unmapped low memory and SIGSEGV'd on the first request of any Hono server.
`.entries()` folded the same way (silently yielding 0); `[...res.headers]` used
the iterator path that already routed handles correctly, which is why only
`.forEach`/`.entries` diverged.

Two layers:

1. HIR: a narrow `fetch_call_response_locals` set records locals initialized
   from a member-call `.fetch(...)`, consulted ONLY by the `is_fetch_headers`
   guard in `array_only_methods.rs` so `res.headers.<m>()` bails the array fold
   and routes through the Headers FFI. This deliberately avoids
   `register_native_instance`, which hijacks every method/property on the local
   and would mis-route a Bookshelf/Backbone `repo.fetch()` that returns a real
   array — verified that case stays correct.

2. Runtime (defense-in-depth): `normalize_array_receiver` and `clean_arr_ptr`
   now reject the small-handle band via `addr_class` predicates before any
   GcHeader deref (the stale `0x1008` floor was the crash site), turning any
   stray handle reaching an array helper into a safe empty-result no-op across
   platforms instead of a crash.

Validated against the real hono ^4.10.0 repro (now iterates, no crash), a
hono-free regression test (test_issue_5432_fetch_headers_foreach.ts), and the
runtime unit suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@proggeramlug proggeramlug force-pushed the worktree-fix-5432-headers-foreach branch from b9b7e93 to 3a429cf Compare June 19, 2026 08:29
@proggeramlug proggeramlug merged commit 9c46c38 into main Jun 19, 2026
14 of 15 checks passed
@proggeramlug proggeramlug deleted the worktree-fix-5432-headers-foreach branch June 19, 2026 08:30
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.

Fetch: Response.headers.forEach() SIGSEGVs (js_array_forEach) on the Hono app.fetch Response; .entries() empty, [...headers] works

1 participant