Skip to content

Commit 283f2aa

Browse files
authored
feat(#4230): Implement pingInterval for dispatching PING frames (#4296)
1 parent 9cc025b commit 283f2aa

8 files changed

Lines changed: 329 additions & 7 deletions

File tree

docs/docs/api/Client.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Returns: `Client`
3434
* **maxConcurrentStreams**: `number` - Default: `100`. Dictates the maximum number of concurrent streams for a single H2 session. It can be overridden by a SETTINGS remote frame.
3535
* **initialWindowSize**: `number` (optional) - Default: `262144` (256KB). Sets the HTTP/2 stream-level flow-control window size (SETTINGS_INITIAL_WINDOW_SIZE). Must be a positive integer greater than 0. This default is higher than Node.js core's default (65535 bytes) to improve throughput, Node's choice is very conservative for current high-bandwith networks. See [RFC 7540 Section 6.9.2](https://datatracker.ietf.org/doc/html/rfc7540#section-6.9.2) for more details.
3636
* **connectionWindowSize**: `number` (optional) - Default `524288` (512KB). Sets the HTTP/2 connection-level flow-control window size using `ClientHttp2Session.setLocalWindowSize()`. Must be a positive integer greater than 0. This provides better flow control for the entire connection across multiple streams. See [Node.js HTTP/2 documentation](https://nodejs.org/api/http2.html#clienthttp2sessionsetlocalwindowsize) for more details.
37+
* **pingInterval**: `number` - Default: `60e3`. The time interval in milliseconds between PING frames sent to the server. Set to `0` to disable PING frames. This is only applicable for HTTP/2 connections. This will emit a `ping` event on the client with the duration of the ping in milliseconds.
3738

3839
> **Notes about HTTP/2**
3940
> - It only works under TLS connections. h2c is not supported.

docs/docs/api/H2CClient.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Returns: `H2CClient`
4848
- **maxResponseSize** `number | null` (optional) - Default: `-1` - The maximum length of response body in bytes. Set to `-1` to disable.
4949
- **maxConcurrentStreams**: `number` - Default: `100`. Dictates the maximum number of concurrent streams for a single H2 session. It can be overridden by a SETTINGS remote frame.
5050
- **pipelining** `number | null` (optional) - Default to `maxConcurrentStreams` - The amount of concurrent requests sent over a single HTTP/2 session in accordance with [RFC-7540](https://httpwg.org/specs/rfc7540.html#StreamsLayer) Stream specification. Streams can be closed up by remote server at any time.
51+
- **pingInterval**: `number` - Default: `60e3`. The time interval in milliseconds between PING frames sent to the server. Set to `0` to disable PING frames. This is only applicable for HTTP/2 connections.
5152
- **connect** `ConnectOptions | null` (optional) - Default: `null`.
5253
- **strictContentLength** `Boolean` (optional) - Default: `true` - Whether to treat request content length mismatches as errors. If true, an error is thrown when the request content-length header doesn't match the length of the request body. **Security Warning:** Disabling this option can expose your application to HTTP Request Smuggling attacks, where mismatched content-length headers cause servers and proxies to interpret request boundaries differently. This can lead to cache poisoning, credential hijacking, and bypassing security controls. Only disable this in controlled environments where you fully trust the request source.
5354
- **autoSelectFamily**: `boolean` (optional) - Default: depends on local Node version, on Node 18.13.0 and above is `false`. Enables a family autodetection algorithm that loosely implements section 5 of [RFC 8305](https://tools.ietf.org/html/rfc8305#section-5). See [here](https://nodejs.org/api/net.html#socketconnectoptions-connectlistener) for more details. This option is ignored if not supported by the current Node version.

lib/core/symbols.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ module.exports = {
6767
kEnableConnectProtocol: Symbol('http2session connect protocol'),
6868
kRemoteSettings: Symbol('http2session remote settings'),
6969
kHTTP2Stream: Symbol('http2session client stream'),
70+
kPingInterval: Symbol('ping interval'),
7071
kNoProxyAgent: Symbol('no proxy agent'),
7172
kHttpProxyAgent: Symbol('http proxy agent'),
7273
kHttpsProxyAgent: Symbol('https proxy agent')

lib/dispatcher/client-h2.js

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const {
2424
kStrictContentLength,
2525
kOnError,
2626
kMaxConcurrentStreams,
27+
kPingInterval,
2728
kHTTP2Session,
2829
kHTTP2InitialWindowSize,
2930
kHTTP2ConnectionWindowSize,
@@ -34,7 +35,8 @@ const {
3435
kBodyTimeout,
3536
kEnableConnectProtocol,
3637
kRemoteSettings,
37-
kHTTP2Stream
38+
kHTTP2Stream,
39+
kHTTP2SessionState
3840
} = require('../core/symbols.js')
3941
const { channels } = require('../core/diagnostics.js')
4042

@@ -102,10 +104,15 @@ function connectH2 (client, socket) {
102104
}
103105
})
104106

107+
client[kSocket] = socket
105108
session[kOpenStreams] = 0
106109
session[kClient] = client
107110
session[kSocket] = socket
108-
session[kHTTP2Session] = null
111+
session[kHTTP2SessionState] = {
112+
ping: {
113+
interval: client[kPingInterval] === 0 ? null : setInterval(onHttp2SendPing, client[kPingInterval], session).unref()
114+
}
115+
}
109116
// We set it to true by default in a best-effort; however once connected to an H2 server
110117
// we will check if extended CONNECT protocol is supported or not
111118
// and set this value accordingly.
@@ -253,6 +260,31 @@ function onHttp2RemoteSettings (settings) {
253260
this[kClient][kResume]()
254261
}
255262

263+
function onHttp2SendPing (session) {
264+
const state = session[kHTTP2SessionState]
265+
if ((session.closed || session.destroyed) && state.ping.interval != null) {
266+
clearInterval(state.ping.interval)
267+
state.ping.interval = null
268+
return
269+
}
270+
271+
// If no ping sent, do nothing
272+
session.ping(onPing.bind(session))
273+
274+
function onPing (err, duration) {
275+
const client = this[kClient]
276+
const socket = this[kClient]
277+
278+
if (err != null) {
279+
const error = new InformationalError(`HTTP/2: "PING" errored - type ${err.message}`)
280+
socket[kError] = error
281+
client[kOnError](error)
282+
} else {
283+
client.emit('ping', duration)
284+
}
285+
}
286+
}
287+
256288
function onHttp2SessionError (err) {
257289
assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID')
258290

@@ -316,14 +348,19 @@ function onHttp2SessionGoAway (errorCode) {
316348
}
317349

318350
function onHttp2SessionClose () {
319-
const { [kClient]: client } = this
351+
const { [kClient]: client, [kHTTP2SessionState]: state } = this
320352
const { [kSocket]: socket } = client
321353

322354
const err = this[kSocket][kError] || this[kError] || new SocketError('closed', util.getSocketInfo(socket))
323355

324356
client[kSocket] = null
325357
client[kHTTPContext] = null
326358

359+
if (state.ping.interval != null) {
360+
clearInterval(state.ping.interval)
361+
state.ping.interval = null
362+
}
363+
327364
if (client.destroyed) {
328365
assert(client[kPending] === 0)
329366

lib/dispatcher/client.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ const {
5454
kMaxConcurrentStreams,
5555
kHTTP2InitialWindowSize,
5656
kHTTP2ConnectionWindowSize,
57-
kResume
57+
kResume,
58+
kPingInterval
5859
} = require('../core/symbols.js')
5960
const connectH1 = require('./client-h1.js')
6061
const connectH2 = require('./client-h2.js')
@@ -112,7 +113,8 @@ class Client extends DispatcherBase {
112113
allowH2,
113114
useH2c,
114115
initialWindowSize,
115-
connectionWindowSize
116+
connectionWindowSize,
117+
pingInterval
116118
} = {}) {
117119
if (keepAlive !== undefined) {
118120
throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead')
@@ -216,6 +218,10 @@ class Client extends DispatcherBase {
216218
throw new InvalidArgumentError('connectionWindowSize must be a positive integer, greater than 0')
217219
}
218220

221+
if (pingInterval != null && (typeof pingInterval !== 'number' || !Number.isInteger(pingInterval) || pingInterval < 0)) {
222+
throw new InvalidArgumentError('pingInterval must be a positive integer, greater or equal to 0')
223+
}
224+
219225
super()
220226

221227
if (typeof connect !== 'function') {
@@ -250,6 +256,8 @@ class Client extends DispatcherBase {
250256
this[kMaxRequests] = maxRequestsPerClient
251257
this[kClosedResolve] = null
252258
this[kMaxResponseSize] = maxResponseSize > -1 ? maxResponseSize : -1
259+
this[kHTTPContext] = null
260+
// h2
253261
this[kMaxConcurrentStreams] = maxConcurrentStreams != null ? maxConcurrentStreams : 100 // Max peerConcurrentStreams for a Node h2 server
254262
// HTTP/2 window sizes are set to higher defaults than Node.js core for better performance:
255263
// - initialWindowSize: 262144 (256KB) vs Node.js default 65535 (64KB - 1)
@@ -259,7 +267,7 @@ class Client extends DispatcherBase {
259267
// Provides better flow control for the entire connection across multiple streams.
260268
this[kHTTP2InitialWindowSize] = initialWindowSize != null ? initialWindowSize : 262144
261269
this[kHTTP2ConnectionWindowSize] = connectionWindowSize != null ? connectionWindowSize : 524288
262-
this[kHTTPContext] = null
270+
this[kPingInterval] = pingInterval != null ? pingInterval : 60e3 // Default ping interval for h2 - 1 minute
263271

264272
// kQueue is built up of 3 sections separated by
265273
// the kRunningIdx and kPendingIdx indices.

0 commit comments

Comments
 (0)