Skip to content

Commit ee54d9d

Browse files
Archkonaduh95
authored andcommitted
http: avoid aborting IncomingMessage signal on normal close
IncomingMessage 'close' is emitted when request handling completes, not only when the underlying connection is closed. Using that event to abort IncomingMessage.signal makes the signal abort after a request body is read normally. Track the underlying socket close instead, and detach the listener when the request or response completes normally. This keeps the signal from being aborted by normal stream completion or later keep-alive socket closure, while still aborting it when the connection closes during the active request lifecycle. Signed-off-by: Archkon <180910180+Archkon@users.noreply.github.com> PR-URL: #64392 Fixes: #64390 Reviewed-By: Ethan Arrowood <ethan@arrowood.dev> Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
1 parent b54aa83 commit ee54d9d

5 files changed

Lines changed: 153 additions & 13 deletions

File tree

doc/api/http.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3010,13 +3010,20 @@ Calls `message.socket.setTimeout(msecs, callback)`.
30103010

30113011
<!-- YAML
30123012
added: v24.16.0
3013+
changes:
3014+
- version: REPLACEME
3015+
pr-url: https://github.com/nodejs/node/pull/64392
3016+
description: The signal is no longer aborted after the message
3017+
completes normally.
30133018
-->
30143019

30153020
* Type: {AbortSignal}
30163021

3017-
An {AbortSignal} that is aborted when the underlying socket closes or the
3018-
request is destroyed. The signal is created lazily on first access — no
3019-
{AbortController} is allocated for requests that never use this property.
3022+
An {AbortSignal} that is aborted when the message is destroyed before
3023+
completion or when its underlying socket closes before request handling or
3024+
response reading completes.
3025+
The signal is created lazily on first access — no {AbortController} is allocated
3026+
for requests that never use this property.
30203027

30213028
This is useful for cancelling downstream asynchronous work such as database
30223029
queries or `fetch` calls when a client disconnects mid-request.

lib/_http_client.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ const {
5050
prepareError,
5151
kSkipPendingData,
5252
} = require('_http_common');
53+
const { kDetachAbortSignal } = require('_http_incoming');
5354
const {
5455
kHighWaterMark,
5556
kUniqueHeaders,
@@ -1017,6 +1018,8 @@ function responseOnEnd() {
10171018
const req = this.req;
10181019
const socket = req.socket;
10191020

1021+
this[kDetachAbortSignal]();
1022+
10201023
if (socket) {
10211024
if (req.timeoutCb) socket.removeListener('timeout', emitRequestTimeout);
10221025
socket.removeListener('timeout', responseOnTimeout);

lib/_http_incoming.js

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ const kTrailers = Symbol('kTrailers');
3838
const kTrailersDistinct = Symbol('kTrailersDistinct');
3939
const kTrailersCount = Symbol('kTrailersCount');
4040
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');
4146

4247
function readStart(socket) {
4348
if (socket && !socket._paused && socket.readable)
@@ -94,6 +99,9 @@ function IncomingMessage(socket) {
9499
// read by the user, so there's no point continuing to handle it.
95100
this._dumped = false;
96101
this[kAbortController] = null;
102+
this[kAbortSignalSocket] = null;
103+
this[kAbortSignalListener] = null;
104+
this[kAbortSignalDetached] = false;
97105
}
98106
ObjectSetPrototypeOf(IncomingMessage.prototype, Readable.prototype);
99107
ObjectSetPrototypeOf(IncomingMessage, Readable);
@@ -195,18 +203,51 @@ ObjectDefineProperty(IncomingMessage.prototype, 'signal', {
195203
if (this[kAbortController] === null) {
196204
const ac = new AbortController();
197205
this[kAbortController] = ac;
198-
if (this.destroyed) {
206+
if (this.destroyed && (!this.readableEnded || !this.complete)) {
199207
ac.abort();
200208
} else {
201-
this.once('close', function() {
202-
ac.abort();
203-
});
209+
this[kAttachAbortSignal]();
204210
}
205211
}
206212
return this[kAbortController].signal;
207213
},
208214
});
209215

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+
210251
IncomingMessage.prototype.setTimeout = function setTimeout(msecs, callback) {
211252
if (callback)
212253
this.on('timeout', callback);
@@ -234,6 +275,7 @@ IncomingMessage.prototype._destroy = function _destroy(err, cb) {
234275
if (!this.readableEnded || !this.complete) {
235276
this.aborted = true;
236277
this.emit('aborted');
278+
abortSignal(this);
237279
}
238280

239281
// If aborted and the underlying socket is not already destroyed,
@@ -255,6 +297,13 @@ IncomingMessage.prototype._destroy = function _destroy(err, cb) {
255297
}
256298
};
257299

300+
function abortSignal(self) {
301+
self[kDetachAbortSignal]();
302+
if (self[kAbortController] !== null) {
303+
self[kAbortController].abort();
304+
}
305+
}
306+
258307
IncomingMessage.prototype._addHeaderLines = _addHeaderLines;
259308
function _addHeaderLines(headers, n) {
260309
if (headers?.length) {
@@ -472,6 +521,7 @@ function onError(self, error, cb) {
472521

473522
module.exports = {
474523
IncomingMessage,
524+
kDetachAbortSignal,
475525
readStart,
476526
readStop,
477527
};

lib/_http_server.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,10 @@ const {
6868
defaultTriggerAsyncIdScope,
6969
getOrSetAsyncId,
7070
} = require('internal/async_hooks');
71-
const { IncomingMessage } = require('_http_incoming');
71+
const {
72+
IncomingMessage,
73+
kDetachAbortSignal,
74+
} = require('_http_incoming');
7275
const {
7376
ConnResetException,
7477
codes: {
@@ -1105,6 +1108,7 @@ function resOnFinish(req, res, socket, state, server) {
11051108
// array will be empty.
11061109
assert(state.incoming.length === 0 || state.incoming[0] === req);
11071110

1111+
req[kDetachAbortSignal]();
11081112
state.incoming.shift();
11091113

11101114
// If the user never called req.read(), and didn't pipe() or

test/parallel/test-http-request-signal.js

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const common = require('../common');
44
const assert = require('assert');
55
const http = require('http');
66

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
88
{
99
const server = http.createServer(common.mustCall((req, res) => {
1010
assert.ok(req.signal instanceof AbortSignal);
@@ -21,21 +21,68 @@ const http = require('http');
2121
}));
2222
}
2323

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
2572
{
2673
const req = new http.IncomingMessage(null);
2774
req.destroy();
2875
assert.strictEqual(req.signal.aborted, true);
2976
}
3077

31-
// Test 3: Multiple accesses return the same signal
78+
// Test 4: Multiple accesses return the same signal
3279
{
3380
const req = new http.IncomingMessage(null);
3481
assert.strictEqual(req.signal, req.signal);
3582
}
3683

3784

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).
3986
{
4087
const server = http.createServer(common.mustCall((req, res) => {
4188
res.writeHead(200);
@@ -61,7 +108,36 @@ const http = require('http');
61108
}));
62109
}
63110

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.
65141
{
66142
const server = http.createServer(common.mustCall((req, res) => {
67143
req.signal.onabort = common.mustCall(() => {

0 commit comments

Comments
 (0)