Skip to content

nghttp2: a peer's RST_STREAM(NO_ERROR) on a request still arriving releases it, as any other reset does - #495

Open
MDA2AV wants to merge 3 commits into
mainfrom
fix/nghttp2-rst-no-error
Open

MDA2AV wants to merge 3 commits into
mainfrom
fix/nghttp2-rst-no-error

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Nov 4, 2026 •

Copy link
Copy Markdown
Owner

What was wrong

  • In buffered mode (a handler without a body reader), the shim told the managed side about a stream's close only when the close carried an error code. A NO_ERROR close was taken to be the normal end of a stream whose response had completed.
  • A peer's RST_STREAM(NO_ERROR) also closes with NO_ERROR, and it can end a stream whose request is still arriving: headers without END_STREAM, maybe some DATA, then the reset.
  • nghttp2 closed and freed the stream, but CallbackStreamError never ran. So the half-built PendingRequest, with its arena holding the headers and any body so far, stayed in _pending until the connection closed.
  • A peer could repeat that on a long-lived connection to grow its memory. Only nghttp2's reset rate limit bounded it.

Details are in #493.

The fix

  • on_stream_close now also reports a NO_ERROR close when our side of the stream is still open (nghttp2_session_get_stream_local_close returns 0). CallbackStreamError then releases the request, as it already did for every other reset.
  • That tells a peer's reset from a stream's normal end exactly. A normal end always has our END_STREAM out first: nghttp2 sets SHUT_WR when it sends it, before it closes the stream. And in buffered mode our side cannot finish before the request has, since a handler runs only once its request has ended.
  • The peer's side cannot tell them apart: nghttp2 marks a stream half-closed when RST_STREAM arrives, before it calls on_stream_close, so nghttp2_session_get_stream_remote_close reports 1 for both.
  • Unchanged:
    • Resets with an error code are reported as before.
    • A managed side that reads request bodies itself (manual_flow) still hears every close.
    • The normal end of a stream still makes no call into C#.

Native libraries

runtimes/linux-x64 and runtimes/linux-arm64 are rebuilt with scripts/build-nghttp2-native.sh at its pinned nghttp2 ref:

  • x64 on the host;
  • arm64 with the aarch64-linux-gnu cross toolchain in an Ubuntu 24.04 container.

Both toolchains first rebuilt main's own shim byte for byte identical to the committed libraries, so the only difference in the new ones is this change. Both still need nothing past GLIBC 2.17.

Behaviour

  • A request its peer resets with NO_ERROR before it ends is now released at once, like any other reset.
  • A streamed response whose peer resets it with NO_ERROR after it was dispatched is marked gone at once (OnPeerReset). Its next flush reports IsCompleted, with or without bytes staged. Before, a writer reset before its headers went out got no such word: a flush with nothing staged reported success, and one past 64 KiB waited until the connection closed. A reset that arrives in the same read as the request's end comes before the writer exists; that case is nghttp2: a response submitted after the peer reset its stream leaks its body in the shim, and a streamed writer can hang #496.
  • Nothing changes for a stream that ends normally.

API

None.

Tests

New in the Unit suite (Nghttp2StreamCloseTests.cs), over the strict test transport:

  • nghttp2: a request its peer resets with NO_ERROR before it ends is released
  • nghttp2: a request its peer resets with CANCEL before it ends is released, the control

Each sends a POST's headers without END_STREAM, then the reset. It checks that _pending is empty (read by reflection, since nothing else can see it), that no handler ran, and that the connection serves its next request.

  • nghttp2: a streamed response its peer resets with NO_ERROR hears the peer gone at a flush of 131072 B, and of 0 B
    • A GET whose handler parks before writing its headers, then the reset, then the handler's flush, which has to return IsCompleted.

Against main's library (the tests only):

FAIL  nghttp2: a request its peer resets with NO_ERROR before it ends is released: expected [0], got [1]
PASS  nghttp2: a request its peer resets with CANCEL before it ends is released
FAIL  nghttp2: a streamed response its peer resets with NO_ERROR hears the peer gone at a flush of 131072 B: the flush of a stream its peer reset never returned
FAIL  nghttp2: a streamed response its peer resets with NO_ERROR hears the peer gone at a flush of 0 B: expected [peer gone], got [flushed]
1 passed, 3 failed, 0 skipped

With the fix:

  • Unit: 149 passed.
  • E2E: 453 passed.
  • Chaos: 83 passed, including the nghttp2 twins.
  • Http: 95 passed.

Bench

The normal end of a stream gains one stream lookup in C (nghttp2_session_get_stream_local_close). Measured with the same setup as #486:

  • One reactor pinned to CPU 6, and h2load -c 32 -m 16 -t 1 pinned to CPU 2.
  • Main, this branch, and main again from a second worktree as a control.
  • Six interleaved rounds; each build is compared with main inside its own round.
Sample Server CPU per request, branch vs main Control vs main
Http2/Nghttp2Buffered -0.6% -0.2%
Http2/Nghttp2Response (8 chunks of 1 KiB) -1.8% -1.0%

Both are inside the noise; the per-round ranges overlap.

Review

A review of the first version found the premise above wrong: it reported the reset from on_frame_recv, on the grounds that on_stream_close could not tell it from a normal end. Checking our own side can, so the report moved back into on_stream_close. That keeps every close in one callback, and a stale RST_STREAM on a stream already closed is no longer forwarded.

Fixes #493.

…leases it, as any other reset does

The shim reported a stream's close to the managed side only when it carried an error code, taking a
NO_ERROR close for the normal end of a completed stream. A peer's RST_STREAM(NO_ERROR) closes with
NO_ERROR too, and on a request still arriving (headers without END_STREAM) that left the half-built
request and its arena in _pending until the connection closed (#493).

on_frame_recv now reports a peer's RST_STREAM(NO_ERROR) as it arrives, just before nghttp2 closes
the stream. on_stream_close cannot tell it from a normal end: nghttp2 marks a stream half-closed when
it receives RST_STREAM, before the close callback. Error resets are still reported from
on_stream_close, a managed side reading bodies itself still hears every close there, and a normal
end still makes no call into C#.

Both runtimes' libraries are rebuilt with scripts/build-nghttp2-native.sh; main's shim rebuilds byte
for byte identical with the same toolchains (x64 on the host, arm64 cross-compiled in an Ubuntu
24.04 container).
…end by whether our END_STREAM went out

From review: the first version reported a peer's RST_STREAM(NO_ERROR) from on_frame_recv, on the
premise that on_stream_close could not tell it from a stream's normal end. It can. A normal end
always has our END_STREAM out first (nghttp2 sets SHUT_WR when it sends it), and in buffered mode
our side cannot finish before the request has, so a NO_ERROR close with SHUT_WR unset is a reset.

on_stream_close now reports a NO_ERROR close when nghttp2_session_get_stream_local_close says our
side is still open, and on_frame_recv is back as on main. All close reporting stays in one callback,
and a stale RST_STREAM on a stream already closed is no longer forwarded. Both runtimes' libraries
are rebuilt from this source.
…et at its next flush, with or without bytes staged

This branch has not been deployed

No deployments
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.

nghttp2: a peer's RST_STREAM(NO_ERROR) mid-request never reaches the connection in buffered mode, so the request stays in _pending until it closes

1 participant