Skip to content
Open
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
16 changes: 16 additions & 0 deletions lib/internal/http/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -897,7 +897,21 @@ 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) {
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();
}
}
}
}

Expand Down Expand Up @@ -1052,6 +1066,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]();
Expand Down
63 changes: 63 additions & 0 deletions test/parallel/test-http-agent-exit.js
Original file line number Diff line number Diff line change
@@ -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();
}
61 changes: 61 additions & 0 deletions test/parallel/test-http-agent-unread-response-exit.js
Original file line number Diff line number Diff line change
@@ -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('<a href="/">Found</a>');
} 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();
}