From fda4ac1de7001624824711dd7866d18aab0fc839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Sun, 1 Nov 2026 14:46:59 -0300 Subject: [PATCH 1/2] http: batch body spans per parser execute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- benchmark/http/bench-parser-chunked.js | 55 +++++++++++++++++++++++ src/node_http_parser.cc | 61 +++++++++++++++++++++----- test/parallel/test-http-parser.js | 6 ++- 3 files changed, 110 insertions(+), 12 deletions(-) create mode 100644 benchmark/http/bench-parser-chunked.js diff --git a/benchmark/http/bench-parser-chunked.js b/benchmark/http/bench-parser-chunked.js new file mode 100644 index 000000000000..782d95ea58d2 --- /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 3bef547c405c..d7841bdda1e6 100644 --- a/src/node_http_parser.cc +++ b/src/node_http_parser.cc @@ -34,6 +34,7 @@ #include #include // free() #include // strdup(), strchr() +#include // This is a binding to llhttp (https://github.com/nodejs/llhttp) @@ -519,35 +520,69 @@ class Parser : public AsyncWrap, public StreamListener { } + // Body spans are buffered and handed to JS once per Execute() (or before + // on_message_complete), so a body split into many small chunks costs one + // C++ -> JS crossing per read instead of one per chunk. int on_body(const char* at, size_t length) { if (length == 0) return 0; + if (pending_body_len_ == 0) { + // Usually the only span in this read: point at it, copy nothing yet. + pending_body_ = at; + } else { + if (body_buf_.empty()) + 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; + } + + // Returns false if the JS callback threw. + 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 cb = CachedCallback(kOnBody, kOnBodyCallback); + if (!cb->IsFunction()) { + body_buf_.clear(); + return true; + } - if (!cb->IsFunction()) - return 0; - - Local buffer = Buffer::Copy(env, at, length).ToLocalChecked(); - - MaybeLocal r = MakeCallback(cb.As(), 1, &buffer); + Local buffer = Buffer::Copy(env, data, length).ToLocalChecked(); + // ponytail: clear() keeps capacity (up to one read, 64 KiB) per parser; + // shrink_to_fit() here if pooled parsers' memory becomes a concern. + body_buf_.clear(); - if (r.IsEmpty()) { + if (MakeCallback(cb.As(), 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 +918,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 +1156,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 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 03ad528c3878..73173763aadb 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); From 8f623b96dab8e979713e9c693471ffc5a9a44990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Sun, 1 Nov 2026 15:04:18 -0300 Subject: [PATCH 2/2] fixup! http: batch body spans per parser execute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Guilherme Araújo --- src/node_http_parser.cc | 26 +++++++++++--------------- test/parallel/test-http-parser.js | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/node_http_parser.cc b/src/node_http_parser.cc index d7841bdda1e6..fdfe6c7f6374 100644 --- a/src/node_http_parser.cc +++ b/src/node_http_parser.cc @@ -36,7 +36,6 @@ #include // strdup(), strchr() #include - // This is a binding to llhttp (https://github.com/nodejs/llhttp) // The goal is to decouple sockets from parsing for more javascript-level // agility. A Buffer is read from a socket and passed to parser.execute(). @@ -86,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; @@ -519,20 +519,22 @@ class Parser : public AsyncWrap, public StreamListener { return static_cast(val); } - - // Body spans are buffered and handed to JS once per Execute() (or before - // on_message_complete), so a body split into many small chunks costs one - // C++ -> JS crossing per read instead of one per chunk. 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) { - // Usually the only span in this read: point at it, copy nothing yet. pending_body_ = at; } else { - if (body_buf_.empty()) + 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; @@ -540,10 +542,8 @@ class Parser : public AsyncWrap, public StreamListener { return 0; } - // Returns false if the JS callback threw. bool FlushBody() { - if (pending_body_len_ == 0) - return true; + if (pending_body_len_ == 0) return true; const char* data = body_buf_.empty() ? pending_body_ : body_buf_.data(); size_t length = pending_body_len_; @@ -564,8 +564,6 @@ class Parser : public AsyncWrap, public StreamListener { } Local buffer = Buffer::Copy(env, data, length).ToLocalChecked(); - // ponytail: clear() keeps capacity (up to one read, 64 KiB) per parser; - // shrink_to_fit() here if pooled parsers' memory becomes a concern. body_buf_.clear(); if (MakeCallback(cb.As(), 1, &buffer).IsEmpty()) { @@ -576,12 +574,10 @@ class Parser : public AsyncWrap, public StreamListener { return true; } - int on_message_complete() { HandleScope scope(env()->isolate()); - if (!FlushBody()) - return -1; + if (!FlushBody()) return -1; if (connectionsList_ != nullptr) { connectionsList_->PopActive(this); diff --git a/test/parallel/test-http-parser.js b/test/parallel/test-http-parser.js index 73173763aadb..d3a9f98d3a59 100644 --- a/test/parallel/test-http-parser.js +++ b/test/parallel/test-http-parser.js @@ -564,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() {