Skip to content

enh(Net,Foundation): multipart parsing performance and stream bulk-read optimization - #5290

Merged
matejk merged 3 commits into
mainfrom
5288-multipart-fixes-improvements
Apr 9, 2026
Merged

enh(Net,Foundation): multipart parsing performance and stream bulk-read optimization#5290
matejk merged 3 commits into
mainfrom
5288-multipart-fixes-improvements

Conversation

@matejk

@matejk matejk commented Apr 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #5288

Closes #4118

Commits

1. enh(Foundation): bulk-read xsgetn and exception safety for stream bufs

  • Override xsgetn() in BasicBufferedStreamBuf to copy directly from internal buffer via char_traits::copy + setg, delegating refill to underflow()
  • Fix UnbufferedStreamBuf::xsgetn to catch exceptions mid-read and return bytes already copied (fixes StreamCopier::copyToString through Base64Decoder on truncated streams)

2. enh(Net): multipart parsing performance and robustness (#5288, #4118)

  • Fix OSS-Fuzz timeout (Fuzzing Issue in MailMessage #5288): cache multipart state in makeMultipart(), validate empty boundary, add MAX_PARTS (100,000) DoS limit
  • Content-Length bulk-read (Poco Multipart parsing is 10x slower than its Boost/beat or restinio equivalent #4118): sgetn fast path when Content-Length present in part headers, with 32-bit overflow protection
  • ReadWindow sliding buffer at MultipartReader level: all reads (parseHeader, readContent, boundary scanning) go through a single 32KB window with zero-copy in-buffer boundary scanning. Over-read bytes stay in the window for the next part's header parsing -- no replay, no holdback vectors
  • Increase STREAM_BUFFER_SIZE from 1KB to 32KB
  • Replace char-by-char stream drain with istr.ignore()
  • C++17: [[nodiscard]], = delete/default, structured bindings, std::none_of, if-with-initializer, enum class, static constexpr

3. test(Net): add multipart parsing tests for boundary, limits, and perf

7 new MailMessage tests: empty boundary, Content-Length path, Content-Length: 0, 60K parts, MAX_PARTS limit, 200x500KB with/without Content-Length. Update StringPartHandler to use StreamCopier::copyToString.

Measured Performance

Production handler (StreamCopier::copyToString, 200 x 500KB parts, ~97 MB)

Scenario macOS main macOS PR Linux main Linux PR
With Content-Length 221 ms 12 ms (18x) 233 ms 19 ms (12x)
Without Content-Length 220 ms 90 ms (2.4x) 230 ms 83 ms (2.8x)

Null handler (stream.ignore, 200 x 500KB parts, ~97 MB)

Scenario macOS main macOS PR Linux main Linux PR
With Content-Length 419 ms 211 ms (2x) 211 ms 5 ms (42x)
Without Content-Length 418 ms 287 ms (1.5x) 212 ms 68 ms (3.1x)

Small parts (60,000 parts, 5.5 MB, null handler)

macOS main macOS PR Linux main Linux PR
30 ms 31 ms 25 ms 25 ms

No regression for small parts.

OSS-Fuzz #5288 (46 KB crafted message)

main PR
~3,900 ms 7 ms (rejected: empty boundary)

Side-effect performance improvements

BufferedStreamBuf::xsgetn (affects all BufferedStreamBuf-derived streams)

The xsgetn() override copies directly from the internal buffer instead of calling sbumpc() per byte. This benefits every consumer using istream::read() or StreamCopier on any BufferedStreamBuf-derived stream:

Stream class Module Expected benefit
InflatingStreamBuf Foundation High -- gzip/zlib decompression via StreamCopier
DeflatingStreamBuf Foundation High -- compression input path
CryptoStreamBuf Crypto High -- bulk encrypt/decrypt
ZipStreamBuf + PartialStreamBuf Zip High -- ZIP extraction
DigestBuf Foundation Medium -- limited by hash CPU cost
MultipartStreamBuf Net Proven 25x with Content-Length

UnbufferedStreamBuf::xsgetn exception safety

Fixes StreamCopier::copyToString returning empty when reading through Base64Decoder on streams that throw mid-read (e.g., multipart without final boundary). Previously, an exception during xsgetn discarded all bytes read so far.

HTTP server impact

For HTTP multipart form uploads (HTMLForm::readMultipart):

Scenario Impact
File uploads with Content-Length per part 25x faster content reading
File uploads without Content-Length (typical browser) 2.4x faster (bulk boundary scanning)
Small form fields Negligible (< 1ms either way)
Plain HTTP body reading via StreamCopier Moderate -- xsgetn benefits any read()-based consumer on HTTPFixedLengthStream

Test plan

  • All 23 MailMessage tests pass (7 new) -- macOS and Linux
  • All 9 MultipartReader tests pass (including Unix line ends, no final boundary)
  • All 15 HTMLForm tests pass
  • All 102 Foundation Streams + FileStream tests pass
  • Benchmarked on macOS (Apple M1 Pro) and Linux (OrbStack)
  • Profiled with Apple Instruments -- remaining hotspots: MessageHeader::read (3.1%), memory management (7.5%)
  • Full CI

@matejk
matejk force-pushed the 5288-multipart-fixes-improvements branch from 501a689 to be73cbe Compare April 3, 2026 09:52
@matejk
matejk requested review from aleks-f and obiltschnig April 3, 2026 15:22
Comment thread Net/src/MultipartReader.cpp Fixed
@matejk
matejk marked this pull request as draft April 5, 2026 20:29
@matejk
matejk force-pushed the 5288-multipart-fixes-improvements branch from 7429582 to 6e39cfb Compare April 8, 2026 08:58
Add xsgetn() override to BasicBufferedStreamBuf that copies directly
from the internal buffer via char_traits::copy + setg, delegating
refill to underflow(). Eliminates per-byte virtual call overhead when
consumers use istream::read() or StreamCopier (up to 25x faster for
multipart Content-Length reads).

Fix UnbufferedStreamBuf::xsgetn to catch exceptions mid-read and
return bytes already copied, rather than losing all data. This fixes
StreamCopier::copyToString returning empty through Base64Decoder on
streams that throw during decoding (e.g., multipart without final
boundary marker).
matejk added 2 commits April 8, 2026 18:36
Fix OSS-Fuzz timeout (#5288) caused by O(parts x content_type_params)
complexity -- a crafted message with 2086 Content-Type parameters and
empty boundary caused 60s+ timeout. Cache multipart state, validate
empty boundary, add MAX_PARTS (100000) DoS limit.

Add Content-Length bulk-read optimization (#4118) using sgetn fast
path when Content-Length is present in part headers, with 32-bit
overflow protection. Increase STREAM_BUFFER_SIZE from 1KB to 16KB.

Introduce ReadWindow sliding buffer at MultipartReader level. All
reads (parseHeader, readContent, boundary scanning) go through a
single 16KB window with zero-copy in-buffer boundary scanning via
scanForBoundary(). Over-read bytes from boundary detection stay in
the window for the next part's header parsing -- no replay, no
holdback vectors, no PrependStreamBuf.

Replace char-by-char stream drain with istr.ignore(). Modernize
with C++17: [[nodiscard]], = delete/default, structured bindings,
std::none_of, if-with-initializer, enum class, static constexpr.
Add 7 new MailMessage tests:
- testReadMultiPartEmptyBoundary: empty boundary throws (#5288)
- testReadMultiPartWithContentLength: Content-Length bulk-read (#4118)
- testReadMultiPartWithZeroContentLength: Content-Length: 0 edge case
- testReadMultiPartManyParts: 60000 parts correctness + timing
- testReadMultiPartTooManyParts: MAX_PARTS (100000) limit enforced
- testReadMultiPartLargeWithContentLength: 200x500KB with CL + timing
- testReadMultiPartLargeWithoutContentLength: 200x500KB no CL + timing

Update StringPartHandler to use StreamCopier::copyToString for bulk
reads and add override/[[nodiscard]] annotations.
@matejk
matejk force-pushed the 5288-multipart-fixes-improvements branch from b469ca6 to 235722b Compare April 8, 2026 16:41

@obiltschnig obiltschnig left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice job!

@matejk
matejk merged commit 7fc4ad7 into main Apr 9, 2026
106 checks passed
@matejk
matejk deleted the 5288-multipart-fixes-improvements branch April 9, 2026 08:14
matejk added a commit that referenced this pull request Apr 9, 2026
…ad optimization (#5290)

* enh(Foundation): bulk-read xsgetn and exception safety for stream bufs

Add xsgetn() override to BasicBufferedStreamBuf that copies directly
from the internal buffer via char_traits::copy + setg, delegating
refill to underflow(). Eliminates per-byte virtual call overhead when
consumers use istream::read() or StreamCopier (up to 25x faster for
multipart Content-Length reads).

Fix UnbufferedStreamBuf::xsgetn to catch exceptions mid-read and
return bytes already copied, rather than losing all data. This fixes
StreamCopier::copyToString returning empty through Base64Decoder on
streams that throw during decoding (e.g., multipart without final
boundary marker).

* enh(Net): multipart parsing performance and robustness (#5288, #4118)

Fix OSS-Fuzz timeout (#5288) caused by O(parts x content_type_params)
complexity -- a crafted message with 2086 Content-Type parameters and
empty boundary caused 60s+ timeout. Cache multipart state, validate
empty boundary, add MAX_PARTS (100000) DoS limit.

Add Content-Length bulk-read optimization (#4118) using sgetn fast
path when Content-Length is present in part headers, with 32-bit
overflow protection. Increase STREAM_BUFFER_SIZE from 1KB to 16KB.

Introduce ReadWindow sliding buffer at MultipartReader level. All
reads (parseHeader, readContent, boundary scanning) go through a
single 16KB window with zero-copy in-buffer boundary scanning via
scanForBoundary(). Over-read bytes from boundary detection stay in
the window for the next part's header parsing -- no replay, no
holdback vectors, no PrependStreamBuf.

Replace char-by-char stream drain with istr.ignore(). Modernize
with C++17: [[nodiscard]], = delete/default, structured bindings,
std::none_of, if-with-initializer, enum class, static constexpr.

* test(Net): add multipart parsing tests for boundary, limits, and perf

Add 7 new MailMessage tests:
- testReadMultiPartEmptyBoundary: empty boundary throws (#5288)
- testReadMultiPartWithContentLength: Content-Length bulk-read (#4118)
- testReadMultiPartWithZeroContentLength: Content-Length: 0 edge case
- testReadMultiPartManyParts: 60000 parts correctness + timing
- testReadMultiPartTooManyParts: MAX_PARTS (100000) limit enforced
- testReadMultiPartLargeWithContentLength: 200x500KB with CL + timing
- testReadMultiPartLargeWithoutContentLength: 200x500KB no CL + timing

Update StringPartHandler to use StreamCopier::copyToString for bulk
reads and add override/[[nodiscard]] annotations.
@matejk matejk added this to the Release 1.15.2 milestone Apr 16, 2026
matejk added a commit that referenced this pull request Apr 20, 2026
Revert the mid-read try/catch added to BasicUnbufferedStreamBuf::xsgetn
in #5290. Swallowing uflow() exceptions after a partial copy left
istream::read unable to set badbit, so Base64Decoder/HexBinaryDecoder
errors became silent no-ops on read() consumers.

Add read()-based regression blocks to Base64Test and HexBinaryTest; the
existing invalid-input blocks only exercised operator>> (sbumpc).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fuzzing Issue in MailMessage Poco Multipart parsing is 10x slower than its Boost/beat or restinio equivalent

3 participants