Skip to content

Commit 37887a3

Browse files
authored
http2: fix write deadlock exposed by larger window sizes
This removes a guard (no reads while write pending) that creates this deadlock, which was added as a security mechanism. This guard is redundant given then other existing mechanisms, and a test is added to demonstrate that. Signed-off-by: Tim Perry <pimterry@gmail.com> PR-URL: #65440 Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Filip Skokan <panva.ip@gmail.com>
1 parent 30542c3 commit 37887a3

6 files changed

Lines changed: 327 additions & 86 deletions

File tree

benchmark/http2/full-duplex.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
'use strict';
2+
3+
const common = require('../common.js');
4+
const fixtures = require('../../test/common/fixtures');
5+
6+
const bench = common.createBenchmark(main, {
7+
n: [100],
8+
streams: [2],
9+
size: [4 * 1024 * 1024],
10+
// Use the HTTP/2 protocol default.
11+
window: [65535],
12+
}, {
13+
test: { size: 128 * 1024, window: 65535 },
14+
});
15+
16+
function main({ n, streams, size, window }) {
17+
const http2 = require('http2');
18+
const payload = Buffer.alloc(size);
19+
const server = http2.createSecureServer({
20+
key: fixtures.readKey('agent1-key.pem'),
21+
cert: fixtures.readKey('agent1-cert.pem'),
22+
settings: { initialWindowSize: window },
23+
});
24+
25+
let completed = 0;
26+
let batches = 0;
27+
28+
function onTransferComplete() {
29+
if (++completed !== streams * 2)
30+
return;
31+
32+
if (++batches === n) {
33+
// Report combined upload and download throughput in MiB/s.
34+
bench.end(n * streams * size * 2 / (1024 * 1024));
35+
client.close();
36+
server.close();
37+
return;
38+
}
39+
40+
startBatch();
41+
}
42+
43+
server.on('stream', (stream) => {
44+
stream.resume();
45+
stream.on('end', onTransferComplete);
46+
stream.respond();
47+
stream.end(payload);
48+
});
49+
50+
let client;
51+
function startBatch() {
52+
completed = 0;
53+
for (let i = 0; i < streams; i++) {
54+
const request = client.request({ ':method': 'POST' });
55+
request.resume();
56+
request.on('end', onTransferComplete);
57+
request.end(payload);
58+
}
59+
}
60+
61+
server.listen(0, () => {
62+
client = http2.connect(`https://localhost:${server.address().port}`, {
63+
rejectUnauthorized: false,
64+
settings: { initialWindowSize: window },
65+
});
66+
client.on('connect', () => {
67+
bench.start();
68+
startBatch();
69+
});
70+
});
71+
}

src/node_http2.cc

