Skip to content

Commit 5f96180

Browse files
authored
http2: error for incomplete reads on RST, auto-drain, deprecate aborted
Signed-off-by: Tim Perry <pimterry@gmail.com> PR-URL: #63249 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Stephen Belanger <admin@stephenbelanger.com> Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
1 parent 6813080 commit 5f96180

34 files changed

Lines changed: 867 additions & 144 deletions

doc/api/deprecations.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4574,7 +4574,62 @@ throwing an error. This behavior is inconsistent with `hash.digest()` and
45744574
may lead to subtle bugs. Calling `hmac.digest()` on a finalized `Hmac` instance
45754575
will throw an error in a future version.
45764576
4577+
### DEP0207: `.aborted` property and `'aborted'` event in `http2`
4578+
4579+
<!-- YAML
4580+
changes:
4581+
- version: REPLACEME
4582+
pr-url: https://github.com/nodejs/node/pull/63249
4583+
description: Documentation-only deprecation.
4584+
-->
4585+
4586+
Type: Documentation-only
4587+
4588+
Use standard stream events and state checks instead. Read-side aborts
4589+
(peer cancelled before sending `END_STREAM`) now surface as `'error'`
4590+
with code `ERR_HTTP2_STREAM_ABORTED` (clean peer reset code) or
4591+
`ERR_HTTP2_STREAM_ERROR` (non-clean code). Write-side aborts (peer
4592+
cancelled while we still had writes in flight) are detectable from
4593+
`'close'` by checking `writableFinished`. Parallels [DEP0156][] for
4594+
`http`.
4595+
4596+
```cjs
4597+
// Deprecated
4598+
server.on('stream', (stream) => {
4599+
stream.on('aborted', () => {
4600+
// Stream was closed while the writable was still open.
4601+
});
4602+
});
4603+
```
4604+
4605+
```cjs
4606+
// Use this instead
4607+
server.on('stream', (stream) => {
4608+
// Read-side abort: peer cancelled before sending END_STREAM.
4609+
stream.on('error', (err) => {
4610+
if (err.code === 'ERR_HTTP2_STREAM_ABORTED' ||
4611+
err.code === 'ERR_HTTP2_STREAM_ERROR') {
4612+
// Peer cancelled the request mid-stream.
4613+
}
4614+
});
4615+
// Write-side abort: our response didn't fully send before close.
4616+
stream.on('close', () => {
4617+
if (!stream.writableFinished) {
4618+
// Writes were aborted (peer cancel, local destroy, etc.).
4619+
}
4620+
});
4621+
});
4622+
```
4623+
4624+
The same patterns apply to the compatibility API (`req` / `res` on
4625+
`http2.createServer((req, res) => …)`). On the read-side, errors on the
4626+
underlying stream are emitted from `req`. On the write-side you can use
4627+
`res.on('close', …)` to hear about client aborts by checking
4628+
`res.writableFinished` to confirm whether the response was written
4629+
successfully before the response closed.
4630+
45774631
[DEP0142]: #dep0142-repl_builtinlibs
4632+
[DEP0156]: #dep0156-aborted-property-and-abort-aborted-event-in-http
45784633
[NIST SP 800-38D]: https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf
45794634
[RFC 6066]: https://tools.ietf.org/html/rfc6066#section-3
45804635
[RFC 8247 Section 2.4]: https://www.rfc-editor.org/rfc/rfc8247#section-2.4

doc/api/errors.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1718,6 +1718,15 @@ Use of the `101` Informational status code is forbidden in HTTP/2.
17181718
An invalid HTTP status code has been specified. Status codes must be an integer
17191719
between `100` and `599` (inclusive).
17201720

1721+
<a id="ERR_HTTP2_STREAM_ABORTED"></a>
1722+
1723+
### `ERR_HTTP2_STREAM_ABORTED`
1724+
1725+
The peer reset the `Http2Stream` with a clean error code (`NGHTTP2_NO_ERROR`
1726+
or `NGHTTP2_CANCEL`) before sending `END_STREAM`, so the readable side will
1727+
not be fully delivered. Mirrors HTTP/1's `ECONNRESET` for a peer-side
1728+
`socket.destroy()`.
1729+
17211730
<a id="ERR_HTTP2_STREAM_CANCEL"></a>
17221731

17231732
### `ERR_HTTP2_STREAM_CANCEL`

doc/api/http2.md

Lines changed: 60 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1238,22 +1238,23 @@ the value is `undefined`, the stream is not yet ready for use.
12381238

12391239
##### Destruction
12401240

1241-
All [`Http2Stream`][] instances are destroyed either when:
1241+
All [`Http2Stream`][] instances are destroyed when one of the following
1242+
happens:
12421243

