Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

http2: implement maxSessionMemory #17967

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions doc/api/http2.md
Original file line number Diff line number Diff line change
Expand Up @@ -1633,6 +1633,15 @@ changes:
* `options` {Object}
* `maxDeflateDynamicTableSize` {number} Sets the maximum dynamic table size
for deflating header fields. **Default:** `4Kib`
* `maxSessionMemory`{number} Sets the maximum memory that the `Http2Session`
is permitted to use. The value is expressed in terms of number of megabytes,
e.g. `1` equal 1 megabyte. The minimum value allowed is `1`. **Default:**
`10`. This is a credit based limit, existing `Http2Stream`s may cause this
limit to be exceeded, but new `Http2Stream` instances will be rejected
while this limit is exceeded. The current number of `Http2Stream` sessions,
the current memory use of the header compression tables, current data
queued to be sent, and unacknowledged PING and SETTINGS frames are all
counted towards the current limit.
* `maxHeaderListPairs` {number} Sets the maximum number of header entries.
**Default:** `128`. The minimum value is `4`.
* `maxOutstandingPings` {number} Sets the maximum number of outstanding,
Expand Down Expand Up @@ -1711,6 +1720,15 @@ changes:
`false`. See the [`'unknownProtocol'`][] event. See [ALPN negotiation][].
* `maxDeflateDynamicTableSize` {number} Sets the maximum dynamic table size
for deflating header fields. **Default:** `4Kib`
* `maxSessionMemory`{number} Sets the maximum memory that the `Http2Session`
is permitted to use. The value is expressed in terms of number of megabytes,
e.g. `1` equal 1 megabyte. The minimum value allowed is `1`. **Default:**
`10`. This is a credit based limit, existing `Http2Stream`s may cause this
limit to be exceeded, but new `Http2Stream` instances will be rejected
while this limit is exceeded. The current number of `Http2Stream` sessions,
the current memory use of the header compression tables, current data
queued to be sent, and unacknowledged PING and SETTINGS frames are all
counted towards the current limit.
* `maxHeaderListPairs` {number} Sets the maximum number of header entries.
**Default:** `128`. The minimum value is `4`.
* `maxOutstandingPings` {number} Sets the maximum number of outstanding,
Expand Down Expand Up @@ -1794,6 +1812,15 @@ changes:
* `options` {Object}
* `maxDeflateDynamicTableSize` {number} Sets the maximum dynamic table size
for deflating header fields. **Default:** `4Kib`
* `maxSessionMemory`{number} Sets the maximum memory that the `Http2Session`
is permitted to use. The value is expressed in terms of number of megabytes,
e.g. `1` equal 1 megabyte. The minimum value allowed is `1`. **Default:**
`10`. This is a credit based limit, existing `Http2Stream`s may cause this
limit to be exceeded, but new `Http2Stream` instances will be rejected
while this limit is exceeded. The current number of `Http2Stream` sessions,
the current memory use of the header compression tables, current data
queued to be sent, and unacknowledged PING and SETTINGS frames are all
counted towards the current limit.
* `maxHeaderListPairs` {number} Sets the maximum number of header entries.
**Default:** `128`. The minimum value is `1`.
* `maxOutstandingPings` {number} Sets the maximum number of outstanding,
Expand Down
8 changes: 7 additions & 1 deletion lib/internal/http2/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,8 @@ const IDX_OPTIONS_PADDING_STRATEGY = 4;
const IDX_OPTIONS_MAX_HEADER_LIST_PAIRS = 5;
const IDX_OPTIONS_MAX_OUTSTANDING_PINGS = 6;
const IDX_OPTIONS_MAX_OUTSTANDING_SETTINGS = 7;
const IDX_OPTIONS_FLAGS = 8;
const IDX_OPTIONS_MAX_SESSION_MEMORY = 8;
const IDX_OPTIONS_FLAGS = 9;

function updateOptionsBuffer(options) {
var flags = 0;
Expand Down Expand Up @@ -219,6 +220,11 @@ function updateOptionsBuffer(options) {
optionsBuffer[IDX_OPTIONS_MAX_OUTSTANDING_SETTINGS] =
Math.max(1, options.maxOutstandingSettings);
}
if (typeof options.maxSessionMemory === 'number') {
flags |= (1 << IDX_OPTIONS_MAX_SESSION_MEMORY);
optionsBuffer[IDX_OPTIONS_MAX_SESSION_MEMORY] =
Math.max(1, options.maxSessionMemory);
}
optionsBuffer[IDX_OPTIONS_FLAGS] = flags;
}

