Skip to content

fix(ext/node): track http2 custom settings and SETTINGS ACK callbacks#33518

Merged
littledivy merged 7 commits into
denoland:mainfrom
divybot:claude/test-http2-session-settings
Apr 27, 2026
Merged

fix(ext/node): track http2 custom settings and SETTINGS ACK callbacks#33518
littledivy merged 7 commits into
denoland:mainfrom
divybot:claude/test-http2-session-settings

Conversation

@divybot

@divybot divybot commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Enables test-http2-session-settings in node_compat suite.

Test plan

  • cargo test --test node_compat -- test-http2-session-settings

divybot and others added 5 commits April 26, 2026 20:32
Enables tests/node_compat/runner/suite/test/parallel/test-http2-session-settings.js

Co-authored-by: Divy Srivastava <[email protected]>
Enables tests/node_compat/runner/suite/test/parallel/test-http2-session-settings.js

Co-authored-by: Divy Srivastava <[email protected]>
…ction

Post-construction session.settings() updates the value advertised to the peer but no longer retroactively tightens local HPACK enforcement, matching Node, which sets the limit once at session creation.

Co-authored-by: Divy Srivastava <[email protected]>
@divybot
divybot force-pushed the claude/test-http2-session-settings branch from e7d1d2f to 8e7df25 Compare April 26, 2026 15:41

@fibibot fibibot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM on the core mechanism. Walked the three main pieces:

  1. Custom settings trackinglocal_custom_settings for outbound, remote_custom_settings for inbound, with bit 16 of the stored ID acting as a "registered but no value yet received" sentinel that write_custom_settings_to_buffer filters out (if id & !0xffff_u32 != 0 { continue; }). The write_custom_settings_to_buffer indexing (offset = Count + 2) lines up with the existing Count+1 = count slot, Count+2 = first entry, two slots per entry. The update_remote_custom_settings_from_iv correctly clears bit 16 via slot.0 = id_lo as i32 after a real value arrives.

  2. local_max_header_list_size capture — capturing only on the first Http2Settings::send (gated by initial_settings_applied) and using it in add_header instead of nghttp2_session_get_local_settings(...) is exactly Node's "enforce only when set up-front" behavior. Default u32::MAX correctly means "no enforcement" when the constructor settings didn't include maxHeaderListSize.

  3. SETTINGS ACK FIFOpending_settings_acks: Vec<v8::Global<v8::Function>>, settings(cb) pushes, handle_settings_ack pops front. The constructor's initial settings doesn't push (so its ACK pops an empty queue and is silently ignored).

Two concerns flagged inline.

// SAFETY: session.isolate is valid for this session's lifetime
let isolate = unsafe { v8::Isolate::from_raw_isolate_ptr(session.isolate) };
session
.pending_settings_acks

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Subtle ordering hazard worth thinking about: the constructor sends initial settings via Http2Settings::send() without pushing to pending_settings_acks. If the user calls session.settings(cb1) before the peer ACKs the constructor's initial settings, the queue is [cb1], then the constructor ACK arrives first and pops cb1cb1 is invoked for the wrong settings ACK. The user's actual ACK then finds an empty queue and is silently dropped.

Node's outstanding_settings_ queue handles this by always pushing for every settings submission (including the constructor's, with a noop callback). Either match that pattern, or push a sentinel for the constructor's submission and skip-pop on it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 17992c0. The push now happens before submit in fn settings(cb), and I added an invariant comment on pending_settings_acks documenting that JS routes the constructor's initial settings through the same Http2Session.prototype.settingssubmitSettingskHandle.settings(boundCb) path (with settingsCallback.bind(this, undefined) as the cb), so the queue and outbound SETTINGS frames stay 1:1.

