Skip to content

Cap BufferPool to stop unbounded growth on receive-heavy connections - #166

Closed
xhon4 wants to merge 1 commit into
algesten:mainfrom
xhon4:fix/bounded-buffer-pool
Closed

xhon4 wants to merge 1 commit into
algesten:mainfrom
xhon4:fix/bounded-buffer-pool

Conversation

@xhon4

@xhon4 xhon4 commented Oct 1, 2026

Copy link
Copy Markdown

Fixes #165.

Problem

Record::parse allocates a fresh buffer for every incoming record. purge_handled_queue_rx then returns each handled buffer to buffers_free. Only the send and handshake paths take buffers back out of the pool, so a connection that mostly receives grows the pool by one buffer per record, with no upper bound. Through str0m, that was about 590 MB retained after receiving 1 GiB.

Change

  • BufferPool::push now drops the buffer once the pool already holds MAX_POOLED_BUFFERS (64) free entries.
  • Reuse is unchanged below the cap: buffers are cleared and keep their capacity.
  • Two unit tests:
    • the pool stays bounded when pushes outnumber pops (this test fails without the fix: 10000 vs 64);
    • pooled buffers are cleared and keep their capacity.

The cap of 64 is a judgment call. I'm happy to switch to a value derived from max_queue_rx/max_queue_tx, or to make Record::parse take its buffer from the pool, if you prefer.

Verification

  • cargo test --lib: 303 passed with default features, 253 with --no-default-features --features aws-lc-rs, and 253 with --features rust-crypto.
  • Integration tests: auto 62 passed and dtls12 73 passed, both with --features aws-lc-rs,rcgen.
  • cargo fmt --check and cargo clippy -D warnings are clean.
  • End to end: a str0m 0.24 receiver taking 1 GiB from Chromium now peaks at about 10 MB RSS (590 MB before), and the payload verifies byte for byte.
  • Not run locally: the wolfssl and openssl interop targets (dtls13, ossl), because my machine has no libclang for wolfssl-sys. CI should cover those.

Record::parse allocates a fresh buffer per incoming record and purge_handled_queue_rx returns each handled buffer to buffers_free, while only send/handshake paths pop from it. A mostly-receiving connection therefore grew the pool by one buffer per record without bound (~590 MB retained after receiving 1 GiB through str0m). Drop buffers once the pool holds 64 free entries.

Fixes algesten#165
algesten added a commit that referenced this pull request Oct 1, 2026
## Summary

DTLS 1.2 and DTLS 1.3 receive records now acquire buffers from the engine pool and recycle them on parsing failures, rejected datagrams, duplicate or old records, and receive-queue replacement. This prevents fresh receive allocations from accumulating in the pool and preserves reuse on discard paths.

Retained handshake buffers also come from the pool. DTLS 1.2 key exchange transfers buffer ownership directly, and DTLS 1.3 retains derived traffic secrets without redundant copies. Remaining `Buf::new()` calls hold temporary work buffers, pre-engine probe state, or test fixtures.

`BufferPool::push` uses an unconditional assertion to reject buffers that were not acquired from a pool, including in release builds. Regression tests cover repeated receive allocation reuse, malformed or oversized datagram cleanup, duplicate rejection, and rejection of fresh or replaced buffers.

Fixes #165.

Supersedes #166.
@algesten algesten closed this Oct 1, 2026
@algesten

algesten commented Oct 1, 2026

Copy link
Copy Markdown
Owner

Restored the intended buffering behavior which I believe fixes this issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unbounded BufferPool growth on receive-heavy connections (memory grows with bytes received)

2 participants