Expand Down
55 changes: 44 additions & 11 deletions src/node_http2.cc
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,18 @@ Http2Options::Http2Options(Environment* env) {
if (flags & (1 << IDX_OPTIONS_MAX_OUTSTANDING_SETTINGS)) {
SetMaxOutstandingSettings(buffer[IDX_OPTIONS_MAX_OUTSTANDING_SETTINGS]);
}

// The HTTP2 specification places no limits on the amount of memory
// that a session can consume. In order to prevent abuse, we place a
// cap on the amount of memory a session can consume at any given time.
// this is a credit based system. Existing streams may cause the limit
// to be temporarily exceeded but once over the limit, new streams cannot
// created.
// Important: The maxSessionMemory option in javascript is expressed in
// terms of MB increments (i.e. the value 1 == 1 MB)
if (flags & (1 << IDX_OPTIONS_MAX_SESSION_MEMORY)) {
SetMaxSessionMemory(buffer[IDX_OPTIONS_MAX_SESSION_MEMORY] * 1e6);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In that case you really should be specifying that you 1 MB is one million bytes, not 2^10 bytes ;)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@addaleax ... sorry, I'm being a bit thick... can you clarify the specific change? comment or code?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jasnell Sorry, missed the notification – I meant, for some people 1 MB means 1.000.000 bytes, and for some it means 1.048.576 bytes, so it would be good to have disambiguated this in the documentation :)

}
}

void Http2Session::Http2Settings::Init() {
Expand Down Expand Up @@ -482,11 +494,13 @@ Http2Session::Http2Session(Environment* env,
// Capture the configuration options for this session
Http2Options opts(env);

int32_t maxHeaderPairs = opts.GetMaxHeaderPairs();
max_session_memory_ = opts.GetMaxSessionMemory();

uint32_t maxHeaderPairs = opts.GetMaxHeaderPairs();
max_header_pairs_ =
type == NGHTTP2_SESSION_SERVER
? std::max(maxHeaderPairs, 4) // minimum # of request headers
: std::max(maxHeaderPairs, 1); // minimum # of response headers
? std::max(maxHeaderPairs, 4U) // minimum # of request headers
: std::max(maxHeaderPairs, 1U); // minimum # of response headers

max_outstanding_pings_ = opts.GetMaxOutstandingPings();
max_outstanding_settings_ = opts.GetMaxOutstandingSettings();
Expand Down Expand Up @@ -672,18 +686,21 @@ inline bool Http2Session::CanAddStream() {
size_t maxSize =
std::min(streams_.max_size(), static_cast<size_t>(maxConcurrentStreams));
// We can add a new stream so long as we are less than the current
// maximum on concurrent streams
return streams_.size() < maxSize;
// maximum on concurrent streams and there's enough available memory
return streams_.size() < maxSize &&
IsAvailableSessionMemory(sizeof(Http2Stream));
}

inline void Http2Session::AddStream(Http2Stream* stream) {
CHECK_GE(++statistics_.stream_count, 0);
streams_[stream->id()] = stream;
IncrementCurrentSessionMemory(stream->self_size());
}


inline void Http2Session::RemoveStream(int32_t id) {
streams_.erase(id);
inline void Http2Session::RemoveStream(Http2Stream* stream) {
streams_.erase(stream->id());
DecrementCurrentSessionMemory(stream->self_size());
}

// Used as one of the Padding Strategy functions. Will attempt to ensure
Expand Down Expand Up @@ -1677,7 +1694,7 @@ Http2Stream::Http2Stream(

Http2Stream::~Http2Stream() {
if (session_ != nullptr) {
session_->RemoveStream(id_);
session_->RemoveStream(this);
session_ = nullptr;
}

Expand Down Expand Up @@ -2007,7 +2024,7 @@ inline int Http2Stream::DoWrite(WriteWrap* req_wrap,
i == nbufs - 1 ? req_wrap : nullptr,
bufs[i]
});
available_outbound_length_ += bufs[i].len;
IncrementAvailableOutboundLength(bufs[i].len);
}
CHECK_NE(nghttp2_session_resume_data(**session_, id_), NGHTTP2_ERR_NOMEM);
return 0;
Expand All @@ -2029,7 +2046,10 @@ inline bool Http2Stream::AddHeader(nghttp2_rcbuf* name,
if (this->statistics_.first_header == 0)
this->statistics_.first_header = uv_hrtime();
size_t length = GetBufferLength(name) + GetBufferLength(value) + 32;
if (current_headers_.size() == max_header_pairs_ ||
// A header can only be added if we have not exceeded the maximum number
// of headers and the session has memory available for it.
if (!session_->IsAvailableSessionMemory(length) ||
current_headers_.size() == max_header_pairs_ ||
current_headers_length_ + length > max_header_length_) {
return false;
}
Expand Down Expand Up @@ -2173,7 +2193,7 @@ ssize_t Http2Stream::Provider::Stream::OnRead(nghttp2_session* handle,
// Just return the length, let Http2Session::OnSendData take care of
// actually taking the buffers out of the queue.
*flags |= NGHTTP2_DATA_FLAG_NO_COPY;
stream->available_outbound_length_ -= amount;
stream->DecrementAvailableOutboundLength(amount);
}
}

Expand All @@ -2196,6 +2216,15 @@ ssize_t Http2Stream::Provider::Stream::OnRead(nghttp2_session* handle,
return amount;
}

inline void Http2Stream::IncrementAvailableOutboundLength(size_t amount) {
available_outbound_length_ += amount;
session_->IncrementCurrentSessionMemory(amount);
}

inline void Http2Stream::DecrementAvailableOutboundLength(size_t amount) {
available_outbound_length_ -= amount;
session_->DecrementCurrentSessionMemory(amount);
}


// Implementation of the JavaScript API
Expand Down Expand Up @@ -2689,6 +2718,7 @@ Http2Session::Http2Ping* Http2Session::PopPing() {
if (!outstanding_pings_.empty()) {
ping = outstanding_pings_.front();
outstanding_pings_.pop();
DecrementCurrentSessionMemory(ping->self_size());
}
return ping;
}
Expand All @@ -2697,6 +2727,7 @@ bool Http2Session::AddPing(Http2Session::Http2Ping* ping) {
if (outstanding_pings_.size() == max_outstanding_pings_)
return false;
outstanding_pings_.push(ping);
IncrementCurrentSessionMemory(ping->self_size());
return true;
}

Expand All @@ -2705,6 +2736,7 @@ Http2Session::Http2Settings* Http2Session::PopSettings() {
if (!outstanding_settings_.empty()) {
settings = outstanding_settings_.front();
outstanding_settings_.pop();
DecrementCurrentSessionMemory(settings->self_size());
}
return settings;
}
Expand All @@ -2713,6 +2745,7 @@ bool Http2Session::AddSettings(Http2Session::Http2Settings* settings) {
if (outstanding_settings_.size() == max_outstanding_settings_)
return false;
outstanding_settings_.push(settings);
IncrementCurrentSessionMemory(settings->self_size());
return true;
}

Expand Down
45 changes: 44 additions & 1 deletion src/node_http2.h
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ void inline debug_vfprintf(const char* format, ...) {
// Also strictly limit the number of outstanding SETTINGS frames a user sends
#define DEFAULT_MAX_SETTINGS 10

// Default maximum total memory cap for Http2Session.
#define DEFAULT_MAX_SESSION_MEMORY 1e7;

// These are the standard HTTP/2 defaults as specified by the RFC
#define DEFAULT_SETTINGS_HEADER_TABLE_SIZE 4096
#define DEFAULT_SETTINGS_ENABLE_PUSH 1
Expand Down Expand Up @@ -501,8 +504,17 @@ class Http2Options {
return max_outstanding_settings_;
}

void SetMaxSessionMemory(uint64_t max) {
max_session_memory_ = max;
}

uint64_t GetMaxSessionMemory() {
return max_session_memory_;
}

private:
nghttp2_option* options_;
uint64_t max_session_memory_ = DEFAULT_MAX_SESSION_MEMORY;
uint32_t max_header_pairs_ = DEFAULT_MAX_HEADER_LIST_PAIRS;
padding_strategy_type padding_strategy_ = PADDING_STRATEGY_NONE;
size_t max_outstanding_pings_ = DEFAULT_MAX_PINGS;
Expand Down Expand Up @@ -629,6 +641,9 @@ class Http2Stream : public AsyncWrap,
// Returns the stream identifier for this stream
inline int32_t id() const { return id_; }

inline void IncrementAvailableOutboundLength(size_t amount);
inline void DecrementAvailableOutboundLength(size_t amount);

inline bool AddHeader(nghttp2_rcbuf* name,
nghttp2_rcbuf* value,
uint8_t flags);
Expand Down Expand Up @@ -848,7 +863,7 @@ class Http2Session : public AsyncWrap {
inline void AddStream(Http2Stream* stream);

// Removes a stream instance from this session
inline void RemoveStream(int32_t id);
inline void RemoveStream(Http2Stream* stream);

// Write data to the session
inline ssize_t Write(const uv_buf_t* bufs, size_t nbufs);
Expand Down Expand Up @@ -906,6 +921,30 @@ class Http2Session : public AsyncWrap {
Http2Settings* PopSettings();
bool AddSettings(Http2Settings* settings);

void IncrementCurrentSessionMemory(uint64_t amount) {
current_session_memory_ += amount;
}

void DecrementCurrentSessionMemory(uint64_t amount) {
current_session_memory_ -= amount;
}

// Returns the current session memory including the current size of both
// the inflate and deflate hpack headers, the current outbound storage
// queue, and pending writes.
uint64_t GetCurrentSessionMemory() {
uint64_t total = current_session_memory_ + sizeof(Http2Session);
total += nghttp2_session_get_hd_deflate_dynamic_table_size(session_);
total += nghttp2_session_get_hd_inflate_dynamic_table_size(session_);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m wondering what the cost of these calls is, since they can’t be inlined & this function is called quite a few times as far as I can tell? Did you try to measure that? (Not a blocker, I think this feature is worth it either way)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, haven't benchmarked it yet.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One way we can limit the impact here is to capture the inbound dynamic table size at the completion of a headers frame, and the outbound dynamic size after sending a headers frame, then just add the static values.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(note that doing so relaxes one of the protections included here that keeps headers within a single HEADERS frame from blowing the memory limit)

total += outgoing_storage_.size();
return total;
}

// Return true if current_session_memory + amount is less than the max
bool IsAvailableSessionMemory(uint64_t amount) {
return GetCurrentSessionMemory() + amount <= max_session_memory_;
}

struct Statistics {
uint64_t start_time;
uint64_t end_time;
Expand Down Expand Up @@ -1035,6 +1074,10 @@ class Http2Session : public AsyncWrap {
// The maximum number of header pairs permitted for streams on this session
uint32_t max_header_pairs_ = DEFAULT_MAX_HEADER_LIST_PAIRS;

// The maximum amount of memory allocated for this session
uint64_t max_session_memory_ = DEFAULT_MAX_SESSION_MEMORY;
uint64_t current_session_memory_ = 0;

// The collection of active Http2Streams associated with this session
std::unordered_map<int32_t, Http2Stream*> streams_;

Expand Down
1 change: 1 addition & 0 deletions src/node_http2_state.h
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ namespace http2 {
IDX_OPTIONS_MAX_HEADER_LIST_PAIRS,
IDX_OPTIONS_MAX_OUTSTANDING_PINGS,
IDX_OPTIONS_MAX_OUTSTANDING_SETTINGS,
IDX_OPTIONS_MAX_SESSION_MEMORY,
IDX_OPTIONS_FLAGS
};

Expand Down
7 changes: 5 additions & 2 deletions test/parallel/test-http2-util-update-options-buffer.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ const IDX_OPTIONS_PADDING_STRATEGY = 4;
const IDX_OPTIONS_MAX_HEADER_LIST_PAIRS = 5;
const IDX_OPTIONS_MAX_OUTSTANDING_PINGS = 6;
const IDX_OPTIONS_MAX_OUTSTANDING_SETTINGS = 7;
const IDX_OPTIONS_FLAGS = 8;
const IDX_OPTIONS_MAX_SESSION_MEMORY = 8;
const IDX_OPTIONS_FLAGS = 9;

{
updateOptionsBuffer({
Expand All @@ -31,7 +32,8 @@ const IDX_OPTIONS_FLAGS = 8;
paddingStrategy: 5,
maxHeaderListPairs: 6,
maxOutstandingPings: 7,
maxOutstandingSettings: 8
maxOutstandingSettings: 8,
maxSessionMemory: 9
});

strictEqual(optionsBuffer[IDX_OPTIONS_MAX_DEFLATE_DYNAMIC_TABLE_SIZE], 1);
Expand All @@ -42,6 +44,7 @@ const IDX_OPTIONS_FLAGS = 8;
strictEqual(optionsBuffer[IDX_OPTIONS_MAX_HEADER_LIST_PAIRS], 6);
strictEqual(optionsBuffer[IDX_OPTIONS_MAX_OUTSTANDING_PINGS], 7);
strictEqual(optionsBuffer[IDX_OPTIONS_MAX_OUTSTANDING_SETTINGS], 8);
strictEqual(optionsBuffer[IDX_OPTIONS_MAX_SESSION_MEMORY], 9);

const flags = optionsBuffer[IDX_OPTIONS_FLAGS];

Expand Down
44 changes: 44 additions & 0 deletions test/sequential/test-http2-max-session-memory.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
'use strict';

const common = require('../common');
if (!common.hasCrypto)
common.skip('missing crypto');

const http2 = require('http2');

// Test that maxSessionMemory Caps work

const largeBuffer = Buffer.alloc(1e6);

const server = http2.createServer({ maxSessionMemory: 1 });

server.on('stream', common.mustCall((stream) => {
stream.respond();
stream.end(largeBuffer);
}));

server.listen(0, common.mustCall(() => {
const client = http2.connect(`http://localhost:${server.address().port}`);

{
const req = client.request();

req.on('response', () => {
// This one should be rejected because the server is over budget
// on the current memory allocation
const req = client.request();
req.on('error', common.expectsError({
code: 'ERR_HTTP2_STREAM_ERROR',
type: Error,
message: 'Stream closed with error code 11'
}));
req.on('close', common.mustCall(() => {
server.close();
client.destroy();
}));
});

req.resume();
req.on('close', common.mustCall());
}
}));