Comment thread ext/node/ops/http2/session.rs Outdated
/// `session.settings()` invocation enqueues one entry; an inbound SETTINGS
/// frame with the ACK flag pops the front and invokes it with
/// `(ack=true, duration=0)`. Mirrors Node's `outstanding_settings_` queue.
pub pending_settings_acks: Vec<v8::Global<v8::Function>>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: Vec::remove(0) is O(n). For a session that posts many session.settings(cb) calls without waiting for ACKs, this becomes quadratic. VecDeque<v8::Global<v8::Function>> with pop_front() would be O(1) and is a drop-in replacement here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 17992c0 — switched to VecDeque<v8::Global<v8::Function>> with push_back / pop_front.

@fibibot fibibot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The custom-settings tracking and SETTINGS-ACK FIFO are well-structured — update_local_custom_settings / update_remote_custom_settings_from_iv mirror Node's logic correctly, the (id | 1<<16) "registered but not received" sentinel is consistent across fetch_allowed_remote_custom_settingswrite_custom_settings_to_buffer, and the JS-side pendingAck cap at kMaxOutstandingSettings (default 10) bounds the VecDeque so the unbounded growth I worried about can't happen from outside.

Two divergences-from-Node I'd want you to either fix or convince me about before I sign off — neither blocks test-http2-session-settings.js (which is why I'm not requesting changes), but both will surface in production use of the API:

  1. Thread-local settings buffer leaks the count across sessions when the new session opts out of remoteCustomSettings. Detail in the inline comment on fetch_allowed_remote_custom_settings.
  2. local_max_header_list_size capture-at-first-send doesn't match Node's behavior, despite what the comment says. Detail in the inline comment on the FIRST-settings block.

CI green. The test does pass once these are landed.

