fix(ext/node): track http2 custom settings and SETTINGS ACK callbacks#33518
Conversation
Enables tests/node_compat/runner/suite/test/parallel/test-http2-session-settings.js Co-authored-by: Divy Srivastava <[email protected]>
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]>
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]>
e7d1d2f to
8e7df25
Compare
fibibot
left a comment
There was a problem hiding this comment.
LGTM on the core mechanism. Walked the three main pieces:
-
Custom settings tracking —
local_custom_settingsfor outbound,remote_custom_settingsfor inbound, with bit 16 of the stored ID acting as a "registered but no value yet received" sentinel thatwrite_custom_settings_to_bufferfilters out (if id & !0xffff_u32 != 0 { continue; }). Thewrite_custom_settings_to_bufferindexing (offset = Count + 2) lines up with the existing Count+1 = count slot, Count+2 = first entry, two slots per entry. Theupdate_remote_custom_settings_from_ivcorrectly clears bit 16 viaslot.0 = id_lo as i32after a real value arrives. -
local_max_header_list_sizecapture — capturing only on the firstHttp2Settings::send(gated byinitial_settings_applied) and using it inadd_headerinstead ofnghttp2_session_get_local_settings(...)is exactly Node's "enforce only when set up-front" behavior. Defaultu32::MAXcorrectly means "no enforcement" when the constructor settings didn't includemaxHeaderListSize. -
SETTINGS ACK FIFO —
pending_settings_acks: Vec<v8::Global<v8::Function>>,settings(cb)pushes,handle_settings_ackpops 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 |
There was a problem hiding this comment.
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 cb1 — cb1 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.
There was a problem hiding this comment.
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.settings → submitSettings → kHandle.settings(boundCb) path (with settingsCallback.bind(this, undefined) as the cb), so the queue and outbound SETTINGS frames stay 1:1.
| /// `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>>, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Done in 17992c0 — switched to VecDeque<v8::Global<v8::Function>> with push_back / pop_front.
…bmit Co-authored-by: Divy Srivastava <[email protected]>
fibibot
left a comment
There was a problem hiding this comment.
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_settings → write_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:
- Thread-local settings buffer leaks the count across sessions when the new session opts out of
remoteCustomSettings. Detail in the inline comment onfetch_allowed_remote_custom_settings. local_max_header_list_sizecapture-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; |
There was a problem hiding this comment.
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:
- Session A constructed (any
remoteCustomSettings, even none). - JS calls
sessionA.settings({ customSettings: { 1244: 456 } }).updateSettingsBufferwritescount=1,id=1244,value=456into the shared buffer. Rust submits the SETTINGS frame. Buffer count remains 1. - Session B is constructed without
remoteCustomSettings. JS skipsremoteCustomSettingsToBuffer.new InternalHttp2Session(type)calls into Rust →fetch_allowed_remote_custom_settingsreadscount=1→ registers1244as a remote custom setting B should track. Then resets count to 0. - 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.)
There was a problem hiding this comment.
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.
| // (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. |
There was a problem hiding this comment.
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_settingson everyadd_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.
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
LGTM after cc9ec5a7. Both concerns from my prior review are cleanly fixed:
-
Cross-session settings buffer leak —
setupHandlenow unconditionally callsremoteCustomSettingsToBuffer(options.remoteCustomSettings || []). The empty-array path still writescount = 0to the count slot, so a session withoutremoteCustomSettingsno longer inherits leftover IDs from a previoussession.settings({ customSettings })call. The leftover ID slots beyond the count don't matter becausefetch_allowed_remote_custom_settingsshort-circuits oncount == 0. -
max_header_lengthenforcement vs Node — moved from a session-level capture-at-first-send to a per-streamHttp2Stream::max_header_lengthfield snapshotted at stream construction vianghttp2_session_get_local_settings(...). This now matches Node'sHttp2Stream::Http2Streamexactly: streams created before a post-initsession.settings({ maxHeaderListSize: N })round-trip keep the prior limit, streams created after see N. The session-levellocal_max_header_list_sizeandinitial_settings_appliedfields are gone, and the comment correctly describes the new semantics. Both stream construction sites insession.rs(line 712 client-initiated, line 2122 server-initiated) go throughHttp2Stream::new, so the snapshot covers all paths.
CI still building; no failures so far. Nice clean follow-up.
Summary
Enables
test-http2-session-settingsin node_compat suite.Test plan
cargo test --test node_compat -- test-http2-session-settings