Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 51 additions & 13 deletions lib/_http_outgoing.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ const kChunkedLength = Symbol('kChunkedLength');
const kUniqueHeaders = Symbol('kUniqueHeaders');
const kBytesWritten = Symbol('kBytesWritten');
const kErrored = Symbol('errored');
const kWritableFinished = Symbol('kWritableFinished');
const kEndCallbacks = Symbol('kEndCallbacks');
const kFlushError = Symbol('kFlushError');
const kHighWaterMark = Symbol('kHighWaterMark');
const kRejectNonStandardBodyWrites = Symbol('kRejectNonStandardBodyWrites');

Expand Down Expand Up @@ -153,6 +156,9 @@ function OutgoingMessage(options) {
this._onPendingData = nop;

this[kErrored] = null;
this[kWritableFinished] = false;
this[kEndCallbacks] = null;
this[kFlushError] = null;
this[kHighWaterMark] = options?.highWaterMark ?? getDefaultHighWaterMark();
this[kRejectNonStandardBodyWrites] = options?.rejectNonStandardBodyWrites ?? false;
}
Expand Down Expand Up @@ -203,11 +209,7 @@ ObjectDefineProperty(OutgoingMessage.prototype, 'closed', {
ObjectDefineProperty(OutgoingMessage.prototype, 'writableFinished', {
__proto__: null,
get() {
return (
this.finished &&
this.outputSize === 0 &&
(!this[kSocket] || this[kSocket].writableLength === 0)
);
return this[kWritableFinished];
},
});

Expand Down Expand Up @@ -1078,8 +1080,48 @@ OutgoingMessage.prototype.addTrailers = function addTrailers(headers) {
}
};

function onFinish(outmsg) {
if (outmsg?.socket?._hadError) return;
// Deliver end() callbacks, mirroring Writable: null on successful finish,
// otherwise the error that prevented all data from being flushed.
function flushEndCallbacks(msg, err) {
const callbacks = msg[kEndCallbacks];
if (callbacks === null)
return;
msg[kEndCallbacks] = null;
for (let i = 0; i < callbacks.length; i++)
callbacks[i](err);
}

function getEndCallbackError(msg) {
return msg[kErrored] ??
msg[kSocket]?.errored ??
new ERR_STREAM_DESTROYED('end');
}

function queueEndCallback(msg, callback) {
if (msg[kWritableFinished]) {
callback(new ERR_STREAM_ALREADY_FINISHED('end'));
return;
}
if (msg[kFlushError] !== null) {
process.nextTick(callback, msg[kFlushError]);
return;
}
msg[kEndCallbacks] ??= [];
msg[kEndCallbacks].push(callback);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ArrayPrototypePush ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've mostly been going in the opposite direction in node:http, it looks like most of this file replaced primordials with simple calls in #38248 and #53698.

I haven't been involved in the primordials debate directly but my impression is that for HTTP generally the primordial guarantees aren't critical and we're happy skipping them as a performance tradeoff. Happy to discuss if we want to change direction but for now I'd rather leave it.

}

function onFinish(outmsg, err) {
if (err ||
outmsg[kErrored] ||
outmsg[kSocket]?.errored ||
outmsg[kSocket]?._hadError) {
outmsg[kFlushError] = err ?? getEndCallbackError(outmsg);
flushEndCallbacks(outmsg, outmsg[kFlushError]);
return;
}

outmsg[kWritableFinished] = true;
flushEndCallbacks(outmsg, null);
outmsg.emit('finish');
}

Expand Down Expand Up @@ -1108,11 +1150,7 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) {
write_(this, chunk, encoding, null, true);
} else if (this.finished) {
if (typeof callback === 'function') {
if (!this.writableFinished) {
this.on('finish', callback);
} else {
callback(new ERR_STREAM_ALREADY_FINISHED('end'));
}
queueEndCallback(this, callback);
}
return this;
} else if (!this._header) {
Expand All @@ -1125,7 +1163,7 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) {
}

if (typeof callback === 'function')
this.once('finish', callback);
queueEndCallback(this, callback);

if (strictContentLength(this) && this[kBytesWritten] !== this._contentLength) {
throw new ERR_HTTP_CONTENT_LENGTH_MISMATCH(this[kBytesWritten], this._contentLength);
Expand Down
4 changes: 3 additions & 1 deletion test/parallel/test-http-outgoing-end-multiple.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ const onWriteAfterEndError = common.mustCall((err) => {
const server = http.createServer(common.mustCall(function(req, res) {
res.end('testing ended state', common.mustCall());
assert.strictEqual(res.writableCorked, 0);
// end() before 'finish' has been emitted queues the callback, which then
// reports the outcome of the flush, matching stream.Writable.
res.end(common.mustCall((err) => {
assert.strictEqual(err.code, 'ERR_STREAM_ALREADY_FINISHED');
assert.strictEqual(err, null);
}));
assert.strictEqual(res.writableCorked, 0);
res.end('end', onWriteAfterEndError);
Expand Down
149 changes: 124 additions & 25 deletions test/parallel/test-http-outgoing-writableFinished.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,130 @@
const common = require('../common');
const assert = require('assert');
const http = require('http');
const { Duplex } = require('stream');

const server = http.createServer(common.mustCall(function(req, res) {
assert.strictEqual(res.writableFinished, false);
res
.on('finish', common.mustCall(() => {
assert.strictEqual(res.writableFinished, true);
server.close();
}))
.end();
}));

server.listen(0);

server.on('listening', common.mustCall(function() {
const clientRequest = http.request({
port: server.address().port,
method: 'GET',
path: '/'
// writableFinished becomes true once all data has been flushed, immediately
// before 'finish' is emitted.
{
const server = http.createServer(common.mustCall(function(req, res) {
assert.strictEqual(res.writableFinished, false);
res
.on('finish', common.mustCall(() => {
assert.strictEqual(res.writableFinished, true);
server.close();
}))
.end();
}));

server.listen(0);

server.on('listening', common.mustCall(function() {
const clientRequest = http.request({
port: server.address().port,
method: 'GET',
path: '/'
});

assert.strictEqual(clientRequest.writableFinished, false);
clientRequest
.on('finish', common.mustCall(() => {
assert.strictEqual(clientRequest.writableFinished, true);
}))
.end();
assert.strictEqual(clientRequest.writableFinished, false);
}));
}

// A request whose writes fail never becomes writableFinished and never emits
// 'finish'; the end() callback receives the write error instead.
{
const writeError = new Error('forced write failure');
const socket = new Duplex({
read() {},
write(chunk, encoding, callback) {
callback(writeError);
},
});
const failedRequest = http.request({
createConnection: common.mustCall(() => socket),
method: 'POST',
});

failedRequest.on('finish', common.mustNotCall());
failedRequest.on('error', common.mustCall((err) => {
assert.strictEqual(err, writeError);
}));
failedRequest.on('close', common.mustCall(() => {
assert.strictEqual(failedRequest.writableFinished, false);
}));

failedRequest.write('body', common.mustCall((err) => {
assert.strictEqual(err, writeError);
}));
failedRequest.end(common.mustCall((err) => {
assert.ok(err instanceof Error);
assert.strictEqual(failedRequest.writableFinished, false);

// Ending again after the flush has failed still reports the failure.
failedRequest.end(common.mustCall((endAgainErr) => {
assert.strictEqual(endAgainErr, err);
}));
}));
}

// The same for a server response whose flush fails (e.g. the connection is
// reset mid-flush). Unlike the client case, the error here only ever
// surfaces through the socket write callbacks.
{
const writeError = new Error('forced write failure');
const socket = new Duplex({
read() {},
write(chunk, encoding, callback) {
callback(writeError);
},
});

const server = http.createServer(common.mustCall((req, res) => {
res.on('finish', common.mustNotCall());
res.on('close', common.mustCall(() => {
assert.strictEqual(res.writableFinished, false);
}));
res.end('hello', common.mustCall((err) => {
assert.strictEqual(err, writeError);
assert.strictEqual(res.writableFinished, false);
}));
}));

server.emit('connection', socket);
socket.push('GET / HTTP/1.1\r\nHost: example.com\r\n\r\n');
}

// The same when end() happens after the failed write, with no data left to
// flush: the write failure must still be detected even though end() itself
// has nothing to send.
{
const writeError = new Error('forced write failure');
const socket = new Duplex({
read() {},
write(chunk, encoding, callback) {
callback(writeError);
},
});

const server = http.createServer(common.mustCall((req, res) => {
res.on('finish', common.mustNotCall());
res.setHeader('Content-Length', '5');
res.write('hello', common.mustCall((err) => {
assert.strictEqual(err, writeError);
}));
setImmediate(common.mustCall(() => {
res.end(common.mustCall((err) => {
assert.strictEqual(err, writeError);
assert.strictEqual(res.writableFinished, false);
}));
}));
}));

assert.strictEqual(clientRequest.writableFinished, false);
clientRequest
.on('finish', common.mustCall(() => {
assert.strictEqual(clientRequest.writableFinished, true);
}))
.end();
assert.strictEqual(clientRequest.writableFinished, false);
}));
server.emit('connection', socket);
socket.push('GET / HTTP/1.1\r\nHost: example.com\r\n\r\n');
}
6 changes: 5 additions & 1 deletion test/parallel/test-stream-pipeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,11 @@ tmpdir.refresh();

{
const server = http.createServer(common.mustCallAtLeast((req, res) => {
pipeline(req, res, common.mustSucceed());
pipeline(req, res, common.mustCall((err) => {
// The client destroys the request body source before EOF below, so the
// echoed response cannot finish successfully either.
assert.strictEqual(err?.code, 'ERR_STREAM_PREMATURE_CLOSE');
}));
}));

server.listen(0, common.mustCall(() => {
Expand Down
Loading