with_settings(|buffer| {
let count = buffer[SettingsIndex::Count as usize + 1] as usize;
if count == 0 {
return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This relies on the thread-local SETTINGS buffer being zero-initialized at the count slot whenever a session is constructed without remoteCustomSettings. But Http2Settings::init (called from fn settings on every JS session.settings({...})) reads — and never resets — buffer[Count + 1]. So this sequence leaks across sessions:

  1. Session A constructed (any remoteCustomSettings, even none).
  2. JS calls sessionA.settings({ customSettings: { 1244: 456 } }). updateSettingsBuffer writes count=1, id=1244, value=456 into the shared buffer. Rust submits the SETTINGS frame. Buffer count remains 1.
  3. Session B is constructed without remoteCustomSettings. JS skips remoteCustomSettingsToBuffer. new InternalHttp2Session(type) calls into Rust → fetch_allowed_remote_custom_settings reads count=1 → registers 1244 as a remote custom setting B should track. Then resets count to 0.
  4. If the peer ever sends a SETTINGS entry with id 1244, it will silently appear on sessionB.remoteSettings.customSettings[1244] even though the user didn't ask for it.

The test enables both remoteCustomSettings on construction so it doesn't hit this, but anyone using customSettings send + a separate session without remoteCustomSettings will. Cleanest fix is to zero buffer[Count + 1] in JS at the start of setupHandle / before new InternalHttp2Session, or pass the registered IDs through a dedicated op argument instead of the shared buffer.

(Same comment also applies to the IDs slots at offsets Count+2 + 2*i: Http2Settings::init doesn't clear them either, so the IDs from a prior session.settings call sit there until overwritten.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in cc9ec5a. Changed setupHandle in ext/node/polyfills/http2.ts to always call remoteCustomSettingsToBuffer(options.remoteCustomSettings || []), which writes count = 0 into the shared buffer when the user didn't pass remoteCustomSettings. So a session B constructed after session A's session.settings({ customSettings }) now sees count=0 in fetch_allowed_remote_custom_settings and doesn't register the leftover IDs.

Comment thread ext/node/ops/http2/session.rs Outdated
// (the constructor's initial settings). Post-construction calls to
// session.settings() change what we advertise but should not retroactively
// tighten our local enforcement — matches Node, which only enforces
// when the limit was set up-front.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The comment is wrong about Node's behavior — Node doesn't capture-at-first-send, it captures per-stream at stream construction by calling nghttp2_session_get_local_settings(session, NGHTTP2_SETTINGS_MAX_HEADER_LIST_SIZE), and that nghttp2 value does update after a post-init session.settings() ACK round-trip. See src/node_http2.cc#L2247:

// Limit the number of header octets
max_header_length_ =
    std::min(
      nghttp2_session_get_local_settings(
        session->session(),
        NGHTTP2_SETTINGS_MAX_HEADER_LIST_SIZE),
    MAX_MAX_HEADER_LIST_SIZE);

So Node's actual semantics are: streams created before a post-init session.settings({ maxHeaderListSize: N }) keep the old limit; streams created after the ACK use the new one.

This PR's behavior — frozen at first Http2Settings::send for the whole session, applied per-header on every stream — is more restrictive than Node in both directions:

  • For old streams: Node's old code (which read nghttp2_session_get_local_settings on every add_header) was already "live" within a stream; the PR makes those streams use the original session-init value forever, even if the user advertised a larger limit later (e.g. raising the cap from the default to allow big-header tools).
  • For new streams: same, but more visibly — the user explicitly tightened the limit, but new streams won't see it.

The test (client.settings({ maxHeaderListSize: 1 })) only checks that the callback fires, not that the new limit is enforced, so this passes — but the semantic is wrong. Easiest fix that matches Node is to drop local_max_header_list_size / initial_settings_applied entirely and keep reading nghttp2_session_get_local_settings(...) in add_header (the original code), but cache it once-per-stream-construction if you want to avoid the FFI call per header.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right, thanks for the citation to node_http2.cc#L2247. Fixed in cc9ec5a: dropped local_max_header_list_size / initial_settings_applied from Session, added max_header_length: u64 to Http2Stream, and snapshot it once in Http2Stream::new via nghttp2_session_get_local_settings(session, NGHTTP2_SETTINGS_MAX_HEADER_LIST_SIZE). add_header reads self.max_header_length, so streams constructed after a post-init session.settings({ maxHeaderListSize: N }) ACK use the new N, while streams from before keep the old value — matching Node's per-stream-at-construction semantics.

…buffer count

- Capture SETTINGS_MAX_HEADER_LIST_SIZE per-stream at construction by reading
  nghttp2_session_get_local_settings, matching node_http2.cc behavior:
  streams created after a post-init session.settings() ACK use the new value.

- In setupHandle, always call remoteCustomSettingsToBuffer (with [] when no

Co-authored-by: Divy Srivastava <[email protected]>  remoteCustomSettings option) so a leftover non-zero count from a prior

  session.settings({ customSettings }) call cannot make the next session
  register stale IDs as remote custom settings.

Co-authored-by: Divy Srivastava <[email protected]>

@fibibot fibibot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM after cc9ec5a7. Both concerns from my prior review are cleanly fixed:

  1. Cross-session settings buffer leaksetupHandle now unconditionally calls remoteCustomSettingsToBuffer(options.remoteCustomSettings || []). The empty-array path still writes count = 0 to the count slot, so a session without remoteCustomSettings no longer inherits leftover IDs from a previous session.settings({ customSettings }) call. The leftover ID slots beyond the count don't matter because fetch_allowed_remote_custom_settings short-circuits on count == 0.

  2. max_header_length enforcement vs Node — moved from a session-level capture-at-first-send to a per-stream Http2Stream::max_header_length field snapshotted at stream construction via nghttp2_session_get_local_settings(...). This now matches Node's Http2Stream::Http2Stream exactly: streams created before a post-init session.settings({ maxHeaderListSize: N }) round-trip keep the prior limit, streams created after see N. The session-level local_max_header_list_size and initial_settings_applied fields are gone, and the comment correctly describes the new semantics. Both stream construction sites in session.rs (line 712 client-initiated, line 2122 server-initiated) go through Http2Stream::new, so the snapshot covers all paths.

CI still building; no failures so far. Nice clean follow-up.

@littledivy littledivy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

@littledivy
littledivy merged commit f2d875d into denoland:main Apr 27, 2026
112 checks passed
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.

3 participants