From f505d5eca1a2cd28a7d1eb2ad5c4d3c0880d5a9a Mon Sep 17 00:00:00 2001 From: Matt Weber Date: Mon, 19 Oct 2026 19:07:34 -0400 Subject: [PATCH 1/2] http: free socket when response cannot have a body Per RFC 9112 section 6.3, responses to HEAD requests, and 204 and 304 responses, cannot contain a message body. When such a response is complete and nobody has started reading it, release its socket back to the agent's free pool instead of waiting for an 'end' that only a reader can cause. Refs: https://github.com/nodejs/node/issues/47228 Assisted-by: claude:sonnet-5.5 Signed-off-by: Matt Weber --- lib/internal/http/client.js | 11 +++++ test/parallel/test-http-agent-exit.js | 63 +++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 test/parallel/test-http-agent-exit.js diff --git a/lib/internal/http/client.js b/lib/internal/http/client.js index 1952e7b69af..142a251103b 100644 --- a/lib/internal/http/client.js +++ b/lib/internal/http/client.js @@ -897,7 +897,16 @@ function socketOnData(d) { socket.removeListener('data', socketOnData); socket.removeListener('end', socketOnEnd); socket.removeListener('drain', ondrain); + const res = parser.incoming; freeParser(parser, req, socket); + if (res.readableFlowing === null && + (req.method === 'HEAD' || res.statusCode === 204 || + res.statusCode === 304)) { + // There are no readers for the body, and there is no body to wait for + // (RFC 9112 Section 6.3), so release the socket instead of waiting for + // an 'end' that only a reader can cause. + responseOnEnd.call(res); + } } } @@ -1052,6 +1061,8 @@ function responseKeepAlive(req) { function responseOnEnd() { const req = this.req; + // The socket was already released while the response was still unread. + if (req._ended) return; const socket = req.socket; this[kDetachAbortSignal](); diff --git a/test/parallel/test-http-agent-exit.js b/test/parallel/test-http-agent-exit.js new file mode 100644 index 00000000000..4f679ae2219 --- /dev/null +++ b/test/parallel/test-http-agent-exit.js @@ -0,0 +1,63 @@ +'use strict'; + +// A response to a HEAD request, or a 204 or 304 response, cannot have a +// message body, so callers have no reason to read it. A keep-alive client +// must still release the socket back to the agent for such responses even if +// the caller never consumes them. Otherwise the socket stays busy and keeps +// the process alive for as long as the server leaves the connection open. +// +// The server runs in a child process and the client process makes unconsumed +// requests and then expects to exit on its own, which it can only do once all +// of its sockets have been freed (and unref'd) by the agent. + +const common = require('../common'); +const cp = require('child_process'); +const http = require('http'); + +if (process.argv[2] === 'server') { + const server = http.createServer((req, res) => { + if (req.method === 'HEAD') { + res.writeHead(200); + res.end(); + } else if (req.url === '/204') { + res.writeHead(204); + res.end(); + } else if (req.url === '/304') { + res.writeHead(304); + res.end(); + } + }); + + server.listen(0, () => { + process.send(server.address().port); + + // Unref server prior to final mustNotCall, server close will be prevented if sockets are still open + setTimeout(() => server.unref(), common.platformTimeout(1000)); + }); +} else { + const serverProcess = cp.fork(__filename, ['server'], { + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + }); + serverProcess.once('message', common.mustCall((port) => { + serverProcess.channel.unref(); + serverProcess.unref(); + const agent = new http.Agent({ keepAlive: true }); + + // Make requests without consuming response + http.get({ method: 'HEAD', host: common.localhostIPv4, port, agent }, common.mustCall()); + http.get({ method: 'GET', host: common.localhostIPv4, port, agent, path: '/204' }, common.mustCall()); + http.get({ method: 'GET', host: common.localhostIPv4, port, agent, path: '/304' }, common.mustCall()); + + // Ensure handlers are called/not called as expected + const cb = common.mustCall((res) => { + res.on('end', common.mustCall()); + res.on('data', common.mustNotCall()); + }, 3); + http.get({ method: 'HEAD', host: common.localhostIPv4, port, agent }, cb); + http.get({ method: 'GET', host: common.localhostIPv4, port, agent, path: '/204' }, cb); + http.get({ method: 'GET', host: common.localhostIPv4, port, agent, path: '/304' }, cb); + })); + + // HEAD, 204, and 304 requests should not block script exit + setTimeout(common.mustNotCall(), common.platformTimeout(3000)).unref(); +} From 0eab23858d6b85e0f39596634f8f1fbf1f67d21c Mon Sep 17 00:00:00 2001 From: Matt Weber Date: Tue, 20 Oct 2026 03:49:23 -0400 Subject: [PATCH 2/2] http: unref socket of a complete unread response A response that has been received completely but that nobody reads keeps its socket busy, and the socket keeps the process alive until the server closes the connection. Once nothing else is keeping the event loop alive, nothing is left that could read the response, so unref the socket in that case instead of waiting for the connection to close. This only applies while nobody has started reading. A response that is read later is still delivered as before, and a request that is still in flight keeps the process alive. As with any unref'd handle, the process may now exit before the server closes the connection, in which case 'close' is not emitted. Fixes: https://github.com/nodejs/node/issues/47228 Assisted-by: claude:sonnet-5.5 Signed-off-by: Matt Weber --- lib/internal/http/client.js | 19 +++--- .../test-http-agent-unread-response-exit.js | 61 +++++++++++++++++++ 2 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 test/parallel/test-http-agent-unread-response-exit.js diff --git a/lib/internal/http/client.js b/lib/internal/http/client.js index 142a251103b..1fc85505c09 100644 --- a/lib/internal/http/client.js +++ b/lib/internal/http/client.js @@ -899,13 +899,18 @@ function socketOnData(d) { socket.removeListener('drain', ondrain); const res = parser.incoming; freeParser(parser, req, socket); - if (res.readableFlowing === null && - (req.method === 'HEAD' || res.statusCode === 204 || - res.statusCode === 304)) { - // There are no readers for the body, and there is no body to wait for - // (RFC 9112 Section 6.3), so release the socket instead of waiting for - // an 'end' that only a reader can cause. - responseOnEnd.call(res); + if (res.readableFlowing === null) { + if (req.method === 'HEAD' || res.statusCode === 204 || + res.statusCode === 304) { + // There are no readers for the body, and there is no body to wait for + // (RFC 9112 Section 6.3), so release the socket instead of waiting for + // an 'end' that only a reader can cause. + responseOnEnd.call(res); + } else if (typeof socket.unref === 'function') { + // There are no readers for the body, so this socket alone must not + // keep the process alive. + socket.unref(); + } } } } diff --git a/test/parallel/test-http-agent-unread-response-exit.js b/test/parallel/test-http-agent-unread-response-exit.js new file mode 100644 index 00000000000..d8b24279547 --- /dev/null +++ b/test/parallel/test-http-agent-unread-response-exit.js @@ -0,0 +1,61 @@ +'use strict'; + +// A complete response that nobody reads must not keep the process alive by +// itself: the socket has nothing left to do, and nothing remains that could +// read the response once the event loop would otherwise be empty. + +const common = require('../common'); +const assert = require('assert'); +const cp = require('child_process'); +const http = require('http'); + +if (process.argv[2] === 'server') { + process.on('disconnect', () => process.exit(0)); + + const server = http.createServer((req, res) => { + if (req.url === '/empty') { + res.writeHead(200); + res.end(); + } else if (req.url === '/redirect') { + res.writeHead(302, { Location: '/' }); + res.end('Found'); + } else { + res.end('hello'); + } + }); + + server.listen(0, () => { + process.send(server.address().port); + }); +} else { + const serverProcess = cp.fork(__filename, ['server'], { + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + }); + serverProcess.once('message', common.mustCall((port) => { + serverProcess.channel.unref(); + serverProcess.unref(); + const agent = new http.Agent({ keepAlive: true }); + + // Make requests without reading the responses + for (const path of ['/empty', '/redirect', '/body']) { + http.get({ host: common.localhostIPv4, port, agent, path }, common.mustCall()); + } + + // A response that is read later still has its body + http.get({ host: common.localhostIPv4, port, agent, path: '/body' }, common.mustCall((res) => { + setTimeout(common.mustCall(() => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { + body += chunk; + }); + res.on('end', common.mustCall(() => { + assert.strictEqual(body, 'hello'); + })); + }), common.platformTimeout(200)); + })); + })); + + // Unread responses should not block script exit + setTimeout(common.mustNotCall(), common.platformTimeout(3000)).unref(); +}