1243-
* An `RST_STREAM` frame for the stream is received by the connected peer,
1244-
and (for client streams only) pending data has been read.
1245-
* The `http2stream.close()` method is called, and (for client streams only)
1246-
pending data has been read.
1247-
* The `http2stream.destroy()` or `http2session.destroy()` methods are called.
1244+
* Both sides send `END_STREAM` (a clean exchange).
1245+
* The peer sends an `RST_STREAM` frame.
1246+
* `http2stream.close()`, `http2stream.destroy()`, or `http2session.destroy()`
1247+
is called locally.
12481248

1249-
When an `Http2Stream` instance is destroyed, an attempt will be made to send an
1250-
`RST_STREAM` frame to the connected peer.
1249+
For clean exchanges and clean cancels, the destroy is deferred until any
1250+
pending `'end'` and `'finish'` events have fired. When destroyed, an
1251+
attempt is made to send an `RST_STREAM` frame to the connected peer if
1252+
one hasn't already been sent.
12511253

1252-
When the `Http2Stream` instance is destroyed, the `'close'` event will
1253-
be emitted. Because `Http2Stream` is an instance of `stream.Duplex`, the
1254-
`'end'` event will also be emitted if the stream data is currently flowing.
1255-
The `'error'` event may also be emitted if `http2stream.destroy()` was called
1256-
with an `Error` passed as the first argument.
1254+
`'close'` is always emitted on destroy. `'end'` and `'finish'` fire if
1255+
their respective halves completed before destroy. `'error'` fires when
1256+
the destroy carries an error — either via `http2stream.destroy(err)`,
1257+
or when the peer reset the stream before sending `END_STREAM`.
12571258

12581259
After the `Http2Stream` has been destroyed, the `http2stream.destroyed`
12591260
property will be `true` and the `http2stream.rstCode` property will specify the
@@ -1264,14 +1265,18 @@ destroyed.
12641265

12651266
<!-- YAML
12661267
added: v8.4.0
1268+
changes:
1269+
- version: REPLACEME
1270+
pr-url: https://github.com/nodejs/node/pull/63249
1271+
description: Documentation-only deprecation.
12671272
-->
12681273

1269-
The `'aborted'` event is emitted whenever a `Http2Stream` instance is
1270-
abnormally aborted in mid-communication.
1271-
Its listener does not expect any arguments.
1274+
> Stability: 0 - Deprecated. Use `'close'` and `'error'` plus
1275+
> `stream.destroyed`.
12721276
1273-
The `'aborted'` event will only be emitted if the `Http2Stream` writable side
1274-
has not been ended.
1277+
Emitted when an `Http2Stream` is closed before the writable side has
1278+
been ended (via `.end()` or auto-ended via `respond({ endStream: true })`).
1279+
Listeners receive no arguments.
12751280

12761281
#### Event: `'close'`
12771282

@@ -1283,19 +1288,29 @@ The `'close'` event is emitted when the `Http2Stream` is destroyed. Once
12831288
this event is emitted, the `Http2Stream` instance is no longer usable.
12841289

12851290
The HTTP/2 error code used when closing the stream can be retrieved using
1286-
the `http2stream.rstCode` property. If the code is any value other than
1287-
`NGHTTP2_NO_ERROR` (`0`), an `'error'` event will have also been emitted.
1291+
the `http2stream.rstCode` property.
12881292

12891293
#### Event: `'error'`
12901294

12911295
<!-- YAML
12921296
added: v8.4.0
1297+
changes:
1298+
- version: REPLACEME
1299+
pr-url: https://github.com/nodejs/node/pull/63249
1300+
description: >-
1301+
Also emitted on peer-initiated resets that arrive before
1302+
`END_STREAM` (`ERR_HTTP2_STREAM_ABORTED` for clean codes,
1303+
`ERR_HTTP2_STREAM_ERROR` otherwise). Locally-initiated resets
1304+
without an explicit error remain silent.
12931305
-->
12941306

12951307
* `error` {Error}
12961308

1297-
The `'error'` event is emitted when an error occurs during the processing of
1298-
an `Http2Stream`.
1309+
Emitted when an error occurs processing the `Http2Stream`. This includes
1310+
peer-initiated resets that arrive before the readable side has been
1311+
fully delivered: a clean reset code (`NGHTTP2_NO_ERROR` or
1312+
`NGHTTP2_CANCEL`) surfaces as [`ERR_HTTP2_STREAM_ABORTED`][], any other
1313+
code as [`ERR_HTTP2_STREAM_ERROR`][].
12991314

13001315
#### Event: `'frameError'`
13011316

@@ -1378,8 +1393,8 @@ added: v8.4.0
13781393

13791394
* Type: {boolean}
13801395

