From 58aded6d28c02804ec99ec3378eed80f67fea049 Mon Sep 17 00:00:00 2001 From: Robert Nagy Date: Wed, 1 Apr 2026 14:40:01 +0200 Subject: [PATCH] http: cleanup pipeline queue When a socket with pipelined requests is destroyed then some requests will leak. --- lib/_http_outgoing.js | 11 ++-- lib/_http_server.js | 9 +++- test/parallel/test-http-outgoing-destroyed.js | 26 ++++++++++ .../test-http-pipeline-outgoing-destroy.js | 50 +++++++++++++++++++ 4 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 test/parallel/test-http-pipeline-outgoing-destroy.js diff --git a/lib/_http_outgoing.js b/lib/_http_outgoing.js index 24aae1caca69d3..8aec411b9adb6f 100644 --- a/lib/_http_outgoing.js +++ b/lib/_http_outgoing.js @@ -328,14 +328,19 @@ OutgoingMessage.prototype.destroy = function destroy(error) { if (this[kSocket]) { this[kSocket].destroy(error); } else { - this.once('socket', function socketDestroyOnConnect(socket) { - socket.destroy(error); - }); + process.nextTick(emitDestroyNT, this); } return this; }; +function emitDestroyNT(self) { + if (!self._closed) { + self._closed = true; + self.emit('close'); + } +} + // This abstract either writing directly to the socket or buffering it. OutgoingMessage.prototype._send = function _send(data, encoding, callback, byteLength) { diff --git a/lib/_http_server.js b/lib/_http_server.js index b042106d2e14da..68db87b094d960 100644 --- a/lib/_http_server.js +++ b/lib/_http_server.js @@ -841,6 +841,7 @@ function socketOnClose(socket, state) { debug('server socket close'); freeParser(socket.parser, null, socket); abortIncoming(state.incoming); + abortOutgoing(state.outgoing); } function abortIncoming(incoming) { @@ -848,7 +849,13 @@ function abortIncoming(incoming) { const req = incoming.shift(); req.destroy(new ConnResetException('aborted')); } - // Abort socket._httpMessage ? +} + +function abortOutgoing(outgoing) { + while (outgoing.length) { + const req = outgoing.shift(); + req.destroy(new ConnResetException('aborted')); + } } function socketOnEnd(server, socket, parser, state) { diff --git a/test/parallel/test-http-outgoing-destroyed.js b/test/parallel/test-http-outgoing-destroyed.js index 1ea804ac9be1ac..b60b6594c765ff 100644 --- a/test/parallel/test-http-outgoing-destroyed.js +++ b/test/parallel/test-http-outgoing-destroyed.js @@ -2,6 +2,32 @@ const common = require('../common'); const http = require('http'); const assert = require('assert'); +const { OutgoingMessage } = require('http'); + +// OutgoingMessage.destroy() with no socket should emit 'close' and set closed. +{ + const msg = new OutgoingMessage(); + assert.strictEqual(msg.destroyed, false); + assert.strictEqual(msg.closed, false); + msg.on('close', common.mustCall(() => { + assert.strictEqual(msg.destroyed, true); + assert.strictEqual(msg.closed, true); + })); + msg.destroy(); + assert.strictEqual(msg.destroyed, true); +} + +// OutgoingMessage.destroy(err) with no socket should set errored and emit 'close'. +{ + const msg = new OutgoingMessage(); + const err = new Error('test destroy'); + msg.on('close', common.mustCall(() => { + assert.strictEqual(msg.closed, true); + assert.strictEqual(msg.errored, err); + })); + msg.destroy(err); + assert.strictEqual(msg.errored, err); +} { const server = http.createServer(common.mustCall((req, res) => { diff --git a/test/parallel/test-http-pipeline-outgoing-destroy.js b/test/parallel/test-http-pipeline-outgoing-destroy.js new file mode 100644 index 00000000000000..4cb4e8030376bf --- /dev/null +++ b/test/parallel/test-http-pipeline-outgoing-destroy.js @@ -0,0 +1,50 @@ +'use strict'; +// Test that queued (pipelined) outgoing responses are destroyed when the +// socket closes before the first response has finished. Previously, +// socketOnClose only aborted state.incoming (pending requests) but left +// state.outgoing responses with socket=null alive forever. + +const common = require('../common'); +const http = require('http'); +const net = require('net'); +const assert = require('assert'); + +let requestCount = 0; + +const server = http.createServer(common.mustCall((req, res) => { + requestCount++; + + if (requestCount === 1) { + // Keep the first response open so the second response is queued in + // state.outgoing with socket === null. + res.writeHead(200); + res.write('start'); + // Intentionally do not call res.end(). + } else { + // The second response should be queued — no socket assigned yet. + assert.strictEqual(res.socket, null); + assert.strictEqual(res.destroyed, false); + assert.strictEqual(res.closed, false); + + res.on('close', common.mustCall(() => { + assert.strictEqual(res.destroyed, true); + assert.strictEqual(res.closed, true); + server.close(); + })); + + // Simulate client dying while first response is still in flight. + req.socket.destroy(); + } +}, 2)); + +server.listen(0, common.mustCall(function() { + const port = this.address().port; + const client = net.connect(port); + + // Send two pipelined HTTP/1.1 requests at once. + client.write( + `GET /1 HTTP/1.1\r\nHost: localhost:${port}\r\n\r\n` + + `GET /2 HTTP/1.1\r\nHost: localhost:${port}\r\n\r\n`, + ); + client.resume(); +}));