Repository navigation
TLS session resumption for resolved suspended routes; reclaim expired IP-affinity entries - #57
Merged
Merged
Conversation
build_resolved_route created a throwaway TlsConfigCache per call, so every resolveConnection() with terminateTls minted a new ServerConfig — re-parsing the cert and creating a fresh session store and ticketer — and no client of a resolved route could ever resume a TLS session. It now draws from the proxy-owned cache, so resolutions of one cert share a config with each other and with any table route carrying the same material. No table holds a resolved config, so it lives by refcount: kept while a connection holds it, otherwise dropped at the next route-table build. Since a proxy that never reloads would then keep one config per distinct cert ever resolved, an insert past max(256, 2x the referenced set) also evicts every config only the cache holds. Dispatch-Task: symphony-resolved-route-tls-cache-and-clippy Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXXMb4qzTwucNae7iSHpUB
UdsBalancer::pick() inserts an affinity entry per client IP and only ever overwrites it when that IP returns; expiry made it ignored, never removed. evict_affinity() existed for this and CLAUDE.md lists affinity eviction among the tasks start() spawns, but nothing called it, so with ipAffinity enabled each map grew by every distinct client IP until the next route-table build. start() now spawns evict_affinity_periodically, which every 60s reloads the live table and runs evict_affinity on the blocking pool for each balancer that has affinity — collected from the route actually installed, so a carried-forward route and an h2 mirror are both covered. Dispatch-Task: symphony-resolved-route-tls-cache-and-clippy Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXXMb4qzTwucNae7iSHpUB
- http_proxy: drop is_connection_close, status_code, with_connection_keep_alive and insert_x_forwarded_for (no callers; XFF is a HeaderRewrite applied by rewrite_request_head). - balancer: drop connection_counts; suspended: drop pending_count (the pendingSuspended metric is GlobalMetrics.pending_suspended). - proxy: use LiveRouteTable::new instead of inlining it; hold the runtime as `_rt: Runtime` — it is Send + Sync, so the Mutex<Option<..>> and the comment saying otherwise were stale. - CI: cargo clippy --all-targets -- -D warnings. Dispatch-Task: symphony-resolved-route-tls-cache-and-clippy Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXXMb4qzTwucNae7iSHpUB
get_or_build marked every key it returned, so each resolveConnection left a mark that only the next route-table build cleared — dead data that grew per distinct cert in a proxy that never reloads. Resolutions now go through get_or_build_unmarked, keeping the mark set to what the build in progress requested. Also guard the session-resumption teardown against a failed setup. Dispatch-Task: symphony-resolved-route-tls-cache-and-clippy Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXXMb4qzTwucNae7iSHpUB
HashMap::retain walks the whole allocation, so after a large referenced set drained, every pressure scan still paid for the old capacity. Shrink to evict_at after each eviction so the scan stays proportional to the threshold. Dispatch-Task: symphony-resolved-route-tls-cache-and-clippy Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXXMb4qzTwucNae7iSHpUB
There was a problem hiding this comment.
Code Review
This pull request integrates the proxy's TlsConfigCache with suspended route resolutions (resolveConnection), enabling TLS session resumption for resolved routes while bounding memory growth via a new cache eviction strategy for unreferenced configurations. It also introduces periodic IP affinity eviction for UDS balancers to prevent memory leaks from expired affinity entries. Feedback on these changes suggests deduplicating the collected affinity_balancers to avoid redundant eviction passes, and properly handling the join handle of spawn_blocking during eviction to ensure background panics are not silently swallowed.
kriszyp
marked this pull request as ready for review
October 12, 2026 14:51
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
resolveConnection()withterminateTlsgave every resolved connection a brand-new rustlsServerConfigfrom a throwaway cache, so each resolution re-parsed its cert, allocated a fresh session store (preallocated for 1024 entries, ~125 KB) and a fresh ticketer — and no client of a resolved route could ever resume a TLS session. Separately, CI rancargo clippy --all-targetswithout-D warnings, and main carried 8 dead-code warnings. One of them,UdsBalancer::evict_affinity, is a background task CLAUDE.md saysstart()spawns, but nothing ever called it: withipAffinityon, every distinct client IP stayed in the affinity map until the next route-table reload.💡 Solution
ServerConfigfrom the proxy's ownTlsConfigCache, so resolutions of one cert share a config with each other — and with a table route carrying the same material — and therefore share its ticketer and session store. End to end, a second connection now resumes under TLS 1.2 and 1.3; both failed on base.clear_used, or when an insert pushes the cache pastevict_at= max(256, 2 × configs still referenced). Resolutions go throughget_or_build_unmarkedand write no marks, so the mark set still means "requested by the build in progress".start()spawnsevict_affinity_periodically: every 60 s it reloads the live table and runsevict_affinityon the blocking pool for each affinity balancer, collected from the route actually installed (h2 mirrors and carried-forward routes included). It removes only entriespick()already treats as expired, so routing is unchanged.cargo clippy --all-targets -- -D warnings.⚖️ Alternatives
better-alternative-exists, adopted. My plan kept idle resolved configs until the next reload; the reviewer showed a proxy that never reloads would then keep one config per distinct cert ever resolved. Adopted its pressure-triggered refcount sweep on insert instead.🔧 Changes
src/tls.rs—get_or_build(marks, for table builds) andget_or_build_unmarked(for resolutions) over a sharedlookup_or_build;evict_unreferenced, used byclear_usedand by a miss pastevict_at, then shrinks the map toevict_atso later scans don't walk an allocation left over from a large drained set.src/suspended.rs—build_resolved_routetakes the proxy's&Mutex<TlsConfigCache>and locks it only for terminating TLS; unusedpending_countremoved.src/proxy.rs— passesself.tls_cacheto the resolver; spawns the affinity-eviction loop instart(); usesLiveRouteTable::newinstead of inlining it; holds the runtime as_rt: Runtime(tokio'sRuntimeisSend + Sync, so theMutex<Option<..>>and its comment were stale).src/router.rs—RouteTable::affinity_balancers, collected besidemonitored_balancers;evict_affinity_periodically; test fixtures madepub(crate)for the other modules' tests.src/balancer.rs—has_affinity;evict_affinity's doc corrected (it checks age only, not active connections); unusedconnection_countsremoved.src/http_proxy.rs— removed unusedis_connection_close,status_code,with_connection_keep_alive,insert_x_forwarded_for(XFF is aHeaderRewriteapplied byrewrite_request_head)..github/workflows/CI.yml—-D warnings.CLAUDE.md— TlsConfigCache section: resolved-route lifetime and the eviction bound.✅ Verification
__test__/session-resumption.spec.ts— a suspended route with no cert of its own, auto-resolved with one; the second connection offering the first's session resumes under TLS 1.3 and 1.2, with two resolutions observed. Both cases failed on base (reused === false). The same file's teardown now tolerates a failed setup.__test__/suspended.spec.tsonly updates a walkthrough comment.suspended.rstests — two resolutions share one config; mTLS required/optional and h2 key separate configs; a resolution shares a table route's config; a held config survives a reload and an idle one doesn't; a failed resolution leaves the cache usable.tls.rsbound test, which fails when the pressure sweep is disabled.router.rs— affinity collection incl. h2 mirror and carry-forward; the eviction loop follows a hot-swap and exits on shutdown.balancer.rs— TTL-only eviction.cargo clippy --all-targets -- -D warningsclean,cargo fmt --checkclean,cargo test153/153,npm run build:debug && npm test153/153.start()itself (no JS-observable effect); checked by inspection.🤖 Generated by Claude Opus 5.5 (Anthropic Claude Code); posted via @kriszyp.
Related PRs: none found
Complexity: medium
Origin — the dispatch brief this PR was written from
Reuse the proxy TlsConfigCache for resolved routes; clippy -D warnings
Dispatch: task
symphony-resolved-route-tls-cache-and-clippy· queued by unknown · 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; rounds=2; full=1 @ fb80fac
Review-Attention: read ~6m (decisions: resolved-config-lifetime, shared-table-and-resolve-cache, affinity-eviction-shard-locks, do-less-alternative) @ fb80fac