Commit 36452f15c50 for nodejs

commit 36452f15c50d77faf32d679ad1ca411b68614974
Author: Guilherme Araújo <arauujogui@gmail.com>
Date:   Thu Nov 5 15:53:46 2026 -0300

    http: batch body spans per parser execute

    Buffer body spans in on_body() and deliver them to JS once per
    execute() call instead of once per chunk. A body sent as many small
    chunks no longer costs one C++ to JS call and Buffer per chunk.

    Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
    PR-URL: https://github.com/nodejs/node/pull/67187
    Reviewed-By: Robert Nagy <ronagy@icloud.com>
    Reviewed-By: Matteo Collina <matteo.collina@gmail.com>

diff --git a/benchmark/http/bench-parser-chunked.js b/benchmark/http/bench-parser-chunked.js
new file mode 100644
index 00000000000..782d95ea58d
--- /dev/null
+++ b/benchmark/http/bench-parser-chunked.js
@@ -0,0 +1,55 @@
+'use strict';
+
+// Measures parsing a chunked request body as the chunk size shrinks.
+// The wire buffer is fed to the parser in 64 KiB slices, like socket reads.
+
+const common = require('../common');
+
+const bench = common.createBenchmark(main, {
+  chunkSize: [1, 16, 256, 4096],
+  len: [64 * 1024],
+  n: [500],
+}, {
+  flags: ['--expose-internals', '--no-warnings'],
+});
+
+function main({ chunkSize, len, n }) {
+  const { HTTPParser } = common.binding('http_parser');
+  const REQUEST = HTTPParser.REQUEST;
+  const kOnHeaders = HTTPParser.kOnHeaders | 0;
+  const kOnHeadersComplete = HTTPParser.kOnHeadersComplete | 0;
+  const kOnBody = HTTPParser.kOnBody | 0;
+  const kOnMessageComplete = HTTPParser.kOnMessageComplete | 0;
+  const kMaxHeaderPairs = 2000;
+  const kReadSize = 64 * 1024;
+
+  const chunk = `${chunkSize.toString(16)}\r\n${'x'.repeat(chunkSize)}\r\n`;
+  const wire = Buffer.from(
+    'POST / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n' +
+    chunk.repeat(Math.ceil(len / chunkSize)) +
+    '0\r\n\r\n');
+
+  const reads = [];
+  for (let i = 0; i < wire.length; i += kReadSize)
+    reads.push(wire.subarray(i, i + kReadSize));
+
+  let bodyBytes = 0;
+  const parser = new HTTPParser();
+  parser.initialize(REQUEST, {}, 0, 0, undefined, kMaxHeaderPairs);
+  parser[kOnHeaders] = function() {};
+  parser[kOnHeadersComplete] = function() {};
+  parser[kOnBody] = function(b) { bodyBytes += b.length; };
+  parser[kOnMessageComplete] = function() {};
+
+  bench.start();
+  for (let i = 0; i < n; i++) {
+    for (const read of reads)
+      parser.execute(read);
+    parser.initialize(REQUEST, {}, 0, 0, undefined, kMaxHeaderPairs);
+  }
+  bench.end(n);
+
+  const expected = Math.ceil(len / chunkSize) * chunkSize * n;
+  if (bodyBytes !== expected)
+    throw new Error(`Expected ${expected} body bytes, got ${bodyBytes}`);
+}
diff --git a/src/node_http_parser.cc b/src/node_http_parser.cc
index 3bef547c405..fdfe6c7f637 100644
--- a/src/node_http_parser.cc
+++ b/src/node_http_parser.cc
@@ -34,7 +34,7 @@
 #include <algorithm>
 #include <cstdlib>  // free()
 #include <cstring>  // strdup(), strchr()
-
+#include <vector>

 // This is a binding to llhttp (https://github.com/nodejs/llhttp)
 // The goal is to decouple sockets from parsing for more javascript-level
@@ -85,6 +85,7 @@ const uint32_t kOnTimeout = 6;
 const size_t kMaxHeaderFieldsCount = 32;
 // Maximum size of chunk extensions
 const size_t kMaxChunkExtensionsSize = 16384;
+const size_t kMaxBufferedBodySize = 64 * 1024;

 const uint32_t kLenientNone = 0;
 const uint32_t kLenientHeaders = 1 << 0;
@@ -518,36 +519,66 @@ class Parser : public AsyncWrap, public StreamListener {
     return static_cast<int>(val);
   }

