Skip to content

Commit e2f40e3

Browse files
committed
http2: emit error on canceled streams when aborted event is not emitted
When a client HTTP/2 stream's writable side is already ended (e.g. GET requests), receiving RST code 8 (NGHTTP2_CANCEL) emitted neither the 'aborted' nor the 'error' event, causing the stream to close silently. This happened because the 'aborted' event is only emitted when the writable side is still open, but the NGHTTP2_CANCEL code was unconditionally excluded from error generation assuming the 'aborted' event would cover it. Fix by only skipping error generation for NGHTTP2_CANCEL when the 'aborted' event was actually emitted. Fixes: #56627 Signed-off-by: Matteo Collina <hello@matteocollina.com>
1 parent 58a8e1d commit e2f40e3

9 files changed

Lines changed: 97 additions & 12 deletions

lib/internal/http2/core.js

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2409,8 +2409,20 @@ class Http2Stream extends Duplex {
24092409
validateFunction(callback, 'callback');
24102410
}
24112411

2412-
if (this.closed)
2412+
if (this.closed) {
2413+
// A client stream may already have been marked as closed with
2414+
// NGHTTP2_NO_ERROR by the time the session or underlying socket is
2415+
// canceled. Preserve the cancelation code so _destroy() can still emit
2416+
// the expected stream error when the aborted event was not emitted.
2417+
if (code === NGHTTP2_CANCEL &&
2418+
this[kSession] !== undefined &&
2419+
this[kSession][kType] === NGHTTP2_SESSION_CLIENT &&
2420+
this.rstCode === NGHTTP2_NO_ERROR &&
2421+
!this.aborted) {
2422+
this[kState].rstCode = code;
2423+
}
24132424
return;
2425+
}
24142426

24152427
if (callback !== undefined)
24162428
this.once('close', callback);
@@ -2441,6 +2453,14 @@ class Http2Stream extends Duplex {
24412453
// Previously, this always overrode a successful close operation code
24422454
// NGHTTP2_NO_ERROR (0) with sessionCode because the use of the || operator.
24432455
let code = this.closed ? this.rstCode : sessionCode;
2456+
if (this.closed &&
2457+
code === NGHTTP2_NO_ERROR &&
2458+
sessionCode === NGHTTP2_CANCEL &&
2459+
session[kType] === NGHTTP2_SESSION_CLIENT &&
2460+
!this.aborted) {
2461+
code = NGHTTP2_CANCEL;
2462+
state.rstCode = code;
2463+
}
24442464
if (err != null) {
24452465
if (sessionCode) {
24462466
code = sessionCode;
@@ -2468,11 +2488,17 @@ class Http2Stream extends Duplex {
24682488
sessionState.writeQueueSize -= state.writeQueueSize;
24692489
state.writeQueueSize = 0;
24702490

2471-
// RST code 8 not emitted as an error as its used by clients to signify
2472-
// abort and is already covered by aborted event, also allows more
2473-
// seamless compatibility with http1
2474-
if (err == null && code !== NGHTTP2_NO_ERROR && code !== NGHTTP2_CANCEL)
2491+
// RST code 8 is commonly used by clients to signify abort and is already
2492+
// covered by the aborted event, which also keeps better compatibility with
2493+
// http1.
2494+
// However, if the aborted event was not emitted (e.g. because the
2495+
// writable side was already ended), client streams must still report the
2496+
// cancelation as an error.
2497+
if (err == null && code !== NGHTTP2_NO_ERROR &&
2498+
(code !== NGHTTP2_CANCEL ||
2499+
session[kType] === NGHTTP2_SESSION_CLIENT && !this.aborted)) {
24752500
err = new ERR_HTTP2_STREAM_ERROR(nameForErrorCode[code] || code);
2501+
}
24762502

24772503
this[kSession] = undefined;
24782504
this[kHandle] = undefined;
@@ -3563,10 +3589,13 @@ function socketOnClose() {
35633589
debugSessionObj(session, 'socket closed');
35643590
const err = session.connecting ? new ERR_SOCKET_CLOSED() : null;
35653591
const state = session[kState];
3592+
const code = session[kType] === NGHTTP2_SESSION_CLIENT ?
3593+
NGHTTP2_CANCEL :
3594+
NGHTTP2_NO_ERROR;
35663595
state.streams.forEach((stream) => stream.close(NGHTTP2_CANCEL));
35673596
state.pendingStreams.forEach((stream) => stream.close(NGHTTP2_CANCEL));
35683597
session.close();
3569-
closeSession(session, NGHTTP2_NO_ERROR, err);
3598+
closeSession(session, code, err);
35703599
}
35713600
}
35723601

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
if (!common.hasCrypto)
5+
common.skip('missing crypto');
6+
const assert = require('assert');
7+
const h2 = require('http2');
8+
9+
// Regression test for https://github.com/nodejs/node/issues/56627
10+
// When a client stream's writable side is already ended (e.g. GET request)
11+
// and the server destroys the session, the client stream should emit an
12+
// error event with RST code NGHTTP2_CANCEL, since the 'aborted' event
13+
// cannot be emitted when the writable side is already ended.
14+
15+
{
16+
const server = h2.createServer();
17+
server.on('stream', common.mustCall((stream) => {
18+
stream.session.destroy();
19+
}));
20+
21+
server.listen(0, common.mustCall(() => {
22+
const client = h2.connect(`http://localhost:${server.address().port}`);
23+
24+
client.on('close', common.mustCall(() => {
25+
server.close();
26+
}));
27+
28+
const req = client.request();
29+
30+
req.on('error', common.mustCall((err) => {
31+
assert.strictEqual(err.code, 'ERR_HTTP2_STREAM_ERROR');
32+
assert.match(err.message, /NGHTTP2_CANCEL/);
33+
}));
34+
35+
req.on('aborted', common.mustNotCall());
36+
37+
req.on('close', common.mustCall(() => {
38+
assert.strictEqual(req.rstCode, h2.constants.NGHTTP2_CANCEL);
39+
}));
40+
41+
req.resume();
42+
}));
43+
}

test/parallel/test-http2-client-destroy.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,10 @@ const { listenerCount } = require('events');
118118
client.destroy();
119119
});
120120

121-
client.request();
121+
const req = client.request();
122+
req.on('error', common.mustCall((err) => {
123+
assert.strictEqual(err.code, 'ERR_HTTP2_STREAM_ERROR');
124+
}));
122125
}));
123126
}
124127

