diff --git a/lib/request.js b/lib/request.js index 2c40cdc98..a45899d3e 100755 --- a/lib/request.js +++ b/lib/request.js @@ -325,7 +325,13 @@ exports = module.exports = internals.Request = class { this.raw.res.on('close', internals.event.bind(this.raw.res, this._eventContext, 'close')); this.raw.req.on('error', internals.event.bind(this.raw.req, this._eventContext, 'error')); + + // 'aborted' was deprecated in Node.js v17 and removed in v24. It remains here for + // compatibility with older Node.js versions where it is the most reliable abort signal. + // For Node.js v24+, where 'aborted' no longer fires, the 'close' event on the response + // (handled in internals.event) serves as the fallback abort indicator. this.raw.req.on('aborted', internals.event.bind(this.raw.req, this._eventContext, 'abort')); + this.raw.res.once('close', internals.closed.bind(this.raw.res, this)); } @@ -713,6 +719,8 @@ internals.closed = function (request) { request._closed = true; }; + + internals.event = function ({ request }, event, err) { if (!request) { @@ -739,7 +747,12 @@ internals.event = function ({ request }, event, err) { request._eventContext.request = null; - if (event === 'abort') { + // On Node.js v24+, 'aborted' was removed. A 'close' on the response before writableEnded + // means the client disconnected mid-request — treat it as an abort. We exclude @hapi/shot + // inject requests (identified by req._shot) because shot fires req.emit('close') for its + // simulate.close scenario but that should not be treated as a client abort. + if (event === 'abort' || + (event === 'close' && !request.raw.res.writableEnded && !request.raw.req._shot)) { // Calling _reply() means that the abort is applied immediately, unless the response has already // called _reply(), in which case this call is ignored and the transmit logic is responsible for diff --git a/test/request.js b/test/request.js index d2f1760e8..de68ad856 100755 --- a/test/request.js +++ b/test/request.js @@ -474,6 +474,56 @@ describe('Request', () => { testComplete: false }); }); + + it('returns false after client closes connection before response is sent', { retry: true }, async (flags) => { + + // Regression test: IncomingMessage 'aborted' event was removed in Node.js v24. + // Verify that an early client disconnect still causes active() to return false. + + const handlerTeam = new Teamwork.Team(); + + const server = Hapi.server(); + flags.onCleanup = () => server.stop(); + + let client; + + server.route({ + method: 'GET', + path: '/', + options: { + handler: async (request) => { + + // Drop the connection from the client side + client.destroy(); + + // Poll until the server-side close propagates + const deadline = Date.now() + 2000; + while (request.active() && Date.now() < deadline) { + await Hoek.wait(10); + } + + handlerTeam.attend({ active: request.active() }); + return null; + } + } + }); + + await server.start(); + + await new Promise((resolve) => { + + client = Net.connect(server.info.port, () => { + + client.write('GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n'); + resolve(); + }); + + client.on('error', Hoek.ignore); + }); + + const result = await handlerTeam.work; + expect(result.active).to.be.false(); + }); }); describe('_execute()', () => {