diff --git a/lib/request.js b/lib/request.js index 2c40cdc98..da542398f 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,13 @@ internals.event = function ({ request }, event, err) { request._eventContext.request = null; - if (event === 'abort') { + // Treat as abort when: (a) the IncomingMessage fired 'aborted' (Node.js < v24), or (b) the + // response closed before writableEnded (guaranteed above) — the Node.js v24+ signal. + // The false branch is structurally unreachable: after all early returns above, only 'abort' + // and 'close' (with !writableEnded) events reach this point, so the condition is always true. + // $lab:coverage:off$ + if (event === 'abort' || event === 'close') { + // $lab:coverage:on$ // 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/payload.js b/test/payload.js index 93b045cdf..54b21771d 100755 --- a/test/payload.js +++ b/test/payload.js @@ -76,7 +76,7 @@ describe('Payload', () => { server.inject({ method: 'POST', url: '/', payload: 'test', simulate: { close: true, end: false } }); const request = await responded; expect(request._isReplied).to.equal(true); - expect(request.response.output.statusCode).to.equal(500); + expect(request.response.output.statusCode).to.equal(499); }); it('handles aborted request mid-lifecycle step', async (flags) => { 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()', () => {