test/parallel/test-http2-client-jsstream-destroy.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ server.listen(0, common.mustCall(function() {
4545
createConnection: () => proxy
4646
});
4747
const req = client.request();
48+
req.on('error', common.mustCall());
4849

4950
server.on('request', () => {
5051
socket.destroy();

test/parallel/test-http2-client-socket-destroy.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,15 @@ server.on('stream', common.mustCall((stream) => {
2626
server.listen(0, common.mustCall(function() {
2727
const client = h2.connect(`http://localhost:${this.address().port}`);
2828
const req = client.request();
29+
req.on('error', common.mustCall());
2930

3031
req.on('response', common.mustCall(() => {
3132
// Send a premature socket close
3233
client[kSocket].destroy();
3334
}));
3435

3536
req.resume();
36-
req.on('end', common.mustCall());
37+
req.on('end', common.mustNotCall());
3738
req.on('close', common.mustCall(() => server.close()));
3839

3940
// On the client, the close event must call

test/parallel/test-http2-respond-with-file-connection-abort.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@ const {
1313

1414
const server = http2.createServer();
1515
server.on('stream', common.mustCall((stream) => {
16-
stream.on('error', common.mustCallAtLeast((err) => assert.strictEqual(err.code, 'ECONNRESET'), 0));
16+
stream.on('error', common.mustCallAtLeast((err) => {
17+
assert.ok(
18+
err.code === 'ECONNRESET' || err.code === 'ERR_HTTP2_STREAM_ERROR',
19+
`Unexpected error code: ${err.code}`
20+
);
21+
}, 0));
1722
stream.respondWithFile(process.execPath, {
1823
[HTTP2_HEADER_CONTENT_TYPE]: 'application/octet-stream'
1924
});
@@ -22,11 +27,11 @@ server.on('stream', common.mustCall((stream) => {
2227
server.listen(0, common.mustCall(() => {
2328
const client = http2.connect(`http://localhost:${server.address().port}`);
2429
const req = client.request();
30+
req.on('error', common.mustCall());
2531

2632
req.on('response', common.mustCall());
2733
req.once('data', common.mustCall(() => {
2834
net.Socket.prototype.destroy.call(client.socket);
2935
server.close();
3036
}));
31-
req.end();
3237
}));

test/parallel/test-http2-server-shutdown-options-errors.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ server.listen(
5858
common.mustCall(() => {
5959
const client = http2.connect(`http://localhost:${server.address().port}`);
6060
const req = client.request();
61+
req.on('error', common.mustCall());
6162
req.resume();
6263
req.on('close', common.mustCall(() => {
6364
client.close();

test/parallel/test-http2-server-stream-session-destroy.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ server.on('stream', common.mustCall((stream) => {
5252
server.listen(0, common.mustCall(() => {
5353
const client = h2.connect(`http://localhost:${server.address().port}`);
5454
const req = client.request();
55+
req.on('error', common.mustCall());
5556
req.resume();
56-
req.on('end', common.mustCall());
57+
req.on('end', common.mustNotCall());
5758
req.on('close', common.mustCall(() => server.close(common.mustCall())));
5859
}));

test/parallel/test-http2-zero-length-header.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,6 @@ server.on('stream', common.mustCall((stream, headers) => {
2222
}));
2323
server.listen(0, common.mustCall(() => {
2424
const client = http2.connect(`http://localhost:${server.address().port}/`);
25-
client.request({ ':path': '/', '': 'foo', 'bar': '' }).end();
25+
const req = client.request({ ':path': '/', '': 'foo', 'bar': '' });
26+
req.on('error', common.mustCall());
2627
}));

0 commit comments

Comments
 (0)