Skip to content

Commit dcbb130

Browse files
author
Archkon
committed
http: defer write errors for pending responses
A transport write error can be delivered before a readable event from the same poll cycle. Writable error handling then destroys both sides of the socket before the HTTP parser can consume an already-sent response. Defer native write errors that do not carry protocol-specific details. After pending reads run, suppress the error only when the request write and response parse are both complete. Continue reporting open writes, truncated responses, user destroy errors, and TLS protocol errors. Follow-up to: #64507 Original PR Refs: #64278 Fixes: #64272 Refs:#64511 Refs: libuv/libuv#5196 Refs: #64507 (comment) Refs: #64511 (comment) Signed-off-by: Archkon <180910180+Archkon@users.noreply.github.com>
1 parent d82019e commit dcbb130

6 files changed

Lines changed: 213 additions & 2 deletions

lib/_http_client.js

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ function emitErrorEvent(request, error) {
116116
}
117117

118118
const { addAbortSignal, finished } = require('stream');
119+
const { setImmediate } = require('timers');
120+
const { kBeforeWriteError } = require('internal/streams/utils');
119121

120122
let debug = require('internal/util/debuglog').debuglog('http', (fn) => {
121123
debug = fn;
@@ -767,10 +769,23 @@ function socketErrorListener(err) {
767769
debug('SOCKET ERROR:', err.message, err.stack);
768770

769771
if (req) {
772+
const res = req.res;
773+
const exchangeComplete = req.writableFinished && res?.complete;
774+
const isUserDestroyError = err === req[kError] || err === res?.errored;
775+
770776
// For Safety. Some additional errors might fire later on
771777
// and we need to make sure we don't double-fire the error event.
772778
socket._hadError = true;
773-
emitErrorEvent(req, err);
779+
// Once both messages are complete, a transport error cannot change the
780+
// result of the exchange. User-provided destroy errors are always emitted.
781+
if (!exchangeComplete || isUserDestroyError) {
782+
emitErrorEvent(req, err);
783+
}
784+
785+
if (res) {
786+
socket.destroy();
787+
return;
788+
}
774789
}
775790

776791
const parser = socket.parser;
@@ -785,6 +800,21 @@ function socketErrorListener(err) {
785800
socket.destroy();
786801
}
787802

803+
function deferSocketWriteError(socket, err, continueWriteError) {
804+
const req = socket._httpMessage;
805+
806+
if (!req || req.destroyed || socket.destroyed || !socket.parser ||
807+
err.syscall !== 'write' ||
808+
req.res?.complete || err === req[kError] || err === req.res?.errored) {
809+
return false;
810+
}
811+
812+
// Let readable events from the current poll cycle reach the HTTP parser
813+
// before the write error tears down both sides of the socket.
814+
setImmediate(continueWriteError);
815+
return true;
816+
}
817+
788818
function socketOnEnd() {
789819
const socket = this;
790820
const req = this._httpMessage;
@@ -1100,6 +1130,7 @@ function tickOnSocket(req, socket) {
11001130

11011131
socket.parser = parser;
11021132
socket._httpMessage = req;
1133+
socket[kBeforeWriteError] = deferSocketWriteError;
11031134

11041135
// Propagate headers limit from request object to parser
11051136
if (typeof req.maxHeadersCount === 'number') {

lib/internal/stream_base_commons.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const {
2626
getTimerDuration,
2727
} = require('internal/timers');
2828
const { isUint8Array } = require('internal/util/types');
29+
const { kBeforeWriteError } = require('internal/streams/utils');
2930
const { clearTimeout } = require('timers');
3031
const { validateFunction } = require('internal/validators');
3132

@@ -86,7 +87,14 @@ function onWriteComplete(status) {
8687
if (status < 0) {
8788
const error = new ErrnoException(status, 'write', this.error);
8889
if (typeof this.callback === 'function') {
89-
return this.callback(error);
90+
const callback = this.callback;
91+
const beforeWriteError = stream[kBeforeWriteError];
92+
if (this.error === undefined &&
93+
beforeWriteError?.(stream, error, () => callback(error))) {
94+
return;
95+
}
96+
97+
return callback(error);
9098
}
9199

92100
return stream.destroy(error);

lib/internal/streams/utils.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const kIsWritable = SymbolFor('nodejs.stream.writable');
1818
const kIsDisturbed = SymbolFor('nodejs.stream.disturbed');
1919

2020
const kOnConstructed = Symbol('kOnConstructed');
21+
const kBeforeWriteError = Symbol('kBeforeWriteError');
2122

2223
const kIsClosedPromise = SymbolFor('nodejs.webstream.isClosedPromise');
2324
const kControllerErrorFunction = SymbolFor('nodejs.webstream.controllerErrorFunction');
@@ -316,6 +317,7 @@ function isErrored(stream) {
316317
}
317318

318319
module.exports = {
320+
kBeforeWriteError,
319321
kOnConstructed,
320322
isDestroyed,
321323
kIsDestroyed,
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
const assert = require('assert');
5+
const http = require('http');
6+
7+
const BODY = Buffer.alloc(1024 * 1024);
8+
9+
const server = http.createServer(common.mustCall((request, response) => {
10+
response.writeHead(202, { 'content-length': 0 });
11+
response.end();
12+
13+
request.once('data', common.mustCall(() => {
14+
request.socket.resetAndDestroy();
15+
}));
16+
}));
17+
18+
server.on('clientError', common.mustNotCall());
19+
20+
server.listen(0, common.mustCall(() => {
21+
let response;
22+
const req = http.request({
23+
method: 'POST',
24+
port: server.address().port,
25+
headers: { 'content-length': BODY.length },
26+
}, common.mustCall((res) => {
27+
response = res;
28+
assert.strictEqual(res.statusCode, 202);
29+
assert.strictEqual(req.writableEnded, false);
30+
req.write(BODY);
31+
}));
32+
33+
req.on('error', common.mustCall((err) => {
34+
assert(response);
35+
assert.strictEqual(response.complete, true);
36+
assert.strictEqual(req.writableFinished, false);
37+
switch (err.code) {
38+
case 'ECONNRESET':
39+
case 'ECONNABORTED':
40+
case 'EPIPE':
41+
break;
42+
default:
43+
assert.fail(`Unexpected error code ${err.code}`);
44+
}
45+
}));
46+
47+
req.on('close', common.mustCall(() => server.close()));
48+
req.flushHeaders();
49+
}));
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
const assert = require('assert');
5+
const http = require('http');
6+
7+
const MAX_BODY_LENGTH = 1024 * 1024;
8+
const BODY = JSON.stringify({
9+
a: 'x'.repeat(512 * 1024),
10+
b: 'x'.repeat(512 * 1024),
11+
c: 'x'.repeat(512 * 1024),
12+
d: 'x'.repeat(512 * 1024),
13+
}, null, '\t');
14+
15+
const server = http.createServer(common.mustCall((request, response) => {
16+
let received = 0;
17+
18+
request.setEncoding('utf8');
19+
request.on('data', function onData(chunk) {
20+
received += chunk.length;
21+
if (received > MAX_BODY_LENGTH) {
22+
response.writeHead(413, { 'content-length': 0 });
23+
response.end();
24+
request.off('data', onData);
25+
request.destroy();
26+
}
27+
});
28+
29+
request.on('end', common.mustNotCall());
30+
request.on('close', common.mustCall(() => {
31+
assert.ok(received > MAX_BODY_LENGTH);
32+
assert.strictEqual(request.complete, false);
33+
}));
34+
}));
35+
36+
server.on('clientError', common.mustNotCall());
37+
38+
server.listen(0, common.mustCall(() => {
39+
let response;
40+
const req = http.request({
41+
method: 'POST',
42+
port: server.address().port,
43+
headers: {
44+
'content-type': 'application/json',
45+
},
46+
}, common.mustCall((res) => {
47+
response = res;
48+
assert.strictEqual(res.statusCode, 413);
49+
assert.strictEqual(req.writableEnded, true);
50+
}));
51+
52+
req.on('error', common.mustNotCall());
53+
req.on('close', common.mustCall(() => {
54+
assert(response);
55+
assert.strictEqual(req.writableFinished, true);
56+
assert.strictEqual(response.complete, true);
57+
server.close();
58+
}));
59+
60+
// Preserve the ordering from the reported regression: the request body is
61+
// written and ended before the response is received.
62+
req.write(BODY);
63+
req.end();
64+
}));
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
const assert = require('assert');
5+
const http = require('http');
6+
7+
// A response that is cut short by a connection reset must still surface the
8+
// socket error on the request. The response exists, but it is not complete, so
9+
// swallowing the error would leave the application with a silently truncated
10+
// body it believes is intact.
11+
12+
const LENGTH = 1024;
13+
14+
let serverSocket;
15+
16+
const server = http.createServer(common.mustCall((request, response) => {
17+
serverSocket = request.socket;
18+
// Promise more body than is ever sent.
19+
response.writeHead(200, { 'content-length': LENGTH });
20+
response.write('hello');
21+
}));
22+
23+
server.on('clientError', common.mustNotCall());
24+
25+
server.listen(0, common.mustCall(() => {
26+
const req = http.request({ port: server.address().port }, common.mustCall((res) => {
27+
assert.strictEqual(res.statusCode, 200);
28+
29+
let received = 0;
30+
let reset = false;
31+
32+
res.on('data', common.mustCallAtLeast((chunk) => {
33+
received += chunk.length;
34+
if (reset) return;
35+
reset = true;
36+
// The headers and part of the body have arrived. Reset from the server
37+
// side so the client sees a genuine inbound RST mid-body.
38+
serverSocket.resetAndDestroy();
39+
}, 1));
40+
41+
res.on('end', common.mustNotCall());
42+
res.on('close', common.mustCall(() => {
43+
assert.strictEqual(res.complete, false);
44+
assert.strictEqual(res.errored.code, 'ECONNRESET');
45+
assert.strictEqual(res.errored.message, 'aborted');
46+
assert.ok(received > 0 && received < LENGTH,
47+
`expected a truncated body, got ${received} of ${LENGTH}`);
48+
server.close();
49+
}));
50+
}));
51+
52+
req.on('error', common.mustCall((err) => {
53+
assert.strictEqual(err.code, 'ECONNRESET');
54+
}));
55+
56+
req.end();
57+
}));

0 commit comments

Comments
 (0)