Commit 66c5a027b63 for nodejs

commit 66c5a027b638b33b73120b1238512236c02e10fb
Author: junyup0503 <97156898+junyup0503@users.noreply.github.com>
Date:   Sat Nov 7 18:33:56 2026 +0900

    http: emit 'drain' after flushing coalesced chunks

    Since chunked writes are coalesced while the message is corked,
    write() decides backpressure from the byte length of the buffered
    chunks and sets kNeedDrain. When uncork() flushes them, net.Socket
    accounts string chunks in UTF-16 code units, so with multi-byte text it
    can stay below its own highWaterMark. It then never needs to drain and
    never emits 'drain'. If the flushed writes complete asynchronously, the
    check at the end of uncork() also runs while bytes are still pending,
    so the response stalls with writableNeedDrain === true and
    writableLength === 0.

    Re-check the drain condition once the flushed data has been written,
    unless the message or its socket has been destroyed in the meantime.

    Fixes: https://github.com/nodejs/node/issues/66781
    Refs: https://github.com/nodejs/node/pull/64987
    Assisted-by: AI coding agent
    Signed-off-by: junyup0503 <97156898+junyup0503@users.noreply.github.com>
    PR-URL: https://github.com/nodejs/node/pull/66782
    Reviewed-By: Tim Perry <pimterry@gmail.com>
    Reviewed-By: Robert Nagy <ronagy@icloud.com>

diff --git a/lib/internal/http/outgoing.js b/lib/internal/http/outgoing.js
index ad0ad6dc372..92a325489f2 100644
--- a/lib/internal/http/outgoing.js
+++ b/lib/internal/http/outgoing.js
@@ -376,6 +376,14 @@ 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 && !this.destroyed && this.writableLength === 0) {
+        this[kNeedDrain] = false;
+        this.emit('drain');
+      }
     });

     this[kChunkedBuffer].length = 0;
diff --git a/test/parallel/test-http-outgoing-chunked-drain-multibyte.js b/test/parallel/test-http-outgoing-chunked-drain-multibyte.js
new file mode 100644
index 00000000000..75b3e63a371
--- /dev/null
+++ b/test/parallel/test-http-outgoing-chunked-drain-multibyte.js
@@ -0,0 +1,78 @@
+'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. The high water mark is pinned because the
+// default is platform dependent (16 KiB on Windows), and the coalesced
+// payloads below must exceed it in bytes while staying under it in code
+// units, so that the socket never emits 'drain' on its own.
+function createSocket() {
+  const pending = [];
+  const socket = new Duplex({
+    decodeStrings: false,
+    writableHighWaterMark: 64 * 1024,
+    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. The socket stays below its own high water mark, so the
+// only candidate emitter is the message itself.
+{
+  const socket = createSocket();
+  socket.on('drain', common.mustNotCall());
+  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');
+}