Lines changed: 13 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -981,45 +981,24 @@ ssize_t Http2Session::OnMaxFrameSizePadding(size_t frameLen,
981981
// quite expensive. This is a potential performance optimization target later.
982982
void Http2Session::ConsumeHTTP2Data() {
983983
CHECK_NOT_NULL(stream_buf_.base);
984-
CHECK_LE(stream_buf_offset_, stream_buf_.len);
985-
size_t read_len = stream_buf_.len - stream_buf_offset_;
986984

987985
// multiple side effects.
988-
Debug(this, "receiving %d bytes [wants data? %d]",
989-
read_len,
986+
Debug(this,
987+
"receiving %d bytes [wants data? %d]",
988+
stream_buf_.len,
990989
nghttp2_session_want_read(session_.get()));
991-
set_receive_paused(false);
992990
custom_recv_error_code_ = nullptr;
993991
set_receiving();
994992
ssize_t ret =
995-
nghttp2_session_mem_recv(session_.get(),
996-
reinterpret_cast<uint8_t*>(stream_buf_.base) +
997-
stream_buf_offset_,
998-
read_len);
993+
nghttp2_session_mem_recv(session_.get(),
994+
reinterpret_cast<uint8_t*>(stream_buf_.base),
995+
stream_buf_.len);
999996
set_receiving(false);
1000997
CHECK_NE(ret, NGHTTP2_ERR_NOMEM);
1001998
CHECK_IMPLIES(custom_recv_error_code_ != nullptr, ret < 0);
1002999

1003-
if (is_receive_paused()) {
1004-
CHECK(is_reading_stopped());
1005-
1006-
CHECK_GT(ret, 0);
1007-
CHECK_LE(static_cast<size_t>(ret), read_len);
1008-
1009-
// Mark the remainder of the data as available for later consumption.
1010-
// Even if all bytes were received, a paused stream may delay the
1011-
// nghttp2_on_frame_recv_callback which may have an END_STREAM flag.
1012-
stream_buf_offset_ += ret;
1013-
// Still complete a Close() deferred during mem_recv; do not fall through
1014-
// to SendPendingData() here (paused receives historically skip that flush
1015-
// because a write may already be in progress).
1016-
MaybeFinishPendingClose();
1017-
goto done;
1018-
}
1019-
10201000
// We are done processing the current input chunk.
10211001
DecrementCurrentSessionMemory(stream_buf_.len);
1022-
stream_buf_offset_ = 0;
10231002
stream_buf_ab_.Reset();
10241003
stream_buf_allocation_.reset();
10251004
stream_buf_ = uv_buf_init(nullptr, 0);
@@ -1028,14 +1007,6 @@ void Http2Session::ConsumeHTTP2Data() {
10281007
// not written after pending RST_STREAM frames.
10291008
MaybeFinishPendingClose();
10301009

1031-
done:
1032-
// Finish a Close() deferred above before flushing, so GOAWAY is not written
1033-
// after pending RST_STREAM frames.
1034-
if (is_close_pending() && !is_destroyed()) {
1035-
set_close_pending(false);
1036-
FinishClose(pending_close_code_, pending_close_socket_closed_);
1037-
}
1038-
10391010
// Send any data that was queued up while processing the received data.
10401011
if (ret >= 0 && !is_destroyed()) {
10411012
SendPendingData();
@@ -1485,15 +1456,6 @@ int Http2Session::OnDataChunkReceived(nghttp2_session* handle,
14851456
}
14861457
} while (len != 0);
14871458

1488-
// If we are currently waiting for a write operation to finish, we should
1489-
// tell nghttp2 that we want to wait before we process more input data.
1490-
if (session->is_write_in_progress()) {
1491-
CHECK(session->is_reading_stopped());
1492-
session->set_receive_paused();
1493-
Debug(session, "receive paused");
1494-
return NGHTTP2_ERR_PAUSE;
1495-
}
1496-
14971459
return 0;
14981460
}
14991461

@@ -1576,7 +1538,6 @@ void Http2StreamListener::OnStreamRead(ssize_t nread, const uv_buf_t& buf) {
15761538
size_t offset = buf.base - session->stream_buf_.base;
15771539

15781540
// Verify that the data offset is inside the current read buffer.
1579-
CHECK_GE(offset, session->stream_buf_offset_);
15801541
CHECK_LE(offset, session->stream_buf_.len);
15811542
CHECK_LE(offset + buf.len, session->stream_buf_.len);
15821543

@@ -1891,11 +1852,6 @@ void Http2Session::OnStreamAfterWrite(WriteWrap* w, int status) {
18911852
return;
18921853
}
18931854

1894-
// If there is more incoming data queued up, consume it.
1895-
if (stream_buf_offset_ > 0) {
1896-
ConsumeHTTP2Data();
1897-
}
1898-
18991855
if (!is_write_scheduled() && !is_destroyed()) {
19001856
// Schedule a new write if nghttp2 wants to send data.
19011857
MaybeScheduleWrite();
@@ -1942,7 +1898,7 @@ void Http2Session::MaybeStopReading() {
19421898
if (is_reading_stopped() || is_closing()) return;
19431899
int want_read = nghttp2_session_want_read(session_.get());
19441900
Debug(this, "wants read? %d", want_read);
1945-
if (want_read == 0 || is_write_in_progress()) {
1901+
if (want_read == 0) {
19461902
set_reading_stopped();
19471903
stream_->ReadStop();
19481904
}
@@ -2207,7 +2163,7 @@ void Http2Session::OnStreamRead(ssize_t nread, const uv_buf_t& buf_) {
22072163
Context::Scope context_scope(env()->context());
22082164
Http2Scope h2scope(this);
22092165
CHECK_NOT_NULL(stream_);
2210-
Debug(this, "receiving %d bytes, offset %d", nread, stream_buf_offset_);
2166+
Debug(this, "receiving %d bytes", nread);
22112167
std::unique_ptr<BackingStore> bs = env()->release_managed_buffer(buf_);
22122168

22132169
// Only pass data on if nread > 0
@@ -2222,40 +2178,18 @@ void Http2Session::OnStreamRead(ssize_t nread, const uv_buf_t& buf_) {
22222178

22232179
statistics_.data_received += nread;
22242180

2225-
if (stream_buf_offset_ == 0 && static_cast<size_t>(nread) != bs->ByteLength())
2226-
[[likely]] {
2181+
// ConsumeHTTP2Data() always consumes the whole chunk, so there is never a
2182+
// partially processed buffer left over from a previous read.
2183+
DCHECK_NULL(stream_buf_.base);
2184+
2185+
if (static_cast<size_t>(nread) != bs->ByteLength()) [[likely]] {
22272186
// Shrink to the actual amount of used data.
22282187
std::unique_ptr<BackingStore> old_bs = std::move(bs);
22292188
bs = ArrayBuffer::NewBackingStore(
22302189
env()->isolate(),
22312190
nread,
22322191
BackingStoreInitializationMode::kUninitialized);
22332192
memcpy(bs->Data(), old_bs->Data(), nread);
2234-
} else {
2235-
// This is a very unlikely case, and should only happen if the ReadStart()
2236-
// call in OnStreamAfterWrite() immediately provides data. If that does
2237-
// happen, we concatenate the data we received with the already-stored
2238-
// pending input data, slicing off the already processed part.
2239-
size_t pending_len = stream_buf_.len - stream_buf_offset_;
2240-
std::unique_ptr<BackingStore> new_bs = ArrayBuffer::NewBackingStore(
2241-
env()->isolate(),
2242-
pending_len + nread,
2243-
BackingStoreInitializationMode::kUninitialized);
2244-
memcpy(static_cast<char*>(new_bs->Data()),
2245-
stream_buf_.base + stream_buf_offset_,
2246-
pending_len);
2247-
memcpy(static_cast<char*>(new_bs->Data()) + pending_len,
2248-
bs->Data(),
2249-
nread);
2250-
2251-
bs = std::move(new_bs);
2252-
nread = bs->ByteLength();
2253-
stream_buf_offset_ = 0;
2254-
stream_buf_ab_.Reset();
2255-
2256-
// We have now fully processed the stream_buf_ input chunk (by moving the
2257-
// remaining part into buf, which will be accounted for below).
2258-
DecrementCurrentSessionMemory(stream_buf_.len);
22592193
}
22602194

22612195
IncrementCurrentSessionMemory(nread);

src/node_http2.h

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,8 @@ constexpr int kSessionStateClosing = 0x8;
8585
constexpr int kSessionStateSending = 0x10;
8686
constexpr int kSessionStateWriteInProgress = 0x20;
8787
constexpr int kSessionStateReadingStopped = 0x40;
88-
constexpr int kSessionStateReceivePaused = 0x80;
89-
constexpr int kSessionStateReceiving = 0x100;
90-
constexpr int kSessionStateClosePending = 0x200;
88+
constexpr int kSessionStateReceiving = 0x80;
89+
constexpr int kSessionStateClosePending = 0x100;
9190

9291
// The Padding Strategy determines the method by which extra padding is
9392
// selected for HEADERS and DATA frames. These are configurable via the
@@ -698,7 +697,6 @@ class Http2Session : public AsyncWrap,
698697
IS_FLAG(sending, kSessionStateSending)
699698
IS_FLAG(write_in_progress, kSessionStateWriteInProgress)
700699
IS_FLAG(reading_stopped, kSessionStateReadingStopped)
701-
IS_FLAG(receive_paused, kSessionStateReceivePaused)
702700
IS_FLAG(receiving, kSessionStateReceiving)
703701
IS_FLAG(close_pending, kSessionStateClosePending)
704702

@@ -979,7 +977,6 @@ class Http2Session : public AsyncWrap,
979977
// will be set. stream_buf_ab_ is lazily created from stream_buf_allocation_.
980978
v8::Global<v8::ArrayBuffer> stream_buf_ab_;
981979
std::unique_ptr<v8::BackingStore> stream_buf_allocation_;
982-
size_t stream_buf_offset_ = 0;
983980
// Custom error code for errors that originated inside one of the callbacks
984981
// called by nghttp2_session_mem_recv.
985982
const char* custom_recv_error_code_ = nullptr;
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
'use strict';
2+
3+
// Regression test against deadlocks between two HTTP/2 peers that are both
4+
// writing at the same time.
5+
//
6+
// To bound how much it buffered while output was backed up, an Http2Session
7+
// used to stop reading from its socket whenever a write was in flight, and
8+
// resume only once that write completed. When the peer was itself blocked
9+
// writing, that write never completed, so the session never read again and
10+
// the connection hung forever with no error and no timeout.
11+
//
12+
// Rather than relying on kernel socket buffers filling up - which depends on
13+
// the platform and configured window sizes - this models one half of that
14+
// cycle directly. The client's socket forwards a write but does not report it
15+
// as complete, substituting for a write blocked because the peer is not
16+
// reading. Only after that write is stalled does the server send its response
17+
// body. A session that stops reading while writing never sees it.
18+
19+
const common = require('../common');
20+
if (!common.hasCrypto)
21+
common.skip('missing crypto');
22+
const assert = require('assert');
23+
const http2 = require('http2');
24+
const net = require('net');
25+
const { Duplex } = require('stream');
26+
27+
const BODY = 'the response body';
28+
29+
const heldCallbacks = [];
30+
let serverStream;
31+
32+
let stallWrites = false;
33+
34+
// Client-side socket that forwards writes to a real connection, but can leave
35+
// their completion callbacks pending to model a transport-blocked write.
36+
class StalledClientSocket extends Duplex {
37+
constructor(port) {
38+
super();
39+
this.inner = net.connect(port, common.localhostIPv4);
40+
this.inner.on('data', (chunk) => this.push(chunk));
41+
}
42+
_read() {
43+
// Incoming data is pushed as it arrives.
44+
}
45+
_write(chunk, encoding, callback) {
46+
this.inner.write(chunk, encoding);
47+
if (stallWrites) {
48+
heldCallbacks.push(callback);
49+
// Avoid writing from the server re-entrantly inside _write(). The
50+
// ordering is still explicit: this callback is already held.
51+
setImmediate(() => serverStream.end(BODY));
52+
return;
53+
}
54+
callback();
55+
}
56+
_final(callback) {
57+
callback();
58+
}
59+
_destroy(err, callback) {
60+
this.inner.destroy();
61+
callback(err);
62+
}
63+
}
64+
65+
const server = http2.createServer();
66+
67+
server.on('stream', common.mustCall((stream) => {
68+
// Send headers first. Their response event starts the stalled client write.
69+
stream.respond();
70+
serverStream = stream;
71+
}));
72+
73+
server.listen(0, common.mustCall(() => {
74+
const port = server.address().port;
75+
76+
const client = http2.connect(`http://${common.localhostIPv4}:${port}`, {
77+
createConnection: () => new StalledClientSocket(port),
78+
});
79+
80+
const req = client.request({ ':method': 'POST' });
81+
82+
let received = '';
83+
84+
req.on('response', common.mustCall(() => {
85+
// _write() will schedule the response body only after it has retained the
86+
// callback, guaranteeing that the native write is still in progress.
87+
stallWrites = true;
88+
req.write(Buffer.alloc(256));
89+
}));
90+
91+
req.on('data', (chunk) => {
92+
received += chunk;
93+
});
94+
95+
req.on('end', common.mustCall(() => {
96+
assert.strictEqual(received, BODY);
97+
assert.ok(heldCallbacks.length > 0,
98+
'test did not actually stall a socket write');
99+
100+
// Let the stalled writes complete so that everything can shut down.
101+
stallWrites = false;
102+
for (const callback of heldCallbacks) callback();
103+
104+
client.destroy();
105+
server.close();
106+
}));
107+
}));

0 commit comments

Comments
 (0)