GeezerVison is showing github.com — http: avoid aborting IncomingMessage signal on normal close · nodejs/node@dfc192f
File treeExpand file treeCollapse file tree Open diff view settings Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: doc/api/http.md +10-3Lines changed: 10 additions & 3 deletions - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
@@ -3031,13 +3031,20 @@ Calls `message.socket.setTimeout(msecs, callback)`. | 3031 | 3031 |
| 3032 | 3032 | <!-- YAML | 3033 | 3033 | added: v26.1.0 | | 3034 | +changes: | | 3035 | + - version: REPLACEME | | 3036 | + pr-url: https://github.com/nodejs/node/pull/64392 | | 3037 | + description: The signal is no longer aborted after the message | | 3038 | + completes normally. | 3034 | 3039 | --> | 3035 | 3040 |
| 3036 | 3041 | * Type: {AbortSignal} | 3037 | 3042 |
| 3038 | | -An {AbortSignal} that is aborted when the underlying socket closes or the | 3039 | | -request is destroyed. The signal is created lazily on first access — no | 3040 | | -{AbortController} is allocated for requests that never use this property. | | 3043 | +An {AbortSignal} that is aborted when the message is destroyed before | | 3044 | +completion or when its underlying socket closes before request handling or | | 3045 | +response reading completes. | | 3046 | +The signal is created lazily on first access — no {AbortController} is allocated | | 3047 | +for requests that never use this property. | 3041 | 3048 |
| 3042 | 3049 | This is useful for cancelling downstream asynchronous work such as database | 3043 | 3050 | queries or `fetch` calls when a client disconnects mid-request. | |
Collapse file Copy file name to clipboardExpand all lines: lib/_http_client.js +3Lines changed: 3 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -50,6 +50,7 @@ const { | 50 | 50 | prepareError, | 51 | 51 | kSkipPendingData, | 52 | 52 | } = require('_http_common'); | | 53 | +const { kDetachAbortSignal } = require('_http_incoming'); | 53 | 54 | const { | 54 | 55 | kHighWaterMark, | 55 | 56 | kUniqueHeaders, | @@ -1019,6 +1020,8 @@ function responseOnEnd() { | 1019 | 1020 | const req = this.req; | 1020 | 1021 | const socket = req.socket; | 1021 | 1022 |
| | 1023 | + this[kDetachAbortSignal](); | | 1024 | + | 1022 | 1025 | if (socket) { | 1023 | 1026 | if (req.timeoutCb) socket.removeListener('timeout', emitRequestTimeout); | 1024 | 1027 | socket.removeListener('timeout', responseOnTimeout); | |
Collapse file Copy file name to clipboardExpand all lines: lib/_http_incoming.js +54-4Lines changed: 54 additions & 4 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -38,6 +38,11 @@ const kTrailers = Symbol('kTrailers'); | 38 | 38 | const kTrailersDistinct = Symbol('kTrailersDistinct'); | 39 | 39 | const kTrailersCount = Symbol('kTrailersCount'); | 40 | 40 | const kAbortController = Symbol('kAbortController'); | | 41 | +const kAbortSignalSocket = Symbol('kAbortSignalSocket'); | | 42 | +const kAbortSignalListener = Symbol('kAbortSignalListener'); | | 43 | +const kAbortSignalDetached = Symbol('kAbortSignalDetached'); | | 44 | +const kAttachAbortSignal = Symbol('kAttachAbortSignal'); | | 45 | +const kDetachAbortSignal = Symbol('kDetachAbortSignal'); | 41 | 46 |
| 42 | 47 | function readStart(socket) { | 43 | 48 | if (socket && !socket._paused && socket.readable) | @@ -94,6 +99,9 @@ function IncomingMessage(socket) { | 94 | 99 | // read by the user, so there's no point continuing to handle it. | 95 | 100 | this._dumped = false; | 96 | 101 | this[kAbortController] = null; | | 102 | + this[kAbortSignalSocket] = null; | | 103 | + this[kAbortSignalListener] = null; | | 104 | + this[kAbortSignalDetached] = false; | 97 | 105 | } | 98 | 106 | ObjectSetPrototypeOf(IncomingMessage.prototype, Readable.prototype); | 99 | 107 | ObjectSetPrototypeOf(IncomingMessage, Readable); | @@ -195,18 +203,51 @@ ObjectDefineProperty(IncomingMessage.prototype, 'signal', { | 195 | 203 | if (this[kAbortController] === null) { | 196 | 204 | const ac = new AbortController(); | 197 | 205 | this[kAbortController] = ac; | 198 | | - if (this.destroyed) { | | 206 | + if (this.destroyed && (!this.readableEnded || !this.complete)) { | 199 | 207 | ac.abort(); | 200 | 208 | } else { | 201 | | - this.once('close', function() { | 202 | | - ac.abort(); | 203 | | - }); | | 209 | + this[kAttachAbortSignal](); | 204 | 210 | } | 205 | 211 | } | 206 | 212 | return this[kAbortController].signal; | 207 | 213 | }, | 208 | 214 | }); | 209 | 215 |
| | 216 | +IncomingMessage.prototype[kAttachAbortSignal] = function() { | | 217 | + if (this[kAbortController].signal.aborted || | | 218 | + this[kAbortSignalDetached] || | | 219 | + this[kAbortSignalListener] !== null) { | | 220 | + return; | | 221 | + } | | 222 | + | | 223 | + const socket = this.socket; | | 224 | + if (!socket) { | | 225 | + return; | | 226 | + } | | 227 | + | | 228 | + if (socket.destroyed) { | | 229 | + abortSignal(this); | | 230 | + return; | | 231 | + } | | 232 | + | | 233 | + this[kAbortSignalSocket] = socket; | | 234 | + this[kAbortSignalListener] = () => { | | 235 | + abortSignal(this); | | 236 | + }; | | 237 | + socket.once('close', this[kAbortSignalListener]); | | 238 | +}; | | 239 | + | | 240 | +IncomingMessage.prototype[kDetachAbortSignal] = function() { | | 241 | + const socket = this[kAbortSignalSocket]; | | 242 | + const listener = this[kAbortSignalListener]; | | 243 | + this[kAbortSignalDetached] = true; | | 244 | + this[kAbortSignalSocket] = null; | | 245 | + this[kAbortSignalListener] = null; | | 246 | + if (socket !== null && listener !== null) { | | 247 | + socket.removeListener('close', listener); | | 248 | + } | | 249 | +}; | | 250 | + | 210 | 251 | IncomingMessage.prototype.setTimeout = function setTimeout(msecs, callback) { | 211 | 252 | if (callback) | 212 | 253 | this.on('timeout', callback); | @@ -234,6 +275,7 @@ IncomingMessage.prototype._destroy = function _destroy(err, cb) { | 234 | 275 | if (!this.readableEnded || !this.complete) { | 235 | 276 | this.aborted = true; | 236 | 277 | this.emit('aborted'); | | 278 | + abortSignal(this); | 237 | 279 | } | 238 | 280 |
| 239 | 281 | // If aborted and the underlying socket is not already destroyed, | @@ -255,6 +297,13 @@ IncomingMessage.prototype._destroy = function _destroy(err, cb) { | 255 | 297 | } | 256 | 298 | }; | 257 | 299 |
| | 300 | +function abortSignal(self) { | | 301 | + self[kDetachAbortSignal](); | | 302 | + if (self[kAbortController] !== null) { | | 303 | + self[kAbortController].abort(); | | 304 | + } | | 305 | +} | | 306 | + | 258 | 307 | IncomingMessage.prototype._addHeaderLines = _addHeaderLines; | 259 | 308 | function _addHeaderLines(headers, n) { | 260 | 309 | if (headers?.length) { | @@ -472,6 +521,7 @@ function onError(self, error, cb) { | 472 | 521 |
| 473 | 522 | module.exports = { | 474 | 523 | IncomingMessage, | | 524 | + kDetachAbortSignal, | 475 | 525 | readStart, | 476 | 526 | readStop, | 477 | 527 | }; |
Collapse file Copy file name to clipboardExpand all lines: lib/_http_server.js +5-1Lines changed: 5 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
@@ -70,7 +70,10 @@ const { | 70 | 70 | defaultTriggerAsyncIdScope, | 71 | 71 | getOrSetAsyncId, | 72 | 72 | } = require('internal/async_hooks'); | 73 | | -const { IncomingMessage } = require('_http_incoming'); | | 73 | +const { | | 74 | + IncomingMessage, | | 75 | + kDetachAbortSignal, | | 76 | +} = require('_http_incoming'); | 74 | 77 | const { | 75 | 78 | ConnResetException, | 76 | 79 | codes: { | @@ -1205,6 +1208,7 @@ function resOnFinish(req, res, socket, state, server) { | 1205 | 1208 | // array will be empty. | 1206 | 1209 | assert(state.incoming.length === 0 || state.incoming[0] === req); | 1207 | 1210 |
| | 1211 | + req[kDetachAbortSignal](); | 1208 | 1212 | state.incoming.shift(); | 1209 | 1213 |
| 1210 | 1214 | // If the user never called req.read(), and didn't pipe() or | |
Collapse file Copy file name to clipboardExpand all lines: test/parallel/test-http-request-signal.js +81-5Lines changed: 81 additions & 5 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -4,7 +4,7 @@ const common = require('../common'); | 4 | 4 | const assert = require('assert'); | 5 | 5 | const http = require('http'); | 6 | 6 |
| 7 | | -// Test 1: req.signal is an AbortSignal and aborts on 'close' | | 7 | +// Test 1: req.signal is an AbortSignal and aborts on socket close | 8 | 8 | { | 9 | 9 | const server = http.createServer(common.mustCall((req, res) => { | 10 | 10 | assert.ok(req.signal instanceof AbortSignal); | @@ -21,21 +21,68 @@ const http = require('http'); | 21 | 21 | })); | 22 | 22 | } | 23 | 23 |
| 24 | | -// Test 2: req.signal is aborted if accessed after destroy | | 24 | +// Test 2: req.signal is not aborted when a request body completes normally. | | 25 | +{ | | 26 | + const body = JSON.stringify({ hello: 'world' }); | | 27 | + const server = http.createServer(common.mustCall((req, res) => { | | 28 | + assert.ok(req.signal instanceof AbortSignal); | | 29 | + assert.strictEqual(req.signal.aborted, false); | | 30 | + req.signal.onabort = common.mustNotCall(); | | 31 | + | | 32 | + req.on('close', common.mustCall(() => { | | 33 | + assert.strictEqual(req.aborted, false); | | 34 | + assert.strictEqual(req.complete, true); | | 35 | + assert.strictEqual(req.signal.aborted, false); | | 36 | + })); | | 37 | + | | 38 | + req.on('end', common.mustCall(() => { | | 39 | + setTimeout(common.mustCall(() => { | | 40 | + assert.strictEqual(req.aborted, false); | | 41 | + assert.strictEqual(req.complete, true); | | 42 | + assert.strictEqual(req.signal.aborted, false); | | 43 | + res.end('ok'); | | 44 | + }), 10); | | 45 | + })); | | 46 | + req.resume(); | | 47 | + })); | | 48 | + | | 49 | + server.listen(0, common.mustCall(() => { | | 50 | + const clientReq = http.request( | | 51 | + { | | 52 | + port: server.address().port, | | 53 | + method: 'PATCH', | | 54 | + path: '/tables/1', | | 55 | + headers: { | | 56 | + 'content-type': 'application/json', | | 57 | + 'content-length': Buffer.byteLength(body), | | 58 | + }, | | 59 | + }, | | 60 | + common.mustCall((res) => { | | 61 | + res.resume(); | | 62 | + res.on('end', common.mustCall(() => { | | 63 | + server.close(); | | 64 | + })); | | 65 | + }), | | 66 | + ); | | 67 | + clientReq.end(body); | | 68 | + })); | | 69 | +} | | 70 | + | | 71 | +// Test 3: req.signal is aborted if accessed after destroy | 25 | 72 | { | 26 | 73 | const req = new http.IncomingMessage(null); | 27 | 74 | req.destroy(); | 28 | 75 | assert.strictEqual(req.signal.aborted, true); | 29 | 76 | } | 30 | 77 |
| 31 | | -// Test 3: Multiple accesses return the same signal | | 78 | +// Test 4: Multiple accesses return the same signal | 32 | 79 | { | 33 | 80 | const req = new http.IncomingMessage(null); | 34 | 81 | assert.strictEqual(req.signal, req.signal); | 35 | 82 | } | 36 | 83 |
| 37 | 84 |
| 38 | | -// Test 4: res.signal on a client-side http.request() response (IncomingMessage). | | 85 | +// Test 5: res.signal on a client-side http.request() response (IncomingMessage). | 39 | 86 | { | 40 | 87 | const server = http.createServer(common.mustCall((req, res) => { | 41 | 88 | res.writeHead(200); | @@ -61,7 +108,36 @@ const http = require('http'); | 61 | 108 | })); | 62 | 109 | } | 63 | 110 |
| 64 | | -// Test 5: Client cancels a pending request. | | 111 | +// Test 6: res.signal is not aborted when a response body completes normally. | | 112 | +{ | | 113 | + const server = http.createServer(common.mustCall((req, res) => { | | 114 | + res.end('ok'); | | 115 | + })); | | 116 | + | | 117 | + server.listen(0, common.mustCall(() => { | | 118 | + const clientReq = http.request( | | 119 | + { port: server.address().port }, | | 120 | + common.mustCall((res) => { | | 121 | + assert.ok(res.signal instanceof AbortSignal); | | 122 | + assert.strictEqual(res.signal.aborted, false); | | 123 | + res.signal.onabort = common.mustNotCall(); | | 124 | + | | 125 | + res.resume(); | | 126 | + res.on('end', common.mustCall(() => { | | 127 | + assert.strictEqual(res.complete, true); | | 128 | + assert.strictEqual(res.signal.aborted, false); | | 129 | + })); | | 130 | + res.on('close', common.mustCall(() => { | | 131 | + assert.strictEqual(res.signal.aborted, false); | | 132 | + server.close(); | | 133 | + })); | | 134 | + }), | | 135 | + ); | | 136 | + clientReq.end(); | | 137 | + })); | | 138 | +} | | 139 | + | | 140 | +// Test 7: Client cancels a pending request. | 65 | 141 | { | 66 | 142 | const server = http.createServer(common.mustCall((req, res) => { | 67 | 143 | req.signal.onabort = common.mustCall(() => { | |
|