diff --git a/lib/request.js b/lib/request.js index 26edc0981..4aee9784d 100755 --- a/lib/request.js +++ b/lib/request.js @@ -448,21 +448,22 @@ exports = module.exports = internals.Request = class { this._setResponse(this._core.Response.wrap(exit, this)); // Wrap to ensure any object thrown is always a valid Boom or Response object } - if (!this._eventContext.request) { - this._finalize(); - return; - } + if (!this._eventContext.request || // Client disconnected + typeof this.response === 'symbol') { // close or abandon - if (typeof this.response === 'symbol') { // close or abandon this._abort(); return; } await this._postCycle(); - if (!this._eventContext.request || + if (!this._eventContext.request || // Client disconnected during onPreResponse typeof this.response === 'symbol') { // close or abandon + if (typeof this.response !== 'symbol') { + this._setResponse(internals.disconnectError(this)); + } + this._abort(); return; } @@ -496,7 +497,11 @@ exports = module.exports = internals.Request = class { _abort() { - if (this.response === Toolkit.symbols.close) { + if (this.response !== Toolkit.symbols.abandon) { + if (typeof this.response !== 'symbol') { // Client disconnected before transmission + this.raw.res.statusCode = this.response.output.statusCode; + } + this.raw.res.end(); // End the response in case it wasn't already closed } @@ -777,21 +782,26 @@ internals.event = function ({ request }, event, err) { request._eventContext.request = null; - if (event === 'abort') { + // Request 'aborted' or response 'close' before the response was sent: the client disconnected. - // 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 - // handling the abort. + // Calling _reply() means that the disconnect is applied immediately, unless the response has already + // called _reply(), in which case this call is ignored and the reply / transmit logic is responsible for + // handling the disconnect. - request._reply(new Boom.Boom('Request aborted', { statusCode: request.route.settings.response.disconnectStatusCode, data: request.response })); + request._reply(internals.disconnectError(request)); - if (request._events) { - request._events.emit('disconnect'); - } + if (request._events) { + request._events.emit('disconnect'); } }; +internals.disconnectError = function (request) { + + return new Boom.Boom('Request aborted', { statusCode: request.route.settings.response.disconnectStatusCode, data: request.response }); +}; + + internals.timeoutReply = function (request, timeout) { const elapsed = Date.now() - request.info.received; 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..82feb421e 100755 --- a/test/request.js +++ b/test/request.js @@ -834,6 +834,180 @@ describe('Request', () => { await server.stop(); }); + it('ends the response when the client disconnects during the handler (aborted)', async (flags) => { + + const server = Hapi.server(); + const team = new Teamwork.Team(); + + server.route({ + method: 'GET', + path: '/', + handler: async (request) => { + + clientRequest.destroy(); + await team.work; + return 'ok'; + } + }); + + let ended = null; + server.ext('onRequest', (request, h) => { + + const res = request.raw.res; + const end = res.end; + res.end = function (...args) { + + ended = res.statusCode; + return end.apply(this, args); + }; + + return h.continue; + }); + + let disconnected = false; + server.ext('onPreHandler', (request, h) => { + + request.events.once('disconnect', () => { + + disconnected = true; + }); + + return h.continue; + }); + + flags.onCleanup = () => server.stop(); + await server.start(); + + const clientRequest = Http.request({ hostname: 'localhost', port: server.info.port, method: 'GET' }); + clientRequest.on('error', Hoek.ignore); + clientRequest.end(); + + const [request] = await server.events.once('response'); + team.attend(); // Response is reported before the handler completes + + expect(disconnected).to.be.true(); + expect(request.response.isBoom).to.be.true(); + expect(request.response.message).to.equal('Request aborted'); + expect(request.response.output.statusCode).to.equal(499); + expect(ended).to.equal(499); + expect(request.raw.res.writableEnded).to.be.true(); + }); + + it('ends the response when the client disconnects during the handler (close after payload)', async (flags) => { + + const server = Hapi.server(); + const team = new Teamwork.Team(); + + server.route({ + method: 'POST', + path: '/', + options: { + response: { disconnectStatusCode: 444 }, + handler: async (request) => { + + clientRequest.destroy(); + await team.work; + return 'ok'; + } + } + }); + + let ended = null; + server.ext('onRequest', (request, h) => { + + const res = request.raw.res; + const end = res.end; + res.end = function (...args) { + + ended = res.statusCode; + return end.apply(this, args); + }; + + return h.continue; + }); + + let disconnected = false; + server.ext('onPreHandler', (request, h) => { + + request.events.once('disconnect', () => { + + disconnected = true; + }); + + return h.continue; + }); + + let postHandler = 0; + server.ext('onPostHandler', (request, h) => { + + ++postHandler; + return h.continue; + }); + + flags.onCleanup = () => server.stop(); + await server.start(); + + const clientRequest = Http.request({ hostname: 'localhost', port: server.info.port, method: 'POST', headers: { 'content-type': 'application/json' } }); + clientRequest.on('error', Hoek.ignore); + clientRequest.end('{"a":1}'); + + const [request] = await server.events.once('response'); + team.attend(); // Response is reported before the handler completes + + expect(request.payload).to.equal({ a: 1 }); // Payload was fully received (no 'aborted' event) + expect(disconnected).to.be.true(); + expect(request.response.isBoom).to.be.true(); + expect(request.response.output.statusCode).to.equal(444); + expect(ended).to.equal(444); + expect(request.raw.res.writableEnded).to.be.true(); + + await Hoek.wait(10); + expect(postHandler).to.equal(0); + }); + + it('reports disconnect when the client disconnects during onPreResponse', async (flags) => { + + const server = Hapi.server(); + server.route({ method: 'GET', path: '/', handler: () => 'ok' }); + + let ended = null; + server.ext('onRequest', (request, h) => { + + const res = request.raw.res; + const end = res.end; + res.end = function (...args) { + + ended = res.statusCode; + return end.apply(this, args); + }; + + return h.continue; + }); + + server.ext('onPreResponse', async (request, h) => { + + const disconnect = request.events.once('disconnect'); + clientRequest.destroy(); + await disconnect; + return h.continue; + }); + + flags.onCleanup = () => server.stop(); + await server.start(); + + const clientRequest = Http.request({ hostname: 'localhost', port: server.info.port, method: 'GET' }); + clientRequest.on('error', Hoek.ignore); + clientRequest.end(); + + const [request] = await server.events.once('response'); + + expect(request.response.isBoom).to.be.true(); + expect(request.response.output.statusCode).to.equal(499); + expect(request.response.data.source).to.equal('ok'); + expect(ended).to.equal(499); + expect(request.info.responded).to.equal(0); + }); + it('returns not found on internal only route (external)', async () => { const server = Hapi.server();