Skip to content

Commit 7288a16

Browse files
committed
http2: send deferred RST_STREAM on stream destroy
close() with NO_ERROR waits for 'finish' before sending RST_STREAM. If the body is stuck behind flow control and the stream is destroyed first, RST_STREAM is never sent and nghttp2 keeps the stream open. On the next WINDOW_UPDATE, OnRead finds no stream and returns 0 without EOF, so nghttp2 sends empty DATA frames until OOM. Both client and server streams are affected. Send the deferred RST_STREAM in _destroy(). As a backstop, return NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE from OnRead when the stream is gone, which nghttp2 documents as the way to reset a stream on error. Fixes: #67363 Signed-off-by: inoway46 <inoueyuya416@gmail.com>
1 parent 2929073 commit 7288a16

4 files changed

Lines changed: 117 additions & 2 deletions

File tree

‎lib/internal/http2/core.js‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,7 @@ const STREAM_FLAGS_HEADERS_SENT = 0x4;
348348
const STREAM_FLAGS_HEAD_REQUEST = 0x8;
349349
const STREAM_FLAGS_ABORTED = 0x10;
350350
const STREAM_FLAGS_HAS_TRAILERS = 0x20;
351+
const STREAM_FLAGS_RST_DEFERRED = 0x40;
351352

352353
const SESSION_FLAGS_PENDING = 0x0;
353354
const SESSION_FLAGS_READY = 0x1;
@@ -644,6 +645,8 @@ function onStreamClose(code, peerReset) {
644645
stream[kState].fd = -1;
645646

646647
if (locallyInitiated) {
648+
// nghttp2 has already closed the stream, a deferred RST_STREAM is moot.
649+
stream[kState].flags &= ~STREAM_FLAGS_RST_DEFERRED;
647650
stream.destroy();
648651
return true;
649652
}
@@ -2166,6 +2169,9 @@ function closeStream(stream, code, rstStreamStatus = kSubmitRstStream) {
21662169
rstStreamStatus === kForceRstStream) {
21672170
finishCloseStream.call(stream, code);
21682171
} else {
2172+
// Remember that the RST_STREAM is still owed, so that destroying the
2173+
// stream before 'finish' can submit it. See Http2Stream#_destroy().
2174+
state.flags |= STREAM_FLAGS_RST_DEFERRED;
21692175
stream.once('finish', finishCloseStream.bind(stream, code));
21702176
}
21712177
}
@@ -2188,6 +2194,7 @@ function closeStream(stream, code, rstStreamStatus = kSubmitRstStream) {
21882194
}
21892195

