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..fdfe6c7f6374 100644 --- a/src/node_http_parser.cc +++ b/src/node_http_parser.cc @@ -34,7 +34,7 @@ #include #include // free() #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 @@ -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(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 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(); + 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 +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 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..d3a9f98d3a59 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() {