Skip to content

http2: add session tracking and graceful server shutdown of http2 server #57586

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

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
31 changes: 30 additions & 1 deletion lib/internal/http2/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ const kServer = Symbol('server');
const kState = Symbol('state');
const kType = Symbol('type');
const kWriteGeneric = Symbol('write-generic');
const kSessions = Symbol('sessions');

const {
kBitfield,
Expand Down Expand Up @@ -1125,9 +1126,13 @@ function emitClose(self, error) {
function cleanupSession(session) {
const socket = session[kSocket];
const handle = session[kHandle];
const server = session[kServer];
session[kProxySocket] = undefined;
session[kSocket] = undefined;
session[kHandle] = undefined;
if (server) {
server[kSessions].delete(session);
}
session[kNativeFields] = trackAssignmentsTypedArray(
new Uint8Array(kSessionUint8FieldCount));
if (handle)
Expand Down Expand Up @@ -1644,6 +1649,9 @@ class ServerHttp2Session extends Http2Session {
constructor(options, socket, server) {
super(NGHTTP2_SESSION_SERVER, options, socket);
this[kServer] = server;
if (server) {
server[kSessions].add(this);
}
// This is a bit inaccurate because it does not reflect changes to
// number of listeners made after the session was created. This should
// not be an issue in practice. Additionally, the 'priority' event on
Expand Down Expand Up @@ -3168,11 +3176,25 @@ function onErrorSecureServerSession(err, socket) {
socket.destroy(err);
}

/**
* This function closes all active sessions gracefully.
* @param {*} server the underlying server whose sessions to be closed
*/
function closeAllSessions(server) {
const sessions = server[kSessions];
if (sessions.size > 0) {
for (const session of sessions) {
session.close();
}
}
}

class Http2SecureServer extends TLSServer {
constructor(options, requestListener) {
options = initializeTLSOptions(options);
super(options, connectionListener);
this[kOptions] = options;
this[kSessions] = new SafeSet();
this.timeout = 0;
this.on('newListener', setupCompat);
if (options.allowHTTP1 === true) {
Expand Down Expand Up @@ -3202,10 +3224,11 @@ class Http2SecureServer extends TLSServer {
}

close() {
ReflectApply(TLSServer.prototype.close, this, arguments);
if (this[kOptions].allowHTTP1 === true) {
httpServerPreClose(this);
}
ReflectApply(TLSServer.prototype.close, this, arguments);
closeAllSessions(this);
}

closeIdleConnections() {
Expand All @@ -3220,6 +3243,7 @@ class Http2Server extends NETServer {
options = initializeOptions(options);
super(options, connectionListener);
this[kOptions] = options;
this[kSessions] = new SafeSet();
this.timeout = 0;
this.on('newListener', setupCompat);
if (typeof requestListener === 'function')
Expand All @@ -3241,6 +3265,11 @@ class Http2Server extends NETServer {
this[kOptions].settings = { ...this[kOptions].settings, ...settings };
}

close() {
ReflectApply(NETServer.prototype.close, this, arguments);
closeAllSessions(this);
}

async [SymbolAsyncDispose]() {
return promisify(super.close).call(this);
}
Expand Down
4 changes: 2 additions & 2 deletions test/parallel/test-http2-capture-rejection.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,6 @@ events.captureRejections = true;
server.on('stream', common.mustCall(async (stream) => {
const { port } = server.address();

server.close();

stream.pushStream({
':scheme': 'http',
':path': '/foobar',
Expand All @@ -127,6 +125,8 @@ events.captureRejections = true;
stream.respond({
':status': 200
});

server.close();
}));

server.listen(0, common.mustCall(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ server.listen(0, common.mustCall(function() {
response.statusMessage = 'test';
response.statusMessage = 'test'; // only warn once
assert.strictEqual(response.statusMessage, ''); // no change
server.close();
}));
response.end();
}));
Expand All @@ -44,6 +43,9 @@ server.listen(0, common.mustCall(function() {
request.on('end', common.mustCall(function() {
client.close();
}));
request.on('close', common.mustCall(function() {
server.close();
}));
request.end();
request.resume();
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ server.listen(0, common.mustCall(function() {
response.on('finish', common.mustCall(function() {
assert.strictEqual(response.statusMessage, '');
assert.strictEqual(response.statusMessage, ''); // only warn once
server.close();
}));
response.end();
}));
Expand All @@ -43,6 +42,9 @@ server.listen(0, common.mustCall(function() {
request.on('end', common.mustCall(function() {
client.close();
}));
request.on('close', common.mustCall(function() {
server.close();
}));
request.end();
request.resume();
}));
Expand Down
67 changes: 67 additions & 0 deletions test/parallel/test-http2-graceful-close.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
'use strict';

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

// This test verifies that the server waits for active connections/sessions
// to complete and all data to be sent before fully closing

// Create a server that will send a large response in chunks
const server = http2.createServer();

// Track server events
server.on('stream', common.mustCall((stream, headers) => {

// Initiate the server close before client data is sent, this will
// test if the server properly waits for the stream to finish
server.close();
setImmediate(() => {
stream.respond({
'content-type': 'text/plain',
':status': 200
});

// Create a large response (1MB) to ensure it takes some time to send
const chunk = Buffer.alloc(64 * 1024, 'x');

// Send 16 chunks (1MB total) to simulate a large response
for (let i = 0; i < 16; i++) {
stream.write(chunk);
}

// Stream end should happen after data is written
stream.end();
});

stream.on('close', common.mustCall(() => {
assert.strictEqual(stream.readableEnded, true);
assert.strictEqual(stream.writableFinished, true);
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
assert.strictEqual(stream.writableFinished, true);
assert.strictEqual(stream.readableFinished, true);
assert.strictEqual(stream.writableFinished, true);

Copy link
Author

@pandeykushagra51 pandeykushagra51 Apr 7, 2025

Choose a reason for hiding this comment

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

just found from nodejs docs that readable do not have readableFinished property but have readableEnded so replaced with that

Copy link
Member

Choose a reason for hiding this comment

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

Yes, it was a typo.

}));
}));

// Start the server
server.listen(0, common.mustCall(() => {
// Create client and request
const client = http2.connect(`http://localhost:${server.address().port}`);
const req = client.request({ ':path': '/' });

// Track received data
let receivedData = 0;
req.on('data', (chunk) => {
receivedData += chunk.length;
});

// When request closes
req.on('close', common.mustCall(() => {
// Should receive all data
assert.strictEqual(req.readableEnded, true);
assert.strictEqual(receivedData, 64 * 1024 * 16);
assert.strictEqual(req.writableFinished, true);
}));

// Start the request
req.end();
}));
58 changes: 58 additions & 0 deletions test/parallel/test-http2-server-close-idle-connection.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
'use strict';

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

// This test verifies that the server closes idle connections

const server = http2.createServer();

server.on('session', common.mustCall((session) => {
session.on('stream', common.mustCall((stream) => {
// Respond to the request with a small payload
stream.respond({
'content-type': 'text/plain',
':status': 200
});
stream.end('hello');
stream.on('close', common.mustCall(() => {
assert.strictEqual(stream.writableFinished, true);
}));
Comment on lines +21 to +23
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
stream.on('close', common.mustCall(() => {
assert.strictEqual(stream.writableFinished, true);
}));

Feel free to keep it, but I don't think it is useful as the server is closed after the request ends (the 'end' event is emitted on the client).

Copy link
Author

@pandeykushagra51 pandeykushagra51 Apr 7, 2025

Choose a reason for hiding this comment

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

I have a but different opinion, I would like to keep it

Copy link
Author

@pandeykushagra51 pandeykushagra51 Apr 7, 2025

Choose a reason for hiding this comment

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

we should ensure that server side stream have closed before server close

Copy link
Member

Choose a reason for hiding this comment

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

The server does not close if there are open streams, no?

Copy link
Author

Choose a reason for hiding this comment

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

IMO if there are open stream we can’t call it graceful closure

Copy link
Member

Choose a reason for hiding this comment

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

Yes, that's the point, if the stream is not closed via server.close(), the process does not exit. If the process exits then it means that the stream and the server were closed. stream.writableFinished is already tested on the client side. Anyway, it does not matter.

Copy link
Author

@pandeykushagra51 pandeykushagra51 Apr 7, 2025

Choose a reason for hiding this comment

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

Do you think we should still remove that?

}));
session.on('close', common.mustCall());
}));

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

// This will ensure that server closed the idle connection
client.on('close', common.mustCall());

// Make an initial request
const req = client.request({ ':path': '/' });

// Process the response data
req.setEncoding('utf8');
let data = '';
req.on('data', (chunk) => {
data += chunk;
});

// When initial request ends
req.on('end', common.mustCall(() => {
assert.strictEqual(data, 'hello');
// Close the server as client is idle now
setImmediate(() => {
server.close();
});
}));

// Don't explicitly close the client, we want to keep it
// idle and check if the server closes the idle connection.
// As this is what we want to test here.
}));
2 changes: 1 addition & 1 deletion test/parallel/test-http2-server-http1-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,5 @@ server.on('session', common.mustCall((session) => {

server.listen(0, common.mustCall(() => {
const req = http.get(`http://localhost:${server.address().port}`);
req.on('error', (error) => server.close());
req.on('error', (error) => setImmediate(() => server.close()));
}));
Loading