-
   int on_body(const char* at, size_t length) {
     if (length == 0)
       return 0;

+    if (pending_body_len_ + length > kMaxBufferedBodySize && !FlushBody()) {
+      llhttp_set_error_reason(&parser_, "HPE_JS_EXCEPTION:JS Exception");
+      return HPE_USER;
+    }
+
+    if (pending_body_len_ == 0) {
+      pending_body_ = at;
+    } else {
+      if (body_buf_.empty()) {
+        body_buf_.reserve(kMaxBufferedBodySize);
+        body_buf_.assign(pending_body_, pending_body_ + pending_body_len_);
+      }
+      body_buf_.insert(body_buf_.end(), at, at + length);
+    }
+    pending_body_len_ += length;
+
+    return 0;
+  }
+
+  bool FlushBody() {
+    if (pending_body_len_ == 0) return true;
+
+    const char* data = body_buf_.empty() ? pending_body_ : body_buf_.data();
+    size_t length = pending_body_len_;
+    pending_body_len_ = 0;
+
+    if (got_exception_) {
+      body_buf_.clear();
+      return true;
+    }
+
     Environment* env = this->env();
     HandleScope handle_scope(env->isolate());

     Local<Value> cb = CachedCallback(kOnBody, kOnBodyCallback);
+    if (!cb->IsFunction()) {
+      body_buf_.clear();
+      return true;
+    }

-    if (!cb->IsFunction())
-      return 0;
-
-    Local<Value> buffer = Buffer::Copy(env, at, length).ToLocalChecked();
-
-    MaybeLocal<Value> r = MakeCallback(cb.As<Function>(), 1, &buffer);
+    Local<Value> buffer = Buffer::Copy(env, data, length).ToLocalChecked();
+    body_buf_.clear();

-    if (r.IsEmpty()) {
+    if (MakeCallback(cb.As<Function>(), 1, &buffer).IsEmpty()) {
       got_exception_ = true;
-      llhttp_set_error_reason(&parser_, "HPE_JS_EXCEPTION:JS Exception");
-      return HPE_USER;
+      return false;
     }

-    return 0;
+    return true;
   }

-
   int on_message_complete() {
     HandleScope scope(env()->isolate());

+    if (!FlushBody()) return -1;
+
     if (connectionsList_ != nullptr) {
       connectionsList_->PopActive(this);
     }
@@ -883,6 +914,9 @@ class Parser : public AsyncWrap, public StreamListener {
       Save();
     }

+    // Deliver buffered body spans while `data` is still alive.
+    FlushBody();
+
     // Calculate bytes read and resume after Upgrade/CONNECT pause
     size_t nread = len;
     if (err != HPE_OK) {
@@ -1118,6 +1152,9 @@ class Parser : public AsyncWrap, public StreamListener {
   bool is_being_freed_ = false;
   size_t current_buffer_len_;
   const char* current_buffer_data_;
+  const char* pending_body_ = nullptr;
+  size_t pending_body_len_ = 0;
+  std::vector<char> body_buf_;
   bool headers_completed_ = false;
   size_t header_pairs_ = 0;
   size_t max_header_pairs_ = 0;
diff --git a/test/parallel/test-http-parser.js b/test/parallel/test-http-parser.js
index 03ad528c387..d3a9f98d3a5 100644
--- a/test/parallel/test-http-parser.js
+++ b/test/parallel/test-http-parser.js
@@ -331,8 +331,9 @@ function expectBody(expected) {
     assert.strictEqual(versionMinor, 1);
   });

+  // Chunks parsed in the same execute() call are delivered as one buffer.
   let body_part = 0;
-  const body_parts = ['123', '123456', '1234567890'];
+  const body_parts = ['1231234561234567890'];

   const onBody = mustCallAtLeast((buf) => {
     const body = String(buf);
@@ -369,9 +370,10 @@ function expectBody(expected) {
     assert.strictEqual(versionMinor, 1);
   });

+  // One buffer per execute() call, holding all the chunks it parsed.
   let body_part = 0;
   const body_parts =
-          ['123', '123456', '123456789', '123456789ABC', '123456789ABCDEF'];
+          ['123123456', '123456789123456789ABC123456789ABCDEF'];

   const onBody = mustCallAtLeast((buf) => {
     const body = String(buf);
@@ -562,6 +564,21 @@ function expectBody(expected) {
   parser.execute(req2, 0, req2.length);
 }

+// Buffered body spans are flushed once they would exceed 64 KiB.
+{
+  const chunk = `400\r\n${'x'.repeat(1024)}\r\n`;
+  const request = Buffer.from(
+    'POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n' +
+    chunk.repeat(100) + '0\r\n\r\n');
+
+  const sizes = [];
+  const parser = newParser(REQUEST);
+  parser[kOnBody] = (buf) => sizes.push(buf.length);
+  parser.execute(request, 0, request.length);
+
+  assert.deepStrictEqual(sizes, [64 * 1024, 36 * 1024]);
+}
+
 // Test parser 'this' safety
 // https://github.com/joyent/node/issues/6690
 assert.throws(function() {