You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
http2: async iterator rejects complete buffered response after NO_ERROR reset with request side open #66693
No package installation, proxy, Go server, or gRPC library is required. It uses node:http2 as the client and a small node:net peer bound to a loopback ephemeral port. The peer writes explicit HTTP/2 frames with a static-table :status: 200 HPACK entry and a literal grpc-status: 0 trailer. The body is synthetic plain bytes, not protobuf or gRPC framing.
The script synchronizes on events, without scheduling sleeps or retries:
Client starts a POST, writes some request bytes, and deliberately does not call request.end().
Peer sends response HEADERS and initial DATA.
Client creates the built-in async iterator, consumes the initial DATA, verifies writableEnded === false, then pauses iteration.
Peer sends final DATA, trailers with END_STREAM, and RST_STREAM(NO_ERROR) in that order in one socket write. The reset does not overtake the response bytes on the wire.
Client waits for trailers and the reset-induced aborted event. It verifies grpc-status === '0', rstCode === 0, and that all 17 final response bytes remain buffered.
Client resumes the existing iterator. It receives and verifies the exact final bytes.
The following next() throws ERR_STREAM_PREMATURE_CLOSE instead of returning EOF.
The five-second timeout only catches a hung reproduction. Session/socket destruction is in finally, after the EOF read; it does not cause the failure.
How often does it reproduce? Is there a required condition?
Deterministic with the event-gated sequence above. I tested the failing case and both controls 20 times each on both runtimes, checking the failing exit status, specific error code, and complete-byte receipt:
Version
Buffered final DATA + NO_ERROR reset
Same exchange without reset
Final DATA consumed before trailers/reset
v24.18.0
20/20 ERR_STREAM_PREMATURE_CLOSE
20/20 clean EOF
20/20 clean EOF
v26.11.1
20/20 ERR_STREAM_PREMATURE_CLOSE
20/20 clean EOF
20/20 clean EOF
Controls:
node repro.mjs no-reset
node repro.mjs drained
The relevant conditions are an open request side, an already-created readable async iterator, and response DATA still buffered when a post-response NO_ERROR reset arrives. Without event gating, this manifests intermittently in application traffic.
What is the expected behavior? Why is that the expected behavior?
The request side is closed by the reset, but the complete response remains consumable and the readable iterator finishes normally. Successful response END_STREAM has already arrived and every response byte can still be read.
RFC 9113 section 8.1 explicitly permits RST_STREAM(NO_ERROR) after a complete response while a request remains in progress and says clients must not discard responses because of such a reset. Both control cases demonstrate clean EOF on the same response content.
What do you see instead?
On v24.18.0:
{"node":"v24.18.0","mode":"reset","status":"0","rstCode":0,"readableLength":17,"writableEnded":true}
Complete response bytes received.
node:internal/streams/end-of-stream:166
new ERR_STREAM_PREMATURE_CLOSE());
^
Error [ERR_STREAM_PREMATURE_CLOSE]: Premature close
at ClientHttp2Stream.onclose (node:internal/streams/end-of-stream:166:30)
at ClientHttp2Stream.emit (node:events:521:24)
at closeStream (node:internal/http2/core:2033:14)
at Http2Stream.onStreamClose (node:internal/http2/core:605:5) {
code: 'ERR_STREAM_PREMATURE_CLOSE'
}
Node.js v24.18.0
writableEnded becomes true upon reset; it was explicitly checked as false before sending completion, and the script never calls request.end().
v26.11.1 also yields all response bytes and then throws, with getEosOnCloseError at end-of-stream:98, onclose at end-of-stream:265, and closeStream at http2/core:2088.
Additional information
Related, but a different reproducer from #65677 / #65689: that issue ends the request and begins iteration after normal stream closure. It lists v24.18.0 as passing. This case leaves the request open and starts/consumes the iterator before closure; it fails on v24.18.0 as well. I have not tested #65689's proposed patch against this case and am not claiming it fixes or fails to fix it.
The observed sequence suggests readable end-of-stream handling interprets the reset-induced duplex aborted event as premature response termination while completed-response bytes are buffered. Those bytes can subsequently be yielded, but the iterator retains a premature-close error for its next read. This is a mechanism hypothesis for maintainers to validate, not a proposed production monkeypatch.
This was observed in the wild with gRPC, but it is a more general HTTP/2 response-consumption issue. The standalone reproduction uses only Node core and synthetic response bytes; it does not depend on gRPC or an application framework.
Filing this to determine whether the open-request/active-iterator case belongs with #65677 or requires separate handling.
Hi @jbeda, thanks for the report, this seems like a clear bug, independent of #65677 I think. I'll look into it, this ties into various other HTTP/2 changes I've been doing recently.
I think in practice it's likely we won't fix this in v24 as it hits the end of active support (moving to security fixes only) one week from now, but I think there are good solutions for v26 and main (which already has substantial changes in this area already from #63249).
Confirming your read, @pimterry: this is independent of #65677. I built #65689, which fixes that one, and ran your reproducer against it. reset still fails with ERR_STREAM_PREMATURE_CLOSE while no-reset and drained both pass, so the premature close here is not the stale close error timing that #65689 addresses.
The difference looks like the request side still being open. #65677 was a fully buffered response after a normal close, where the error was computed before the buffer drained and then reported a tick later. Here the stream is reset with NO_ERROR while the writable side is still open, so getEosOnCloseError() sees a readable that genuinely has not finished and reports a real premature close. Recomputing it later does not change that.
Nothing needed from me, just wanted to save you checking the overlap.
Activity
pimterry commentedon Oct 13, 2026
Hi @jbeda, thanks for the report, this seems like a clear bug, independent of #65677 I think. I'll look into it, this ties into various other HTTP/2 changes I've been doing recently.
I think in practice it's likely we won't fix this in v24 as it hits the end of active support (moving to security fixes only) one week from now, but I think there are good solutions for v26 and main (which already has substantial changes in this area already from #63249).
jbeda commentedon Oct 14, 2026
Thanks! We are working around it but wanted to make sure I at least had a bug number to reference while we did so.
fix(sdk): end gRPC request sides when response trailers arrive
bitpshr commentedon Oct 19, 2026
Confirming your read, @pimterry: this is independent of #65677. I built #65689, which fixes that one, and ran your reproducer against it.
resetstill fails withERR_STREAM_PREMATURE_CLOSEwhileno-resetanddrainedboth pass, so the premature close here is not the stale close error timing that #65689 addresses.The difference looks like the request side still being open. #65677 was a fully buffered response after a normal close, where the error was computed before the buffer drained and then reported a tick later. Here the stream is reset with
NO_ERRORwhile the writable side is still open, sogetEosOnCloseError()sees a readable that genuinely has not finished and reports a real premature close. Recomputing it later does not change that.Nothing needed from me, just wanted to save you checking the overlap.