Apply readBufferSize to the copy loop and split it per direction - #36
Conversation
`readBufferSize` has been plumbed from the JS config into `ProxyConnConfig` for several releases without anything reading it: the copy is `copy_bidirectional`, which uses tokio's hard-coded 8 KiB per direction. `http_proxy.rs` doesn't read it either — that path uses fixed stack buffers — so the setting has never had any effect on any port, and the documented 64 KiB default has never applied. This matters at high connection counts, not high throughput. Both buffers are allocated for the whole life of every connection whether or not it is transferring anything, so the size multiplies straight into per-connection memory: 2 x size x connections. At a few hundred thousand mostly-idle MQTT subscribers that is gigabytes of buffers nothing ever reads. - Wire the value through to `copy_bidirectional_with_sizes`. - Lower the default 65536 -> 8192. This is a compatibility requirement rather than a preference: 8192 is what the copy loop has actually been using, so wiring the old default through would have taken every existing deployment from 16 KiB to 128 KiB per connection. - Add `clientReadBufferSize` / `upstreamReadBufferSize`. MQTT is strongly asymmetric — after SUBSCRIBE a client sends almost nothing but PINGREQ while the broker carries the fan-out — so 1024/4096 beats a symmetric 2048 at both memory and downstream headroom. - Clamp to [512, 1 MiB] and log out-of-range values. Zero is the correctness case: the copy loop would read into an empty slice and take the `Ok(0)` for EOF, closing the connection instead of proxying it. Also narrows the header-rewrite gate. `l7_http1` was "ALPN is not h2", so a terminated MQTT connection on a route configured with `sourceAddressHeader: 'xForwardedFor'` sent the stream through the HTTP/1 rewriter, which would wait for a `\r\n\r\n` that never arrives and stall until the idle timeout. A negotiated ALPN is now taken at its word; an absent one stays permissive, because HTTPS clients that offer no ALPN are real and still need their X-Forwarded-For. A native MQTT client that negotiates no ALPN is still indistinguishable from one of those, so the complete fix is a route-level protocol declaration — noted in the function docs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces per-direction copy buffer configurations (clientReadBufferSize and upstreamReadBufferSize) to override the base readBufferSize, which has been reduced to a default of 8192 bytes. It integrates tokio::io::copy_bidirectional_with_sizes to apply these sizes, adds clamping bounds to prevent invalid buffer sizes, and refines ALPN protocol checks to avoid header rewriting on non-HTTP/1 streams like MQTT. Comprehensive unit and integration tests, along with detailed documentation, have been added to support these changes. I have no feedback to provide as there are no review comments to evaluate.
…gate Cross-model review (Codex + Gemini + Grok-experiment + a Harper domain pass) found three real problems and one overclaim in the first commit. Standalone-server reload silently ignored the new fields. `symphony-server` decided recreate-vs-hot-swap from a signature over listeners only, but the buffer sizes are frozen at construction on the Rust side and `updateConfig` reaches routes and protection only. Editing just a buffer size left the signature unchanged, so the reload took the hot-swap branch, reported success, and kept running the old value — the exact "config masquerading as applied" that the clamp warning exists to prevent. The signature now covers the proxy-level construction-frozen fields, `workerThreads` included; it had the same pre-existing hole. The doc comment's memory figure was wrong by ~15x. Going from 8 KiB to 64 KiB per direction across a million connections is 2 x 56 KiB x 1e6, which is ~107 GiB, not the ~7 GiB claimed. An operator sizing a node from that comment would have under-planned RSS by an order of magnitude. The README's own numbers checked out; two roundings there are tightened (5.2 -> 5.1 GiB, 3.6 -> 3.75 GB). Reverted the `is_http1_alpn` gate as inert. `cfg.alpn_protocols` is set at one site, to `[h2, http/1.1]`, and only when a route sets `http2` (tls.rs:107), so rustls can only ever negotiate None, h2, or http/1.1 — never `mqtt`. The new gate was therefore behavior-identical to the old `!= h2` in every reachable state, and real terminated MQTT (which negotiates no ALPN at all) is still classified as HTTP/1. It bought an unreachable branch plus a test asserting an impossible state, and the commit message claimed a stall was fixed that was not. The actual root cause needs a route-level protocol declaration, filed as #38 with a design question rather than papered over here. Scoped two overclaims. The buffer settings do not reach header-rewriting routes, whose framing uses its own fixed 8 KiB buffers, so the README no longer implies they govern every proxied byte. And "behavior-preserving" holds only for configs that leave `readBufferSize` unset: one that explicitly set 65536 was getting 8 KiB while the field was inert and now gets what it asked for, so the README carries an upgrade note. host-manager never sets the field, so the Harper fleet is on the default path. Also label the clamp warning with the key the operator actually set — the fallback previously reported a per-direction field name for an out-of-range `readBufferSize`, so a grep for the key they wrote found nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The section's totals are GiB but the MQTT saving was written in decimal GB, so a reader comparing 3.75 against 5.1 in the line above is comparing different units. Same quantity, stated as ~3.5 GiB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…equation Review noted the specs prove only that large payloads round-trip: they pass just as well if the new fields are accepted and never read, which is the exact regression this change exists to prevent, and they cannot tell whether the two directions were transposed. Closed that with an instrumented Rust test. `CapacityRecorder` records the capacity of every `ReadBuf` the copy loop hands it, and that capacity IS the configured size for its direction, so the assertions distinguish "the size reached this half" from "the size was accepted and dropped". A second case feeds the same two values exchanged, so a transposed implementation fails even though it would satisfy the first case's value set. Getting there needed the copy step reachable from a unit test: `ConnContext` holds an `Arc<ThreadsafeFunction>`, which cannot be constructed without a napi env, so `forward` now delegates to `copy_both_ways(client, upstream, client_size, upstream_size)`. That leaves exactly one untested step — the field-to-argument mapping on the call line, directly above the tested function. Also corrects the memory equation, which was only valid for symmetric sizing. `2 × readBufferSize × connections` does not describe the recommended MQTT setting at all: 1024 + 4096 is 5 KiB, not twice either value. The general form is `(client + upstream) × connections` with each unset override falling back to `readBufferSize`, stated now in the README, the napi struct docs, ts/types.ts, and the addon typings, with the symmetric shorthand kept as the special case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
kriszyp
left a comment
There was a problem hiding this comment.
🤖 Submitting the dispatched codex/gpt-5.6-sol review (verdict CHANGES) so its findings are on the record rather than sitting in a draft. Replies to each point follow; two were already addressed by the review-fix commit, three are addressed in 4ed6304.
…unt (#37) Replaces copy_bidirectional_with_sizes with a hand-rolled copy_bidirectional_lazy (src/copy.rs). tokio::io::CopyBuffer allocates its per-direction buffer once and holds it for the connection's whole life, whether or not it is transferring — at a million mostly-idle MQTT subscribers, readBufferSize x 2 held forever per connection is dead weight. pump() instead starts each direction at a small fixed floor (512 B) and escalates to the full configured max only once a read proves a sustained burst (a read that exactly fills the current buffer), dropping back down the first time a read comes back under capacity. Every read is a single ordinary blocking .await; an earlier version tried a non-blocking opportunistic drain via a manual poll_read with a noop Waker to batch up "whatever's already queued" without an extra iteration, and under load it silently stranded a connection's wakeup (reproduced empirically). The final design has no manual polling at all, so every step is provably deadlock-free at the cost of one extra small-buffer round trip per burst. tokio::try_join! (not join!) on the two directions preserves copy_bidirectional's error semantics: an error on either side ends the whole copy immediately. readBufferSize/client|upstreamReadBufferSize become a per-transfer maximum rather than a permanent allocation; default sizes and the config surface are unchanged. Measured (see __test__/bench-copy-memory.ts, __test__/bench-copy-throughput.ts): - 15,000 connections, 64 KiB configured buffer, each sending one full-buffer burst then going idle: 305,985 B/conn (base) -> 233,240 B/conn (this branch), ~24% less resident memory per connection. - Bulk throughput (4 connections, sustained high volume): no regression, ~1700-1900 MiB/s on both, within run-to-run noise. Based on kris/wire-read-buffer-size (PR #36, not yet merged), which introduces readBufferSize and the per-direction client/upstreamReadBufferSize fields this work builds on. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Devin-Holland
left a comment
There was a problem hiding this comment.
Verified locally
The reviewer notes say the toolchain wasn't available, so I re-ran the parts that were CI-only. There is a Rust toolchain on this box (cargo 1.94.1):
cargo test --lib— 105 passed, 0 failed, including the four new tests.cargo clippy --all-targets— no errors. The 9 warnings are all pre-existing dead-code ones on lines this diff doesn't touch (rtfield,JsEvent::Errorvariant,http_proxyhelpers).- CI is green on
4ed6304across all 11 checks.
I also checked the two load-bearing claims against the tokio source rather than taking them on trust:
DEFAULT_COPY_BUFFER_SIZE = 8 * 1024is exactly right.tokio-1.53.1/src/io/util/mod.rs:88—const DEFAULT_BUF_SIZE: usize = 8 * 1024, andcopy_bidirectional.rs:83-84builds bothCopyBuffers from it. So "unset configs keep the footprint they already had" holds.- The per-direction mapping is not transposed.
copy_bidirectional_with_sizes(a, b, a_to_b, b_to_a)passesa_to_btoCopyBuffer::newfor the a→b half, so witha= client,clientReadBufferSizereally does size reads from the client. TheCapacityRecordertests are a good way to pin that down — nice touch making the two values asymmetric so a transposition can't pass. - The eager-allocation premise is real:
CopyBuffer::newdoesvec![0; buf_size].into_boxed_slice(), so both buffers are allocated up front and held for the connection's life. The memory arithmetic checks out —2 × 57344 × 1e6= 106.8 GiB for the 64 KiB-vs-8 KiB comparison in the doc comment, and2 × 8192 × 333_000= 5.08 GiB. host-managernever setsreadBufferSize— greppedsrc/, no hits — so the default-lowering is a no-op for the Harper fleet, as the body claims.
Approving. Two things inline, neither blocking.
Claude (Opus 5)
Co-authored-by: Devin Holland <50112339+Devin-Holland@users.noreply.github.com>
…unt (#37) Replaces copy_bidirectional_with_sizes with a hand-rolled copy_bidirectional_lazy (src/copy.rs). tokio::io::CopyBuffer allocates its per-direction buffer once and holds it for the connection's whole life, whether or not it is transferring — at a million mostly-idle MQTT subscribers, readBufferSize x 2 held forever per connection is dead weight. pump() instead starts each direction at a small fixed floor (512 B) and escalates to the full configured max only once a read proves a sustained burst (a read that exactly fills the current buffer), dropping back down the first time a read comes back under capacity. Every read is a single ordinary blocking .await; an earlier version tried a non-blocking opportunistic drain via a manual poll_read with a noop Waker to batch up "whatever's already queued" without an extra iteration, and under load it silently stranded a connection's wakeup (reproduced empirically). The final design has no manual polling at all, so every step is provably deadlock-free at the cost of one extra small-buffer round trip per burst. tokio::try_join! (not join!) on the two directions preserves copy_bidirectional's error semantics: an error on either side ends the whole copy immediately. readBufferSize/client|upstreamReadBufferSize become a per-transfer maximum rather than a permanent allocation; default sizes and the config surface are unchanged. Measured (see __test__/bench-copy-memory.ts, __test__/bench-copy-throughput.ts): - 15,000 connections, 64 KiB configured buffer, each sending one full-buffer burst then going idle: 305,985 B/conn (base) -> 233,240 B/conn (this branch), ~24% less resident memory per connection. - Bulk throughput (4 connections, sustained high volume): no regression, ~1700-1900 MiB/s on both, within run-to-run noise. Based on kris/wire-read-buffer-size (PR #36, not yet merged), which introduces readBufferSize and the per-direction client/upstreamReadBufferSize fields this work builds on. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The escalate/park/shrink behaviour saves memory in proportion to connection count, but costs two allocations plus a zeroing `vec![0u8; n]` per burst regardless of it. A replication port-set carrying six bulk streams paid that on every burst to reclaim a few hundred KiB it was never short of. `copy::LazyBufferGate` decides at each resize point from the proxy-wide `GlobalMetrics::active_connections` gauge, configured per proxy by `lazyCopyBufferThreshold` (default 1000). Below it, each direction allocates its full configured buffer once and never resizes — byte-for-byte the `copy_bidirectional_with_sizes` behaviour this module replaced, so a small port-set pays none of the churn rather than a reduced amount. `0` engages always; a value above peak concurrency disables it. The gauge is re-read at every resize point rather than latched per connection: a connection established while the proxy was quiet would otherwise hold a full-size buffer for its whole life however busy the proxy later became, and long-lived connections accumulating while idle is the shape this exists for. Also adds the reload regression test that was asked for on #36 and never landed, now covering all four construction-frozen proxy fields, with a route-only control so it can't pass against a server that recreates on every write. Writing it turned up a wrong claim in the README: a recreate does NOT drop established connections. `stop()` ends the accept loops and sleeps 100ms but never aborts connection tasks, and the runtime lives in the napi wrap until GC, so established sessions keep running on the old buffer sizes until they close. That is now documented and pinned by a test. Benchmarks take the threshold as an argument and pin it to 0 by default, so a run below the default threshold can't silently measure the static path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
What
readBufferSizehas been accepted by the config and plumbed all the way intoProxyConnConfigfor several releases without anything reading it. The copy wascopy_bidirectional, which uses tokio's hard-coded 8 KiB per direction.http_proxy.rsdoesn't read it either — that path uses fixed stack buffers — so the setting has never had any effect on any port, and the 64 KiB default documented in the README has never applied.This PR makes it live, per direction.
Why it matters
Not throughput — connection count. Both copy buffers are allocated for the whole life of every connection whether or not it is transferring anything, so the size multiplies straight into per-connection memory:
At the 8192 default that is 16 KiB/conn — 4.0 GiB at 262k connections, 5.1 GiB at 333k. For a fleet of mostly-idle MQTT subscribers those are gigabytes of buffers nothing ever reads. Dropping MQTT to
1024/4096is 5 KiB/conn, freeing ~3.75 GB at 333k connections at no CPU cost (a payload larger than the buffer is just more loop iterations, and on a TLS-terminating listener those aren't even syscalls — the reads come out of rustls's already-decrypted buffer).Context: Customer load test, where a 3-node cluster is being sized for ~1M concurrent MQTT connections.
Changes
copy_bidirectional_with_sizes.clientReadBufferSize/upstreamReadBufferSize. MQTT is strongly asymmetric — afterSUBSCRIBEa client sends almost nothing butPINGREQwhile the broker carries the fan-out — so1024/4096beats a symmetric2048on both memory and downstream headroom.[512, 1 MiB]and log anything out of range, naming the key the operator actually set. Zero is the correctness case: the copy loop would read into an empty slice and take the resultingOk(0)for EOF, closing the connection instead of proxying it.symphony-server: widen the recreate-vs-hot-swap signature (listenerSig→constructionSig) to cover the proxy-level fields that are frozen at construction. Without this, editing only a buffer size inconfig.jsonleft the signature unchanged, so the reload took the route-only hot-swap branch, reported success, and kept running the old value.workerThreadshad the same pre-existing hole and is included.Scope limits worth knowing
sourceAddressHeader: 'xForwardedFor'or a header-carriedforwardFingerprintis set, the flow takesproxy_http1_rewriting, which frames with its own fixed 8 KiB buffers. The new default equals that, so only a raised or lowered value is a no-op there. The MQTT target uses PROXY protocol, so it takes the plain path. Wiring those buffers too is a reasonable follow-up; the README no longer implies the setting governs every proxied byte.65536(plausibly copied from the old documented default) was getting 8 KiB while the field was inert and now gets what it asked for — 128 KiB/conn. There's an upgrade note in the README. host-manager never sets the field, so the Harper fleet is on the default path.Testing
u32::MAX→ ceiling, in-range passthrough).__test__/copy-buffers.spec.ts: round-trips a 512 KiB patterned payload (1024× the minimum buffer) through TLS termination with a 512-byte buffer, with asymmetric buffers, and with a clamped0— asserting byte-for-byte integrity. A too-small buffer must only mean more iterations, never truncation or a stall. Registered in thenpm testfile list.CapacityRecorder) record theReadBufcapacity the copy loop uses in each direction, so a config that is accepted-then-ignored fails, and a transposed pair of directions fails — neither of which a byte-for-byte round-trip can detect. Added in response to review;forwarddelegates tocopy_both_waysto make the copy step reachable without a napi env.Cross-model review
Ran thorough mode: Codex + Gemini + an experimental Grok leg + a Harper-domain pass, adjudicated. No blockers. Four significant concerns, all addressed in the second commit:
constructionSigabove.2 × 56 KiB × 1e6). Corrected. The README's own numbers verified correct; two roundings tightened.is_http1_alpngate was inert (domain pass + Codex) — reverted.cfg.alpn_protocolsis set at one site, to[h2, http/1.1], and only when a route setshttp2(src/tls.rs:107), so rustls can only ever negotiateNone,h2, orhttp/1.1— nevermqtt. The gate was behavior-identical to the old!= h2in every reachable state, and real terminated MQTT negotiates no ALPN at all, so it is still classified HTTP/1. The first commit message claimed a stall was fixed that was not. The real root cause needs a route-level protocol declaration and a design call, so it's filed as Header rewriting can be applied to non-HTTP streams: ALPN cannot distinguish MQTT from an HTTPS client that offers no ALPN #38 rather than papered over here.Plus one from the Grok leg that the other legs and I both missed: the upgrade-migration case for configs that set
readBufferSizeexplicitly (scope limit #2 above).Review round 2 (dispatched codex/gpt-5.6-sol, verdict CHANGES)
Five comments, all addressed; threads replied to and resolved.
constructionSig)Reviewer notes
cargo test/cargo clippy/ the addon build andnpm testall ran on CI (green on the first commit; re-running for the second).tsc --noEmitpasses locally.package-lock.jsononmainis stale (version: 0.1.0, missing thesymphony-serverbin entry) and.claude/worktrees/is not in symphony's.git/info/excludeunlike the other repos.CopyBufferallocates eagerly and holds both buffers for the connection's whole life), Header rewriting can be applied to non-HTTP streams: ALPN cannot distinguish MQTT from an HTTPS client that offers no ALPN #38 (the non-HTTP-stream header-rewriting footgun), Copy-buffer settings do not reach the HTTP/1 header-rewriting path #39 (thread the sizes through the rewriting path).metrics.spec.ts"records an unreachable upstream as upstream_connect" failed once on Node 22 (waitFor: timed out) and passed on re-run of the identical commit — a timing flake in a test this PR doesn't touch, not a regression.🤖 Generated with Claude Code (Opus 5)