21902196
function finishCloseStream(code) {
2197+
this[kState].flags &= ~STREAM_FLAGS_RST_DEFERRED;
21912198
const rstStreamFn = submitRstStream.bind(this, code);
21922199
// Defer until the stream has an ID assigned.
21932200
if (this.pending) {
@@ -2681,8 +2688,16 @@ class Http2Stream extends Duplex {
26812688
}
26822689
const hasHandle = handle !== undefined;
26832690

2684-
if (!this.closed)
2691+
if (!this.closed) {
26852692
closeStream(this, code, hasHandle ? kForceRstStream : kNoRstStream);
2693+
} else if (state.flags & STREAM_FLAGS_RST_DEFERRED) {
2694+
// close() deferred the RST_STREAM until 'finish', which will never be
2695+
// emitted now. Submit it here, otherwise nghttp2 keeps the stream open
2696+
// with a data provider that no longer has a stream to read from.
2697+
state.flags &= ~STREAM_FLAGS_RST_DEFERRED;
2698+
if (hasHandle)
2699+
handle.rstStream(code);
2700+
}
26862701
this.push(null);
26872702

26882703

‎src/node_http2.cc‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2840,7 +2840,10 @@ ssize_t Http2Stream::Provider::Stream::OnRead(nghttp2_session* handle,
28402840
Http2Session* session = static_cast<Http2Session*>(user_data);
28412841
Debug(session, "reading outbound data for stream %d", id);
28422842
BaseObjectPtr<Http2Stream> stream = session->FindStream(id);
2843-
if (!stream) return 0;
2843+
// The stream was destroyed without nghttp2 being told. Returning 0 here
2844+
// would make nghttp2 send empty DATA frames forever, so have it reset
2845+
// the stream instead.
2846+
if (!stream) return NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE;
28442847
if (stream->statistics_.first_byte_sent == 0)
28452848
stream->statistics_.first_byte_sent = uv_hrtime();
28462849
CHECK_EQ(id, stream->id());
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
'use strict';
2+
3+
// Destroying a stream that was closed with NO_ERROR while its request body
4+
// was still queued behind flow control must still send RST_STREAM.
5+
// Refs: https://github.com/nodejs/node/issues/67363
6+
7+
const common = require('../common');
8+
if (!common.hasCrypto)
9+
common.skip('missing crypto');
10+
const assert = require('assert');
11+
const http2 = require('http2');
12+
const { NGHTTP2_NO_ERROR } = http2.constants;
13+
14+
// A small stream window makes sure that most of the body stays queued.
15+
const server = http2.createServer({ settings: { initialWindowSize: 16384 } });
16+
17+
let req;
18+
19+
server.on('stream', common.mustCall((stream) => {
20+
// The stream is not being read yet, so the request body is stuck behind
21+
// flow control when the client closes and destroys the request.
22+
req.close();
23+
req.destroy();
24+
25+
stream.on('error', common.expectsError({
26+
code: 'ERR_HTTP2_STREAM_ABORTED',
27+
}));
28+
stream.on('close', common.mustCall(() => {
29+
assert.strictEqual(stream.rstCode, NGHTTP2_NO_ERROR);
30+
server.close();
31+
}));
32+
// Open the window again. Without the RST_STREAM this is what makes the
33+
// client loop.
34+
stream.resume();
35+
}));
36+
37+
server.listen(0, common.mustCall(() => {
38+
const client = http2.connect(`http://localhost:${server.address().port}`);
39+
req = client.request({ ':method': 'POST' });
40+
req.on('error', common.mustNotCall());
41+
req.on('close', common.mustCall(() => {
42+
assert.strictEqual(req.rstCode, NGHTTP2_NO_ERROR);
43+
client.close();
44+
}));
45+
req.end(Buffer.alloc(1024 * 1024));
46+
}));
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
'use strict';
2+
3+
// Destroying a stream that was closed with NO_ERROR while its response body
4+
// was still queued behind flow control must still send RST_STREAM.
5+
// Refs: https://github.com/nodejs/node/issues/67363
6+
7+
const common = require('../common');
8+
if (!common.hasCrypto)
9+
common.skip('missing crypto');
10+
const assert = require('assert');
11+
const http2 = require('http2');
12+
const { NGHTTP2_NO_ERROR } = http2.constants;
13+
14+
const server = http2.createServer();
15+
16+
let serverStream;
17+
18+
server.on('stream', common.mustCall((stream) => {
19+
serverStream = stream;
20+
stream.on('error', common.mustNotCall());
21+
stream.on('close', common.mustCall(() => {
22+
assert.strictEqual(stream.rstCode, NGHTTP2_NO_ERROR);
23+
}));
24+
stream.respond();
25+
stream.end(Buffer.alloc(1024 * 1024));
26+
}));
27+
28+
server.listen(0, common.mustCall(() => {
29+
// A small stream window makes sure that most of the body stays queued.
30+
const client = http2.connect(`http://localhost:${server.address().port}`,
31+
{ settings: { initialWindowSize: 16384 } });
32+
const req = client.request();
33+
req.on('response', common.mustCall(() => {
34+
// The response is not being read yet, so the response body is stuck
35+
// behind flow control when the server closes and destroys the stream.
36+
serverStream.close();
37+
serverStream.destroy();
38+
// Open the window again. Without the RST_STREAM this is what makes the
39+
// server loop.
40+
req.resume();
41+
}));
42+
req.on('error', common.expectsError({
43+
code: 'ERR_HTTP2_STREAM_ABORTED',
44+
}));
45+
req.on('close', common.mustCall(() => {
46+
assert.strictEqual(req.rstCode, NGHTTP2_NO_ERROR);
47+
client.close();
48+
server.close();
49+
}));
50+
req.end();
51+
}));

0 commit comments

Comments
 (0)