Skip to content

fix(http): res.setHeaders no longer SIGSEGVs on Headers; correct Headers/Map handling (#4965)#5175

Merged
proggeramlug merged 1 commit into
mainfrom
worktree-fix-4965-setheaders-segv
Jun 15, 2026
Merged

fix(http): res.setHeaders no longer SIGSEGVs on Headers; correct Headers/Map handling (#4965)#5175
proggeramlug merged 1 commit into
mainfrom
worktree-fix-4965-setheaders-segv

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Fixes #4965.

Root cause

res.setHeaders(headers) JSON-stringified its argument directly. A Headers value is a fetch-band registry handle (its first id is 0x40000, per value::addr_class), not a heap pointer — so the generic JSON.stringify walker dereferenced id - 8 as a GcHeader and segfaulted. Because the page at that low address is sometimes mapped (mimalloc retention) and sometimes not, the crash was nondeterministic: test-http-response-setheaders.js exited 139 (SIGSEGV) ~6/8 runs and 1 ("Missing expected exception") otherwise (the no-op fallback also never threw Node's validation errors).

Fix

  • Runtime normalizer js_node_setheaders_entries_json(value) (perry-runtime/src/object/global_fetch.rs) classifies the argument by address band before any dereference: a Map (real heap MapHeader) is read directly via js_map_entries; a Headers handle is delegated to a registered perry-stdlib producer; everything else returns null. No path ever dereferences a registry handle.
  • perry-stdlib producer js_headers_setheaders_entries_json (fetch/headers.rs, registered from js_stdlib_init_dispatch) emits WHATWG sorted-by-name [name, value] entries, collapsing Set-Cookie into a ["set-cookie", [c1, c2, …]] array. Routed through the always-linked runtime so perry-ext-http-server keeps no direct perry-stdlib symbol dependency (avoids the fix(runtime,codegen,hir): Next.js standalone walls 31-35 #5112 link-break class).
  • perry-ext-http-server js_node_http_res_set_headers rewritten to Node's contract: ERR_HTTP_HEADERS_SENT when the head is committed, then ERR_INVALID_ARG_TYPE for non-Headers/Map args, else apply. A new header_committed flag is set by writeHead (distinct from the lazy headers_sent wire-flush flag, so the deferred-send path is unchanged). writeHead also now applies the flat-array headers form ([name, value, …]).

Verification

  • Repro no longer SIGSEGVs (was 6/8).
  • ERR_INVALID_ARG_TYPE fires for all six invalid arg shapes; ERR_HTTP_HEADERS_SENT fires after writeHead.
  • Map and Headers entries both apply (confirmed via res.getHeaders()).
  • cargo test -p perry-ext-http-server --lib: 27 passed (4 new unit tests for the entries/flat-array appliers).

Known separate blocker (NOT this issue)

Filed as #5174: instantiating a globalThis.Headers object in a program that runs both an http.Server and an in-process http client hangs the response pump — even without calling setHeaders. This is why the full upstream test still can't complete end-to-end; the SIGSEGV it reported is fixed.

Summary by CodeRabbit

Release Notes

  • New Features

    • writeHead() now accepts headers in both object and array formats for greater flexibility.
    • Enhanced header validation and normalization with consistent casing preservation.
  • Bug Fixes

    • Improved error handling when attempting to set headers after they've been committed to prevent unexpected behavior.
  • Tests

    • Added comprehensive unit tests for header processing and validation scenarios.

@coderabbitai

coderabbitai Bot commented Jun 15, 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: bbdcbd49-a5f5-493e-968c-f8b40419aec8

📥 Commits

Reviewing files that changed from the base of the PR and between 036d12f and 7dc687d.

📒 Files selected for processing (5)
  • crates/perry-ext-http-server/src/response.rs
  • crates/perry-ext-http-server/src/types.rs
  • crates/perry-runtime/src/object/global_fetch.rs
  • crates/perry-stdlib/src/common/dispatch.rs
  • crates/perry-stdlib/src/fetch/headers.rs
🚧 Files skipped from review as they are similar to previous changes (4)
  • crates/perry-ext-http-server/src/types.rs
  • crates/perry-stdlib/src/common/dispatch.rs
  • crates/perry-runtime/src/object/global_fetch.rs
  • crates/perry-ext-http-server/src/response.rs

📝 Walkthrough

Walkthrough

ServerResponse gains a header_committed flag to track Node's _header commitment separately from wire flush. res.setHeaders is rewritten to enforce ERR_HTTP_HEADERS_SENT/ERR_INVALID_ARG_TYPE semantics using a new Headers/Map-to-JSON normalization pipeline wired across perry-runtime, perry-stdlib, and perry-ext-http-server. writeHead gains flat-array header support and sets header_committed after applying headers.

Changes

res.setHeaders SIGSEGV fix and normalization pipeline

Layer / File(s) Summary
Headers-to-JSON producer: stdlib serializer, runtime registration, dispatch wiring
crates/perry-stdlib/src/fetch/headers.rs, crates/perry-runtime/src/object/global_fetch.rs, crates/perry-stdlib/src/common/dispatch.rs
js_headers_setheaders_entries_json serializes HeadersStore to sorted WHATWG-style [name, value] JSON with Set-Cookie collapse. A GLOBAL_HEADERS_ENTRIES_JSON atomic pointer and js_register_global_headers_entries_json export are added in the runtime; js_node_setheaders_entries_json dispatches Map vs Headers by address band. Registration is wired in js_stdlib_init_dispatch under the http-client feature flag.
FFI binding declaration
crates/perry-ext-http-server/src/types.rs
Adds extern "C" declaration for js_node_setheaders_entries_json with documentation of address-band classification and null-return semantics for invalid argument types.
ServerResponse: header_committed flag, setHeaders rewrite, writeHead flat-array, and tests
crates/perry-ext-http-server/src/response.rs
Adds header_committed: bool field (initialized false). Rewrites js_node_http_res_set_headers to guard on headers_sent/header_committed, normalize via the new FFI, and apply entries with case preservation and multi-valued expansion. Updates js_node_http_res_write_head for flat-array JSON routing and sets header_committed = true after applying. Adds unit tests for both applier helpers.

Sequence Diagram(s)

sequenceDiagram
  rect rgba(30, 100, 200, 0.5)
    note over dispatch,stdlib: Startup: producer registration
    participant dispatch as js_stdlib_init_dispatch
    participant reg as js_register_global_headers_entries_json
    participant global as GLOBAL_HEADERS_ENTRIES_JSON
    dispatch->>reg: register(js_headers_setheaders_entries_json)
    reg->>global: store fn pointer
  end

  rect rgba(200, 80, 30, 0.5)
    note over jscaller,sr: Runtime: res.setHeaders(headers)
    participant jscaller as JS caller
    participant setHeaders as js_node_http_res_set_headers
    participant norm as js_node_setheaders_entries_json
    participant stdlib as js_headers_setheaders_entries_json
    participant sr as ServerResponse

    jscaller->>setHeaders: setHeaders(value)
    setHeaders->>sr: check headers_sent || header_committed
    alt committed
      setHeaders-->>jscaller: throw ERR_HTTP_HEADERS_SENT
    else not committed
      setHeaders->>norm: normalize(value)
      norm->>global: load fn pointer
      global->>stdlib: call(handle)
      stdlib-->>norm: [name,value] JSON entries
      norm-->>setHeaders: *mut StringHeader
      setHeaders->>sr: apply_headers_entries(entries)
      setHeaders-->>jscaller: ok
    end
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

  • #4965 (node:http: test-http-response-setheaders.js SIGSEGVs nondeterministically): This PR directly addresses the SIGSEGV by replacing the unsafe res.setHeaders path with a safe Headers/Map-to-JSON normalization pipeline and adding ERR_HTTP_HEADERS_SENT/ERR_INVALID_ARG_TYPE guard semantics that the failing test exercises.

Poem

🐇 Hop, hop — no more SIGSEGV today!
The headers now normalize, safe all the way.
header_committed guards the gate with care,
flat arrays and Maps land without despair.
A rabbit tidied up the FFI mess —
now setHeaders throws the right error, no less! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main fix: resolving the SIGSEGV crash in res.setHeaders when handling Headers objects, and correcting the handling of Headers/Map inputs.
Description check ✅ Passed The description thoroughly covers the root cause, the three-component fix, verification steps, and notes a separate blocker, meeting all template requirements despite some checklist items unchecked.
Linked Issues check ✅ Passed The PR directly addresses issue #4965 by fixing the SIGSEGV crash, implementing proper error handling (ERR_HTTP_HEADERS_SENT, ERR_INVALID_ARG_TYPE), and correctly applying Headers/Map entries.
Out of Scope Changes check ✅ Passed All changes are scoped to fixing the identified SIGSEGV issue and implementing Node.js setHeaders contract; no unrelated modifications detected across the five modified files.
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-4965-setheaders-segv

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: 3

🤖 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-ext-http-server/src/response.rs`:
- Around line 1014-1018: The `header_committed` guard that prevents mutations
after writeHead() is only applied in the writeHead() method itself, but the
related header mutation methods (setHeader, removeHeader, appendHeader) and
status/statusMessage property setters can still modify state after the head is
committed. Add the same header_committed check to each of these four header
mutator methods and property setters, ensuring they throw an error consistent
with Node.js behavior (ERR_HTTP_HEADERS_SENT) when attempting to mutate after
the head has been committed.
- Around line 793-819: Header name validation is missing in the bulk-applied
header methods. In both apply_headers_entries (shown in the diff at lines
793-819) and apply_headers_flat_array (at lines 1033-1059), add a check using
the same http_is_valid_token validation that is applied in setHeader before
inserting any header names into sr.headers, sr.header_value_lists, and
sr.raw_header_names. If a header name fails validation, the methods should throw
ERR_INVALID_HTTP_TOKEN instead of silently accepting the malformed name. Apply
this validation guard consistently in both functions before any map insertions
occur.

In `@crates/perry-runtime/src/object/global_fetch.rs`:
- Around line 275-278: The code is directly passing Map entries obtained from
js_map_entries(map) to js_json_stringify without validating that the entries
don't contain fetch-band handles as keys or values, which can cause the JSON
walker to recursively process those handles incorrectly. Before calling
js_json_stringify on the boxed entries pointer, either validate and normalize
the Map entries with an address-band-aware header normalizer to safely handle
any fetch-band handles, or explicitly reject Map entries that contain
unsupported key/value shapes (such as fetch-band handles) before the
stringification step.
🪄 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: 89b8afd2-79bf-4c0b-8711-2a11c27ff4ef

📥 Commits

Reviewing files that changed from the base of the PR and between 08819f0 and 036d12f.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (8)
  • CHANGELOG.md
  • CLAUDE.md
  • Cargo.toml
  • crates/perry-ext-http-server/src/response.rs
  • crates/perry-ext-http-server/src/types.rs
  • crates/perry-runtime/src/object/global_fetch.rs
  • crates/perry-stdlib/src/common/dispatch.rs
  • crates/perry-stdlib/src/fetch/headers.rs

Comment on lines +793 to +819
let name = match name_v {
serde_json::Value::String(s) => s,
other => other.to_string(),
};
if name.is_empty() {
continue;
}
let lower = name.to_lowercase();
if let serde_json::Value::Array(elems) = value_v {
let elems: Vec<String> = elems
.into_iter()
.map(|item| match item {
serde_json::Value::String(s) => s,
other => other.to_string(),
})
.collect();
sr.headers.insert(lower.clone(), elems.join(", "));
sr.header_value_lists.insert(lower.clone(), elems);
} else {
let value = match value_v {
serde_json::Value::String(s) => s,
other => other.to_string(),
};
sr.headers.insert(lower.clone(), value);
sr.header_value_lists.remove(&lower);
}
sr.raw_header_names.insert(lower, name);

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

Validate header names before storing bulk-applied entries.

setHeaders(new Map(...)) and writeHead([...]) now bypass the http_is_valid_token check used by setHeader, so malformed names can enter sr.headers instead of throwing ERR_INVALID_HTTP_TOKEN. Add the same validation in both new appliers before inserting into the maps.

Suggested guard
         if name.is_empty() {
             continue;
         }
+        if !http_is_valid_token(&name) {
+            perry_ffi::throw_with_code(
+                &format!("Header name must be a valid HTTP token [\"{name}\"]"),
+                "ERR_INVALID_HTTP_TOKEN",
+                perry_ffi::ErrorKind::TypeError,
+            );
+        }
         let lower = name.to_lowercase();

Apply the same guard in apply_headers_entries and apply_headers_flat_array.

Also applies to: 1033-1059

🤖 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-ext-http-server/src/response.rs` around lines 793 - 819, Header
name validation is missing in the bulk-applied header methods. In both
apply_headers_entries (shown in the diff at lines 793-819) and
apply_headers_flat_array (at lines 1033-1059), add a check using the same
http_is_valid_token validation that is applied in setHeader before inserting any
header names into sr.headers, sr.header_value_lists, and sr.raw_header_names. If
a header name fails validation, the methods should throw ERR_INVALID_HTTP_TOKEN
instead of silently accepting the malformed name. Apply this validation guard
consistently in both functions before any map insertions occur.

Comment on lines +1014 to +1018
// Mark the head committed (Node's `_header`) so a later
// `res.setHeaders(...)` throws `ERR_HTTP_HEADERS_SENT`. The actual wire
// flush still happens lazily at `write`/`end` (`headers_sent`), so the
// deferred-send path is unchanged (#4965).
sr.header_committed = true;

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

Apply header_committed to every head mutator.

After writeHead() sets header_committed but leaves headers_sent false, setHeader, removeHeader, appendHeader, and status/statusMessage setters can still mutate state that will later be snapshotted to the wire. Use a shared “head committed” guard for those paths, not only for setHeaders.

🤖 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-ext-http-server/src/response.rs` around lines 1014 - 1018, The
`header_committed` guard that prevents mutations after writeHead() is only
applied in the writeHead() method itself, but the related header mutation
methods (setHeader, removeHeader, appendHeader) and status/statusMessage
property setters can still modify state after the head is committed. Add the
same header_committed check to each of these four header mutator methods and
property setters, ensuring they throw an error consistent with Node.js behavior
(ERR_HTTP_HEADERS_SENT) when attempting to mutate after the head has been
committed.

Comment on lines +275 to +278
if let Some(map) = crate::map::map_ptr_from_receiver_bits(bits) {
let entries = crate::map::js_map_entries(map);
let boxed = crate::value::js_nanbox_pointer(entries as i64);
return unsafe { crate::json::js_json_stringify(f64::from_bits(boxed.to_bits()), 0) };

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 | 🔴 Critical | 🏗️ Heavy lift

Avoid recursively stringifying arbitrary Map entries.

The top-level Map receiver is heap-backed, but its keys/values can still be fetch-band handles; js_map_entries(map) preserves those values inside heap arrays, and the generic JSON walker can then recurse into a handle and recreate the same handle-as-GcHeader crash class. Serialize Map entries with an address-band-aware header normalizer, or reject unsupported key/value shapes before calling js_json_stringify.

🤖 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_fetch.rs` around lines 275 - 278, The
code is directly passing Map entries obtained from js_map_entries(map) to
js_json_stringify without validating that the entries don't contain fetch-band
handles as keys or values, which can cause the JSON walker to recursively
process those handles incorrectly. Before calling js_json_stringify on the boxed
entries pointer, either validate and normalize the Map entries with an
address-band-aware header normalizer to safely handle any fetch-band handles, or
explicitly reject Map entries that contain unsupported key/value shapes (such as
fetch-band handles) before the stringification step.

…ers/Map handling (#4965)

setHeaders JSON-stringified its argument directly. A Headers value is a
fetch-band registry handle (first id 0x40000), not a heap pointer, so the
generic stringify walker dereferenced id-8 as a GcHeader and segfaulted
nondeterministically (~6/8 runs of test-http-response-setheaders.js).

- New runtime js_node_setheaders_entries_json classifies by address band
  before any deref: Map read directly, Headers delegated to a registered
  perry-stdlib producer; else null -> ERR_INVALID_ARG_TYPE.
- perry-stdlib js_headers_setheaders_entries_json emits sorted [name,value]
  entries, Set-Cookie as an array; routed via the always-linked runtime so
  http-server keeps no direct stdlib symbol dep.
- setHeaders throws ERR_HTTP_HEADERS_SENT once the head is committed (new
  header_committed flag set by writeHead, distinct from headers_sent), and
  writeHead now accepts the flat-array headers form.

Separate pre-existing blocker filed as #5174 (Headers + in-process http
server/client hang). Unit tests added for the entries/flat-array appliers.
@proggeramlug proggeramlug force-pushed the worktree-fix-4965-setheaders-segv branch from 036d12f to 7dc687d Compare June 15, 2026 06:45
@proggeramlug proggeramlug merged commit fc1264e into main Jun 15, 2026
15 checks passed
@proggeramlug proggeramlug deleted the worktree-fix-4965-setheaders-segv branch June 15, 2026 08:00
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.

node:http: test-http-response-setheaders.js SIGSEGVs nondeterministically (res.setHeaders + assert.throws path)

1 participant