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() {