Skip to content

quic: improve stream cleanup & lookup - #65944

Open
pimterry wants to merge 1 commit into
nodejs:mainfrom
pimterry:nghttp3-stream-caching
Open

quic: improve stream cleanup & lookup#65944
pimterry wants to merge 1 commit into
nodejs:mainfrom
pimterry:nghttp3-stream-caching

Conversation

@pimterry

@pimterry pimterry commented Sep 9, 2026

Copy link
Copy Markdown
Member

Extracted from #63995, but with a few further improvements en route.

The core change here is to stop querying for our Stream instance by id in every on_read_data, on_acked_stream_data and on_receive_data call (all hot paths where this is awkward & expensive). We now cache it in nghttp3's stream-linked data, which it then passes back to each callback for us, so we can just read it directly.

Note that both ngtcp2 & nghttp3 separately track per-stream user data, which is a bit confusing for some of the explanation below - we're caching this in nghttp3 user data.

Having extracted that though and working through the logic to clean up when H3 streams close, I found some interesting problems, so this expanded a bit:

  • We actually already stashed &stream on every nghttp3 client stream (the last argument to nghttp3_conn_submit_request) but we never read it or never cleaned it up - this was a dangling pointer that's only safe because it was unused.
  • There are multiple cleanup paths required (local destroy & remote close can happen in either order) but the stream closure chain to do that had a series of nested methods which took overlapping parts of the process and weaved in & out of nghttp3/ngtcp2 callbacks unnecessarily en route, with special cases depending on which other steps had already completed. This was hard to follow, but also created some bugs:
    • Applications weren't actually informed about local stream closure, and in some cases nghttp3 never cleaned up local streams at all: stream.destroy() from JS called Session::RemoveStream which cleared ngtcp2 stream user data beforehand, and then Session::on_stream_close exited early if the ngtcp2 user data was null. That means we skipped calling ReceiveStreamClose, which means we never called nghttp3_conn_close_stream2, which is what actually frees the nghttp3 stream. End result is that locally destroyed nghttp3 streams were orphaned & retained for the lifetime of the entire connection.
    • Http3ApplicationImpl::OnStreamClose called ExtendMaxStreams, and then Session::RemoveStream called ngtcp2_conn_extend_max_streams_* as well, so closing any remote stream actually expanded the max-streams window twice.
    • Http3ApplicationImpl::OnStreamClose called ExtendMaxStreams with REMOTE unconditionally, even though it was called for local streams too, so closing a local stream expanded the remote stream window as well.
  • More generally: the max stream window was tightly tied to the lifetime of our Stream object & application behaviour, not the actual live streams in ngtcp2, which is the thing it should be limiting.

End result: we had a few different & overlapping paths that stream closure could take, and the interactions created a selection of small bugs.

Src changes here are a net deletion, simplifying this and fixing the bugs:

  • There's now one hook for "stream on the wire closed" which runs in all cases when a QUIC stream is actually closed. Session:on_stream_close calls application().ReceiveStreamClose(), and the application cleans up its state there in one place.
  • There's now one hook for "local Stream destruction" (by JS, or after closure on the wire): Session::RemoveStream cleans up that side, and calls application.StreamRemoved() to inform the app when it's gone.
  • Nghttp3 stream close callbacks become redundant, so that code is just removed. Nghttp3 close events only ever happened after we told nghttp3 about it from ReceiveStreamClose, so we just cleanup in a single step there instead.
  • The max-stream limit is tied directly to ngtcp2 stream closure: when on_stream_close fires, the credit is returned immediately.
  • Max-stream logic on Applications (ExtendMaxStreams, EndpointLabel) goes away completely.
  • The original goal: we cache our Stream object in nghttp3 so we don't have to look it up for every data callback, and successfully clean that up later when the Stream is gone.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/quic

Signed-off-by: Tim Perry <pimterry@gmail.com>
@nodejs-github-bot nodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run. labels Sep 9, 2026
@pimterry
pimterry force-pushed the nghttp3-stream-caching branch from 6d51d4e to 28daf59 Compare September 9, 2026 17:10
@codecov

codecov Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.18%. Comparing base (b3fb344) to head (28daf59).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #65944      +/-   ##
==========================================
+ Coverage   90.16%   90.18%   +0.01%     
==========================================
  Files         771      771              
  Lines      265434   265445      +11     
  Branches    50450    50457       +7     
==========================================
+ Hits       239332   239395      +63     
+ Misses      17045    16993      -52     
  Partials     9057     9057              

see 28 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread src/quic/application.cc
Stream* stream,
QuicError&& error) {
DCHECK_NOT_NULL(stream);
if (stream == nullptr) return;

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.

Nit: let's add a comment here explaining briefly the conditions in whch stream can be nullptr

Comment thread src/quic/session.cc
if (flags & NGTCP2_STREAM_CLOSE_FLAG_APP_ERROR_CODE_SET) {
session->application().ReceiveStreamClose(
stream, QuicError::ForApplication(app_error_code));
stream_id, stream, QuicError::ForApplication(app_error_code));

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.

A comment here explaining that we're passing stream_id independently from stream because stream might be nullptr would be good.

@jasnell jasnell 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 with a couple of comment nits

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++ Issues and PRs that require attention from people who are familiar with C++. lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants