Skip to content

Commit 684b581

Browse files
committed
http2: add new connectionWindowSize option
Signed-off-by: Tim Perry <pimterry@gmail.com> PR-URL: #65619 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Yongsheng Zhang <zyszys98@gmail.com>
1 parent 8955781 commit 684b581

8 files changed

Lines changed: 212 additions & 35 deletions

File tree

‎doc/api/http2.md‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -669,10 +669,16 @@ added:
669669

670670
* `windowSize` {number}
671671

672-
Sets the local endpoint's window size.
672+
Sets the local endpoint's connection-level window size.
673673
The `windowSize` is the total window size to set, not
674674
the delta.
675675

676+
Increases take effect immediately, but decreases only apply as the window
677+
already advertised to the peer is consumed, since a window that has been
678+
advertised cannot be retracted. To use a window smaller than the default from
679+
the start of the connection, set the `connectionWindowSize` option when
680+
creating the server or client session instead.
681+
676682
```mjs
677683
import { createServer } from 'node:http2';
678684

@@ -2804,6 +2810,9 @@ Throws `ERR_INVALID_ARG_TYPE` for invalid `settings` argument.
28042810
<!-- YAML
28052811
added: v8.4.0
28062812
changes:
2813+
- version: REPLACEME
2814+
pr-url: https://github.com/nodejs/node/pull/65619
2815+
description: Added the `connectionWindowSize` option.
28072816
- version: v25.7.0
28082817
pr-url: https://github.com/nodejs/node/pull/59917
28092818
description: Added the `strictSingleValueFields` option.
@@ -2917,6 +2926,13 @@ changes:
29172926
error that should tell the peer to not open any more streams, continuing
29182927
to open streams is therefore regarded as a sign of a misbehaving peer.
29192928
**Default:** `100`.
2929+
* `connectionWindowSize` {number} Sets the initial flow control window for
2930+
each session, in bytes. This is the total amount of data the remote peer
2931+
may send across all streams before it has to wait for a `WINDOW_UPDATE`.
2932+
The equivalent per-stream limit is `settings.initialWindowSize`. The
2933+
minimum allowed value is `1` and the maximum is 2<sup>31</sup>-1. Values
2934+
below 65535 will not take effect until the initial protocol-default
2935+
window of 65535 has been used.
29202936
* `settings` {HTTP/2 Settings Object} The initial settings to send to the
29212937
remote peer upon connection.
29222938
* `streamResetBurst` {number} and `streamResetRate` {number} Sets the rate
@@ -3028,6 +3044,9 @@ server.listen(8000);
30283044
<!-- YAML
30293045
added: v8.4.0
30303046
changes:
3047+
- version: REPLACEME
3048+
pr-url: https://github.com/nodejs/node/pull/65619
3049+
description: Added the `connectionWindowSize` option.
30313050
- version: v25.7.0
30323051
pr-url: https://github.com/nodejs/node/pull/59917
30333052
description: Added the `strictSingleValueFields` option.
@@ -3132,6 +3151,14 @@ changes:
31323151
error that should tell the peer to not open any more streams, continuing
31333152
to open streams is therefore regarded as a sign of a misbehaving peer.
31343153
**Default:** `100`.
3154+
* `connectionWindowSize` {number} Sets the initial flow control window for
3155+
each session, in bytes. This is the total amount of data the remote peer
3156+
may send across all streams before it has to wait for a `WINDOW_UPDATE`.
3157+
The equivalent per-stream limit is `settings.initialWindowSize`. The
3158+
minimum allowed value is `1` and the maximum is 2<sup>31</sup>-1. Values
3159+
below 65535 will not take effect until the initial protocol-default
3160+
window of 65535 has been used.
3161+
**Default:** `65535`.
31353162
* `settings` {HTTP/2 Settings Object} The initial settings to send to the
31363163
remote peer upon connection.
31373164
* `streamResetBurst` {number} and `streamResetRate` {number} Sets the rate
@@ -3228,6 +3255,9 @@ server.listen(8443);
32283255
<!-- YAML
32293256
added: v8.4.0
32303257
changes:
3258+
- version: REPLACEME
3259+
pr-url: https://github.com/nodejs/node/pull/65619
3260+
description: Added the `connectionWindowSize` option.
32313261
- version:
32323262
- v15.10.0
32333263
- v14.16.0
@@ -3315,6 +3345,14 @@ changes:
33153345
* `protocol` {string} The protocol to connect with, if not set in the
33163346
`authority`. Value may be either `'http:'` or `'https:'`. **Default:**
33173347
`'https:'`
3348+
* `connectionWindowSize` {number} Sets the initial flow control window for
3349+
this session, in bytes. This is the total amount of data the remote peer
3350+
may send across all streams before it has to wait for a `WINDOW_UPDATE`.
3351+
The equivalent per-stream limit is `settings.initialWindowSize`. The
3352+
minimum allowed value is `1` and the maximum is 2<sup>31</sup>-1. Values
3353+
below 65535 will not take effect until the initial protocol-default
3354+
window of 65535 has been used.
3355+
**Default:** `65535`.
33183356
* `settings` {HTTP/2 Settings Object} The initial settings to send to the
33193357
remote peer upon connection.
33203358
* `remoteCustomSettings` {Array} The array of integer values determines the
@@ -3747,6 +3785,9 @@ properties.
37473785
* `initialWindowSize` {number} Specifies the _sender's_ initial window size in
37483786
bytes for stream-level flow control. The minimum allowed value is 0. The
37493787
maximum allowed value is 2<sup>32</sup>-1. **Default:** `65535`.
3788+
This is a per-stream limit; the window for the connection as a whole is
3789+
configured separately with the `connectionWindowSize` option of
3790+
[`http2.createServer()`][] or [`http2.connect()`][].
37503791
* `maxFrameSize` {number} Specifies the size in bytes of the largest frame
37513792
payload. The minimum allowed value is 16,384. The maximum allowed value is
37523793
2<sup>24</sup>-1. **Default:** `16384`.
@@ -5194,6 +5235,7 @@ you need to implement any fall-back behavior yourself.
51945235
[`http.createServer()`]: http.md#httpcreateserveroptions-requestlistener
51955236
[`http2.SecureServer`]: #class-http2secureserver
51965237
[`http2.Server`]: #class-http2server
5238+
[`http2.connect()`]: #http2connectauthority-options-listener
51975239
[`http2.createSecureServer()`]: #http2createsecureserveroptions-onrequesthandler
51985240
[`http2.createServer()`]: #http2createserveroptions-onrequesthandler
51995241
[`http2stream.pushStream()`]: #http2streampushstreamheaders-options-callback

‎lib/internal/http2/core.js‎

Lines changed: 28 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,8 @@ function debugSessionObj(session, message, ...args) {
238238
const kMaxFrameSize = (2 ** 24) - 1;
239239
const kMaxInt = (2 ** 32) - 1;
240240
const kMaxInitialWindowSize = (2 ** 31) - 1; // HTTP/2 spec maximum
241+
// A connection window of 0 stalls the connection
242+
const kMinConnectionWindowSize = 1;
241243
const kMaxStreams = (2 ** 32) - 1;
242244
const kMaxALTSVC = (2 ** 14) - 2;
243245

@@ -3418,19 +3420,38 @@ function connectionListener(socket) {
34183420
this.emit('session', session);
34193421
}
34203422

3421-
function initializeOptions(options) {
3422-
assertIsObject(options, 'options');
3423-
options = { ...options };
3424-
assertIsObject(options.settings, 'options.settings');
3425-
options.settings = { ...options.settings };
3426-
3423+
// Validates the session options that apply to both client and server sessions
3424+
function validateSessionOptions(options) {
34273425
assertIsArray(options.remoteCustomSettings, 'options.remoteCustomSettings');
34283426
if (options.remoteCustomSettings) {
34293427
options.remoteCustomSettings = [ ...options.remoteCustomSettings ];
34303428
if (options.remoteCustomSettings.length > MAX_ADDITIONAL_SETTINGS)
34313429
throw new ERR_HTTP2_TOO_MANY_CUSTOM_SETTINGS();
34323430
}
34333431

3432+
if (options.strictSingleValueFields !== undefined) {
3433+
validateBoolean(
3434+
options.strictSingleValueFields,
3435+
'options.strictSingleValueFields',
3436+
);
3437+
} else {
3438+
options.strictSingleValueFields = true;
3439+
}
3440+
3441+
if (options.connectionWindowSize !== undefined) {
3442+
validateInt32(options.connectionWindowSize, 'options.connectionWindowSize',
3443+
kMinConnectionWindowSize, kMaxInitialWindowSize);
3444+
}
3445+
}
3446+
3447+
function initializeOptions(options) {
3448+
assertIsObject(options, 'options');
3449+
options = { ...options };
3450+
assertIsObject(options.settings, 'options.settings');
3451+
options.settings = { ...options.settings };
3452+
3453+
validateSessionOptions(options);
3454+
34343455
if (options.maxSessionInvalidFrames !== undefined)
34353456
validateUint32(options.maxSessionInvalidFrames, 'options.maxSessionInvalidFrames');
34363457

@@ -3447,16 +3468,6 @@ function initializeOptions(options) {
34473468
// TODO(danbev): is this a good default value?
34483469
options.unknownProtocolTimeout = 10000;
34493470

3450-
if (options.strictSingleValueFields !== undefined) {
3451-
validateBoolean(
3452-
options.strictSingleValueFields,
3453-
'options.strictSingleValueFields',
3454-
);
3455-
} else {
3456-
options.strictSingleValueFields = true;
3457-
}
3458-
3459-
34603471
// Initialize http1Options bag for HTTP/1 fallback when allowHTTP1 is true.
34613472
// This bag is passed to storeHTTPOptions() to configure HTTP/1 server
34623473
// behavior (timeouts, IncomingMessage/ServerResponse classes, etc.).
@@ -3668,21 +3679,7 @@ function connect(authority, options, listener) {
36683679
maxOriginSetSize = 128;
36693680
}
36703681

3671-
assertIsArray(options.remoteCustomSettings, 'options.remoteCustomSettings');
3672-
if (options.remoteCustomSettings) {
3673-
options.remoteCustomSettings = [ ...options.remoteCustomSettings ];
3674-
if (options.remoteCustomSettings.length > MAX_ADDITIONAL_SETTINGS)
3675-
throw new ERR_HTTP2_TOO_MANY_CUSTOM_SETTINGS();
3676-
}
3677-
3678-
if (options.strictSingleValueFields !== undefined) {
3679-
validateBoolean(
3680-
options.strictSingleValueFields,
3681-
'options.strictSingleValueFields',
3682-
);
3683-
} else {
3684-
options.strictSingleValueFields = true;
3685-
}
3682+
validateSessionOptions(options);
36863683

36873684
if (typeof authority === 'string')
36883685
authority = new URL(authority);

‎lib/internal/http2/util.js‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,8 @@ const IDX_OPTIONS_MAX_SETTINGS = 9;
231231
const IDX_OPTIONS_STREAM_RESET_RATE = 10;
232232
const IDX_OPTIONS_STREAM_RESET_BURST = 11;
233233
const IDX_OPTIONS_STRICT_HTTP_FIELD_WHITESPACE_VALIDATION = 12;
234-
const IDX_OPTIONS_FLAGS = 13;
234+
const IDX_OPTIONS_CONNECTION_WINDOW_SIZE = 13;
235+
const IDX_OPTIONS_FLAGS = 14;
235236

236237
function updateOptionsBuffer(options) {
237238
let flags = 0;
@@ -301,6 +302,11 @@ function updateOptionsBuffer(options) {
301302
optionsBuffer[IDX_OPTIONS_STRICT_HTTP_FIELD_WHITESPACE_VALIDATION] =
302303
options.strictFieldWhitespaceValidation === true ? 0 : 1;
303304
}
305+
if (typeof options.connectionWindowSize === 'number') {
306+
flags |= (1 << IDX_OPTIONS_CONNECTION_WINDOW_SIZE);
307+
optionsBuffer[IDX_OPTIONS_CONNECTION_WINDOW_SIZE] =
308+
options.connectionWindowSize;
309+
}
304310

305311
optionsBuffer[IDX_OPTIONS_FLAGS] = flags;
306312
}

‎src/node_http2.cc‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,11 @@ Http2Options::Http2Options(Http2State* http2_state, SessionType type) {
217217
static_cast<size_t>(buffer[IDX_OPTIONS_MAX_SETTINGS]));
218218
}
219219

220+
if (flags & (1 << IDX_OPTIONS_CONNECTION_WINDOW_SIZE)) {
221+
set_connection_window_size(
222+
static_cast<int32_t>(buffer[IDX_OPTIONS_CONNECTION_WINDOW_SIZE]));
223+
}
224+
220225
if ((flags & (1 << IDX_OPTIONS_STREAM_RESET_BURST)) &&
221226
(flags & (1 << IDX_OPTIONS_STREAM_RESET_RATE))) {
222227
nghttp2_option_set_stream_reset_rate_limit(
@@ -612,6 +617,12 @@ Http2Session::Http2Session(Http2State* http2_state,
612617
&alloc_info), 0);
613618
session_.reset(session);
614619

620+
// User settings have to be applied here initially rather than updating
621+
// later as windows cannot be shrunk after they've been advertised.
622+
CHECK_EQ(nghttp2_session_set_local_window_size(
623+
session, NGHTTP2_FLAG_NONE, 0, opts.connection_window_size()),
624+
0);
625+
615626
outgoing_storage_.reserve(1024);
616627
outgoing_buffers_.reserve(32);
617628

‎src/node_http2.h‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ constexpr size_t kDefaultMaxSettings = 10;
3939
// Default maximum total memory cap for Http2Session.
4040
constexpr uint64_t kDefaultMaxSessionMemory = 10000000;
4141

42+
constexpr int32_t kDefaultConnectionWindowSize = 65535;
43+
4244
// These are the standard HTTP/2 defaults as specified by the RFC
4345
constexpr uint32_t DEFAULT_SETTINGS_HEADER_TABLE_SIZE = 4096;
4446
constexpr uint32_t DEFAULT_SETTINGS_ENABLE_PUSH = 1;
@@ -236,9 +238,16 @@ class Http2Options {
236238
return max_session_memory_;
237239
}
238240

241+
void set_connection_window_size(int32_t size) {
242+
connection_window_size_ = size;
243+
}
244+
245+
int32_t connection_window_size() const { return connection_window_size_; }
246+
239247
private:
240248
Nghttp2OptionPointer options_;
241249
uint64_t max_session_memory_ = kDefaultMaxSessionMemory;
250+
int32_t connection_window_size_ = kDefaultConnectionWindowSize;
242251
uint32_t max_header_pairs_ = DEFAULT_MAX_HEADER_LIST_PAIRS;
243252
PaddingStrategy padding_strategy_ = PADDING_STRATEGY_NONE;
244253
size_t max_outstanding_pings_ = kDefaultMaxPings;

‎src/node_http2_state.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ namespace http2 {
6161
IDX_OPTIONS_STREAM_RESET_RATE,
6262
IDX_OPTIONS_STREAM_RESET_BURST,
6363
IDX_OPTIONS_STRICT_HTTP_FIELD_WHITESPACE_VALIDATION,
64+
IDX_OPTIONS_CONNECTION_WINDOW_SIZE,
6465
IDX_OPTIONS_FLAGS
6566
};
6667

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
if (!common.hasCrypto)
5+
common.skip('missing crypto');
6+
7+
const assert = require('assert');
8+
const http2 = require('http2');
9+
10+
const DEFAULT_WINDOW = 65535;
11+
12+
// Set client & server's windows (or default if undefined) and validate that
13+
// the both peers see the remote value as expected.
14+
function check(serverWindow, clientWindow) {
15+
const server = http2.createServer({ connectionWindowSize: serverWindow });
16+
serverWindow ??= DEFAULT_WINDOW;
17+
clientWindow ??= DEFAULT_WINDOW;
18+
19+
server.on('session', common.mustCall((session) => {
20+
assert.strictEqual(session.state.effectiveLocalWindowSize, serverWindow);
21+
}));
22+
23+
server.on('stream', common.mustCall((stream) => {
24+
assert.strictEqual(stream.session.state.remoteWindowSize, clientWindow);
25+
stream.respond({ ':status': 200 }, { endStream: true });
26+
}));
27+
28+
server.listen(0, common.mustCall(() => {
29+
const client = http2.connect(`http://localhost:${server.address().port}`,
30+
{ connectionWindowSize: clientWindow });
31+
32+
client.on('connect', common.mustCall(() => {
33+
assert.strictEqual(client.state.effectiveLocalWindowSize, clientWindow);
34+
}));
35+
36+
const req = client.request();
37+
req.resume();
38+
req.on('close', common.mustCall(() => {
39+
assert.strictEqual(client.state.remoteWindowSize, serverWindow);
40+
client.close();
41+
server.close();
42+
}));
43+
}));
44+
}
45+
46+
check(undefined, undefined);
47+
48+
// Each end of the connection is configured independently.
49+
check(2 ** 20, 2 ** 21);
50+
51+
// A window below the peer's fixed initial 65535 is the only case that shrinks
52+
// the window nghttp2 has already accounted for, leaving a reduction to pay off
53+
// before any WINDOW_UPDATE can be sent again. Larger windows only ever grow it.
54+
// setLocalWindowSize() cannot do this once the connection is established.
55+
// Send more than the window to prove the connection still resumes rather than
56+
// stalling once the reduction is paid.
57+
{
58+
const windowSize = 16384;
59+
const payload = Buffer.alloc(2 ** 18, 'x');
60+
const server = http2.createServer({ connectionWindowSize: windowSize });
61+
62+
server.on('stream', common.mustCall((stream) => {
63+
let received = 0;
64+
stream.on('data', (chunk) => { received += chunk.length; });
65+
stream.on('end', common.mustCall(() => {
66+
assert.strictEqual(received, payload.length);
67+
stream.respond({ ':status': 200 }, { endStream: true });
68+
}));
69+
}));
70+
71+
server.listen(0, common.mustCall(() => {
72+
const client = http2.connect(`http://localhost:${server.address().port}`,
73+
{ connectionWindowSize: windowSize });
74+
const req = client.request({ ':method': 'POST', ':path': '/' });
75+
req.resume();
76+
req.end(payload);
77+
req.on('close', common.mustCall(() => {
78+
client.close();
79+
server.close();
80+
}));
81+
}));
82+
}
83+
84+
for (const connectionWindowSize of [-1, 0, 2 ** 31, 1.5]) {
85+
assert.throws(() => http2.createServer({ connectionWindowSize }),
86+
{ code: 'ERR_OUT_OF_RANGE' });
87+
assert.throws(() => http2.createSecureServer({ connectionWindowSize }),
88+
{ code: 'ERR_OUT_OF_RANGE' });
89+
assert.throws(() => http2.connect('http://localhost:80', { connectionWindowSize }),
90+
{ code: 'ERR_OUT_OF_RANGE' });
91+
}
92+
93+
// Windows below the peer's fixed initial 65535 are permitted: they cannot
94+
// shrink that first burst, but they do throttle everything after it.
95+
for (const connectionWindowSize of [1, 1024, 65534]) {
96+
http2.createServer({ connectionWindowSize }).close();
97+
}
98+
99+
for (const connectionWindowSize of ['1024', null, {}]) {
100+
assert.throws(() => http2.createServer({ connectionWindowSize }),
101+
{ code: 'ERR_INVALID_ARG_TYPE' });
102+
assert.throws(() => http2.connect('http://localhost:80', { connectionWindowSize }),
103+
{ code: 'ERR_INVALID_ARG_TYPE' });
104+
}

0 commit comments

Comments
 (0)