Repository navigation
Cut parked per-connection memory 59% by shrinking each connection's task - #61
Merged
Merged
Conversation
`handle`'s future is the tokio task each accepted connection keeps for its whole life. Two things in `forward` made it 42.8 KB: - The HTTP/1 header-rewriting future (17.4 KB) was awaited inline, so every connection carried its state although only header-rewriting HTTP/1 routes run it. It is now awaited through `Box::pin`, allocated only on those routes. - `copy` was captured into `body` and then moved again by `tokio::pin!`, and rustc keeps the captured and the pinned copy as separate fields. It is now pinned once where it is created. No behavior change: the same pinned future is polled by the same `select!` against the half-close bound. A unit test reads the future's size from its type and fails past 12 KiB; it fails on the previous code (42,872 B). Dispatch-Task: symphony-shrink-connection-future Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DxQT3d8CgJNgAjYp8Pc5ww
….3 KB The accepted 1.2 KB TlsStream was held five times while a TLS-terminated connection was parked: the handshake result, the `tls_stream` binding (kept because it was borrowed), and in `proxy_via_tls` the async fn argument, its working copy and the CountingStream. Only the last was live. Matching the handshake result by reference and passing `&mut TlsStream` on leaves one. The listeners also spawn `handle(..)` / `handle_http(..)` directly instead of an `async move` block that held their arguments a second time (-128 B per task cell). `handle`'s future is now 3,344 B in release; the task cell is 3,456 B, down from main's 43,016 B in two allocations (tokio boxes futures over 16 KiB). The size guard tightens to 4 KiB, so putting the TLS stream back by value fails it. Two liveness cases cover the half-close bound on a TLS-terminated route that rewrites HTTP headers, enabled and disabled. Dispatch-Task: symphony-shrink-connection-future Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DxQT3d8CgJNgAjYp8Pc5ww
The response wait in the new liveness cases resolved only on `end`, so a failed handshake or a reset (`error` then `close`, no `end`) would hang the suite. It now rejects on `close` with what arrived, and both cases carry a 10 s timeout. The layout comments drop their size figures, and CLAUDE.md notes that the size guard runs in the debug profile. Dispatch-Task: symphony-shrink-connection-future Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DxQT3d8CgJNgAjYp8Pc5ww
There was a problem hiding this comment.
Code Review
This pull request optimizes the memory footprint of per-connection futures to ensure they stay within a 4 KiB budget. Key changes include spawning the handle and handle_http futures directly instead of wrapping them in async move blocks, passing the TLS stream by reference rather than by value, pinning the copy future in place, and boxing the large HTTP/1 header rewriter future. Additionally, a unit test was added to assert the future size budget, and integration tests were introduced to verify half-closed connection reclamation on HTTP-rewriting routes. No review comments were provided, so there is no additional feedback to address.
kriszyp
marked this pull request as ready for review
October 14, 2026 17:07
kriszyp
added a commit
that referenced
this pull request
Oct 15, 2026
Sync package + platform + crate versions to 1.4.0. Since 1.3.0, in the order a consumer meets them: Less memory per connection (#61). Each connection's task future shrank from 42.8 KB to 3.3 KB: the HTTP/1 header-rewriting path is boxed, the copy future is pinned once, and the accepted TLS stream is passed by reference instead of being copied into every state. Parked connections measured 66.8 KB -> 27.2 KB each (-59%). A test fails if the future grows past 4 KiB. Memory is returned after connection peaks on Linux (#66). The addon's Rust allocations now use jemalloc on Linux (glibc and musl, x64 and arm64); macOS keeps the system allocator, and Node's own allocations are unchanged. Under glibc, symphony kept ~100% of its peak memory after the connection count fell, and the peak ratcheted up across cycles. With jemalloc, troughs hold ~28% of peak and stay flat across cycles, at +1.9% memory per parked connection. Build with --no-default-features for the system allocator. arm64 builds use 64 KiB jemalloc pages so one binary runs on 4, 16 and 64 KiB-page kernels. A route that fails to rebuild no longer keeps its old client-certificate policy (#65). A hot reload now carries a failing route forward only when the new spec asks for the same client-cert policy; otherwise the SNI is tombstoned rather than admitting clients under the old verifier. TLS session resumption works for resolved suspended routes, and expired IP-affinity entries are reclaimed (#57). forwardFingerprint accepts a list, so a route can forward both JA3 and JA4 (#49). Overlapping upgrades keep status.json pointing at the new process: an outgoing process no longer repoints or unlinks it, and a process that yields to a newer peer keeps watching. Benchmarking: `npm run benchmark:memory-oop` measures symphony's own memory out of process under parked, active, stalled-reader and churn-then-drop load (#60). SYMPHONY_TLS_BUFFER_LIMIT and SYMPHONY_MALLOC_TRIM_INTERVAL_MS are benchmark hooks, off by default and not a supported configuration surface. Known open measurements on #66: CPU for 4 KiB messages is still noisy (256 B messages show no difference), and trough retention with 64 KiB pages has been measured on x86 only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
⊙ Problem
Each accepted TLS connection is one tokio task, and the task's future stays allocated for the connection's whole life. On 1.3.x that future is 42.8 KB, so 100k idle connections spend 4.3 GB on it. Most of it is state that most connections never use: the HTTP/1 header rewriter (17.4 KB), a second copy of the copy future, and four dead copies of the 1.2 KB TLS stream.
🤔 Is this the right fix to make now?
Yes, as specified. It is the largest per-connection cost that #60's benchmark found. No config, default or behavior changes.
💡 Solution
handle's future grows past 4 KiB.⚖️ Alternatives
copyonce) — 8,192 B. It leaves four dead copies of the TLS stream.proxy_via_tls— measured 3,824 B, worse than passing the stream by reference, with more code moved.http_proxy.rsand saves nothing on header-rewriting connections.🤔 Is 4 KiB the right budget?
It leaves about 700 B over today's 3,376 B (
cargo testprofile). Passing the TLS stream by value again (+1.2 KiB) or inlining the rewriter (+17 KiB) fails it. A small future stored twice still fits. It guardshandleonly, not the short-lived HTTP :80 task. Raise it deliberately if a dependency bump trips it.🔧 Changes
src/proxy_conn.rsforwardawaitsproxy_http1_rewritingthroughBox::pin, so its 17 KiB lives on the heap only on header-rewriting routes.copyis pinned where it is created, and the half-closeselect!polls that pinned future instead of moving and re-pinning it.handlematches the handshake result by reference andproxy_via_tlstakes&mut TlsStream, so the stream has one slot in the future. Look hardest here: the client stream now drops inhandle, right afterproxy_via_tlsreturns. That is after the upstream, in the same poll, before any metric is recorded.the_per_connection_future_stays_within_budgetreads the future's size from its type, so it builds no socket, runtime orConnContext.src/listener.rs,src/http_listener.rs: spawnhandle(..)andhandle_http(..)directly. Theasync movewrapper held their arguments a second time.__test__/liveness.spec.ts: two cases for the half-close bound on a TLS-terminated route that rewrites headers, with the bound on and off. Until now the bound was tested only on passthrough.CLAUDE.md: a gotcha entry on keeping the per-connection future small, naming the guard and how to find what grew.✅ Verification
origin/main(42,872 B) and on Add an out-of-process memory benchmark for active, stalled and churning connections #60's patch alone (8,256 B), and passes here (3,376 B).cargo test: 154 pass.npm test: 155 pass, includingliveness.spec.tsandcopy-buffers.spec.ts. The two new liveness cases also pass on main's addon. Clippy andcargo fmt --checkare clean.Benchmark: 30k connections, 3 repeats
Parked marginal is the RSS slope between 15k and 30k parked connections. Cells: mean (min–max) over 3 runs, bytes per connection.
N=30000, 3 client procs, 4 UDS upstreams (ipAffinity true), PROXY v1, default 8 KiB copy buffers, msg 256 B at 1/s/conn, slow fraction 0.1. Each arm is a release
.nodebuilt from its commit with the same toolchain, swapped into one checkout'sdist/via--addon-arm; arms run in shuffled order per repeat, underbench-run(CPU capped at 2 GHz). Run record:~/dev/tmp/bench-runs/symphony-connfuture-30k-20261013-210938/meta.txton the bench host. The harness is not committed here; it lands with #60.Future sizes, and the other per-connection futures
Release build,
RUSTC_BOOTSTRAP=1 RUSTFLAGS=-Zprint-type-sizes cargo build --release --lib, rustc 1.94.1, tokio 1.53.3, rustls 0.23.45 (resolved locally;Cargo.lockis not tracked).proxy_conn::handleproxy_conn::proxy_via_tlsproxy_conn::proxy_raw(passthrough)proxy_conn::forward<..>http_proxy::proxy_http1_rewriting<..>Timeout<Accept<TcpStream>>(TLS handshake)sni::peektokio's
spawnboxes any future over 16 KiB in release builds, which is why main's task is two allocations.Test profile (
cargo test),handle: main 42,872; box only 8,712; pin only 25,336; #60's patch 8,256; this PR 3,376.handlepicksdestination_h2and runs the sameforward.handle's handshake, TLS and passthrough states are each 3.3 KB. Going lower means trimming locals they all hold (route, peek info, source forwarding), which is not mechanical.read_header_block. These connections live for one header read and one response, so it is not a parked cost.copy_exact's 8 KiB array inline, plusread_header_block's 4 KiB array in another state. Follow-up: heap buffers sized on demand.🤖 Generated by Claude (Opus 5.5); posted via @kriszyp.
🤖 Generated with Claude Code
https://claude.ai/code/session_01DxQT3d8CgJNgAjYp8Pc5ww
Related PRs: #60 overlaps (source of the first commit; both edit src/proxy_conn.rs)
Complexity: medium
Origin — the dispatch brief this PR was written from
Shrink symphony's per-connection task future, the 'future-size patch' described in draft PR #60 (see the collapsed section 'The future-size patch (the futfix arm; not in this PR)' in its body). In src/proxy_conn.rs,
handle's future is ~42.8 KB per connection on 1.3.x; 17.9 KB of that is new in 1.3.0. Two changes take it to ~8.2 KB: (1) Box::pin thecrate::http_proxy::proxy_http1_rewriting(...)future so its large state machine lives on the heap only on routes that rewrite HTTP headers, instead of inline in every connection's future; (2) pin thecopyfuture once withstd::pin::pin!where it is created, and drop the latertokio::pin!(copy)insidebody, so the future is not stored twice. That measured -34.6 KB resident per parked connection, with no behavior change. This is the dominant per-connection cost on fan-out nodes with 100k+ long-lived TLS connections.Deliverables:
handlefuture (or the closest public-in-crate entry point spawned per accepted connection) without polling it, and assertsstd::mem::size_of_val(&fut)is under a budget (e.g. 12 KiB), with a comment saying why the budget exists. If constructing it in a test needs heavy scaffolding, a smaller test around thecopy/bodycomposition is fine, as long as it would fail on today's main. Prove the guard fails without the fix: run it against main's proxy_conn.rs and watch it go red.-Zprint-type-sizeson nightly, or with size_of_val probes. Report the top few sizes in the PR body. Fix any with the same pattern (an inline large rarely-used branch, or a future stored twice) only if the fix is equally mechanical; otherwise list them as follow-ups.npm run benchmark:memory-oop, parked phase at least 30k connections, glibc default) against this branch vs main, and put before/after B/conn in the PR body. Do not commit the harness here; it lands with Add an out-of-process memory benchmark for active, stalled and churning connections #60.Acceptance
cargo testandnpm testpass; CI green.Dispatch: task
symphony-shrink-connection-future· queued by kris-session · ran by claude/opus/xhigh · worker kzyp-xps-1Review-Coverage: authored=claude; ran=gemini,codex; adjudicated=domain; declined=cursor-grok,cursor-composer,cursor-kimi,cursor-muse,muse; rounds=2; full=1 @ 312469f
Review-Attention: read ~3m (decisions: box-rewriter-vs-slim-framing, layout-budget-as-guard, guard-covers-tls-only) @ 312469f