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
9 changes: 9 additions & 0 deletions lib/internal/http/outgoing.js
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,15 @@ OutgoingMessage.prototype.uncork = function uncork() {
for (const callback of callbacks) {
callback(err);
}
// write() returned false based on the byte length of the coalesced
// chunks, but net.Socket counts string chunks in UTF-16 code units and
// may stay below its own highWaterMark, so it never emits 'drain'.
// Re-check once the flushed data has been written.
if (!err && this.writableNeedDrain && !socket.destroyed &&
this.writableLength === 0) {
Comment on lines +382 to +384

@par333k par333k Oct 16, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
// Re-check once the flushed data has been written.
if (!err && this.writableNeedDrain && !socket.destroyed &&
this.writableLength === 0) {
const currentSocket = this[kSocket];
if (!err && this.writableNeedDrain && currentSocket &&
!currentSocket.destroyed && this.writableLength === 0) {

this[kNeedDrain] = false;
this.emit('drain');
}
});

this[kChunkedBuffer].length = 0;
Expand Down
72 changes: 72 additions & 0 deletions test/parallel/test-http-outgoing-chunked-drain-multibyte.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
'use strict';
const common = require('../common');
const assert = require('assert');
const http = require('http');
const { Duplex } = require('stream');

// Refs: https://github.com/nodejs/node/issues/66781
// A chunked response written with multi-byte string chunks must emit 'drain'
// after write() returned false. write() decides backpressure from the byte
// length of the auto-corked chunks, while net.Socket counts string chunks in
// UTF-16 code units, so the socket may stay below its own highWaterMark and
// never emit 'drain' itself once the coalesced chunks are flushed.

// Behaves like net.Socket for this purpose: strings are counted in UTF-16
// code units and writes complete asynchronously, so flushed data is still in
// flight when uncork() returns.
function createSocket() {
const pending = [];
const socket = new Duplex({
decodeStrings: false,
read() {},
write(chunk, encoding, callback) {
pending.push(callback);
},
});
// Complete one write per turn of the event loop.
(function flush() {
pending.shift()?.();
if (!socket.destroyed) setImmediate(flush);
})();
return socket;
}

const row = 'a'.repeat(100) + 'ع'.repeat(150) + '\n'; // 401 bytes

{
const ROWS = 2000;
const socket = createSocket();
const server = http.createServer(common.mustCall(async (req, res) => {
let drains = 0;
for (let i = 0; i < ROWS; i++) {
if (!res.write(row)) {
assert.strictEqual(res.writableNeedDrain, true);
await new Promise((resolve) => res.once('drain', resolve));
assert.strictEqual(res.writableLength, 0);
assert.strictEqual(res.writableNeedDrain, false);
drains++;
}
}
assert.ok(drains > 0);
res.end(common.mustCall(() => socket.destroy()));
}));

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

// 'drain' must not be emitted once the response has been destroyed from a
// write callback.
{
const socket = createSocket();
const server = http.createServer(common.mustCall((req, res) => {
res.on('drain', common.mustNotCall());
const ret = res.write(row.repeat(200), common.mustCall(() => {
res.destroy();
}));
assert.strictEqual(ret, false);
}));

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