1381-
Set to `true` if the `Http2Stream` instance was aborted abnormally. When set,
1382-
the `'aborted'` event will have been emitted.
1396+
`true` if the `Http2Stream` was closed while the writable side was
1397+
still open. When set, the `'aborted'` event was emitted.
13831398

13841399
#### `http2stream.bufferSize`
13851400

@@ -1723,6 +1738,13 @@ stream.on('push', (headers, flags) => {
17231738

17241739
<!-- YAML
17251740
added: v8.4.0
1741+
changes:
1742+
- version: REPLACEME
1743+
pr-url: https://github.com/nodejs/node/pull/63249
1744+
description: >-
1745+
If no `'response'` listener is attached when the response headers
1746+
arrive, the response body is now silently discarded - matching
1747+
the `lib/http` client behaviour.
17261748
-->
17271749

17281750
* `headers` {HTTP/2 Headers Object}
@@ -1744,6 +1766,16 @@ req.on('response', (headers, flags) => {
17441766
});
17451767
```
17461768

1769+
If no `'response'` listener is attached at the moment the response
1770+
arrives, the response body will be entirely discarded (the stream is
1771+
silently resumed). However, if a `'response'` listener is added, the
1772+
data from the response object **must** be consumed — either by calling
1773+
`response.read()` whenever there is a `'readable'` event, by adding a
1774+
`'data'` handler, or by calling the `.resume()` method. Until the data
1775+
is consumed, the `'end'` event will not fire. Also, until the data is
1776+
read, it will consume memory that can eventually lead to a "process
1777+
out of memory" error.
1778+
17471779
```cjs
17481780
const http2 = require('node:http2');
17491781
const client = http2.connect('https://localhost');
@@ -4035,11 +4067,8 @@ data.
40354067
added: v8.4.0
40364068
-->
40374069

4038-
The `'aborted'` event is emitted whenever a `Http2ServerRequest` instance is
4039-
abnormally aborted in mid-communication.
4040-
4041-
The `'aborted'` event will only be emitted if the `Http2ServerRequest` writable
4042-
side has not been ended.
4070+
The `'aborted'` event is emitted whenever a `Http2ServerRequest` instance
4071+
is closed while the underlying writable side is still open.
40434072

40444073
#### Event: `'close'`
40454074

@@ -5050,6 +5079,8 @@ you need to implement any fall-back behavior yourself.
50505079
[`'unknownProtocol'`]: #event-unknownprotocol
50515080
[`ClientHttp2Stream`]: #class-clienthttp2stream
50525081
[`Duplex`]: stream.md#class-streamduplex
5082+
[`ERR_HTTP2_STREAM_ABORTED`]: errors.md#err_http2_stream_aborted
5083+
[`ERR_HTTP2_STREAM_ERROR`]: errors.md#err_http2_stream_error
50535084
[`Http2ServerRequest`]: #class-http2http2serverrequest
50545085
[`Http2ServerResponse`]: #class-http2http2serverresponse
50555086
[`Http2Session` and Sockets]: #http2session-and-sockets

lib/internal/errors.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1321,6 +1321,8 @@ E('ERR_HTTP2_SOCKET_UNBOUND',
13211321
E('ERR_HTTP2_STATUS_101',
13221322
'HTTP status code 101 (Switching Protocols) is forbidden in HTTP/2', Error);
13231323
E('ERR_HTTP2_STATUS_INVALID', 'Invalid status code: %s', RangeError);
1324+
E('ERR_HTTP2_STREAM_ABORTED',
1325+
'The stream was reset before the readable side was fully consumed', Error);
13241326
E('ERR_HTTP2_STREAM_CANCEL', function(error) {
13251327
let msg = 'The pending stream has been canceled';
13261328
if (error) {

lib/internal/http2/compat.js

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -158,11 +158,11 @@ function onStreamEnd() {
158158
}
159159

160160
function onStreamError(error) {
161-
// This is purposefully left blank
162-
//
163-
// errors in compatibility mode are
164-
// not forwarded to the request
165-
// and response objects.
161+
// Mirror HTTP/1's IncomingMessage._destroy: forward 'error' to req
162+
// only when a listener is attached.
163+
const request = this[kRequest];
164+
if (request !== undefined && request.listenerCount('error') > 0)
165+
request.emit('error', error);
166166
}
167167

168168
function onRequestPause() {
@@ -460,7 +460,9 @@ function onStreamCloseResponse() {
460460
this.removeListener('wantTrailers', onStreamTrailersReady);
461461
this[kResponse] = undefined;
462462

463-
res.emit('finish');
463+
// Only emit 'finish' when the underlying writable actually finished
464+
if (this.writableFinished)
465+
res.emit('finish');
464466
res.emit('close');
465467
}
466468

0 commit comments

Comments
 (0)