Skip to content

TLS session resumption for resolved suspended routes; reclaim expired IP-affinity entries - #57

Merged
kriszyp merged 5 commits into
mainfrom
fix/resolved-route-tls-cache-and-clippy
Oct 12, 2026
Merged

kriszyp merged 5 commits into
mainfrom
fix/resolved-route-tls-cache-and-clippy

Conversation

@kriszyp

@kriszyp kriszyp commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

⊙ Problem

resolveConnection() with terminateTls gave every resolved connection a brand-new rustls ServerConfig from 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 ran cargo clippy --all-targets without -D warnings, and main carried 8 dead-code warnings. One of them, UdsBalancer::evict_affinity, is a background task CLAUDE.md says start() spawns, but nothing ever called it: with ipAffinity on, every distinct client IP stayed in the affinity map until the next route-table reload.

❓ Your call: is the requirement right? As specified, yes for both halves. The affinity-eviction wiring goes past "delete dead helpers": deleting evict_affinity would have silenced the only signal of an unbounded map. It is its own commit (6af5459) and can be dropped if you would rather track it separately.

💡 Solution

⚠️ Look hardest: evict_unreferenced — every cache eviction decision is Arc::strong_count(config) > 1. The claim is that anything still in use has a second owner — a route table, or a live connection (the TlsAcceptor in proxy_conn.rs:242 lives through proxy_via_tls) — so eviction can only cost a future resumption, never a live connection.

⚖️ Alternatives

  • Planning review returned 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.
  • Separate resolve-only cache: rejected — a cert shared by a table route and a resolution would get two ticketers, so a ticket from one path would fail on the other.
  • API change so JS passes a cert/route handle instead of PEM: new API surface to save one SHA-256 over the PEM per resolution.
  • Process-wide cache: orthogonal (it would also survive listener recreation); not done here.

❓ Your call: idle resolved configs are dropped at every route-table build, including one updateConfig then rejects, so each returning client of an idle resolved route does one full handshake per reload. Keeping them would need a second mark set that a failed build's marks can't be confused with; I judged that not worth it. Reversible.

❓ Your call: a resolution with the same cert, key, client-auth CA and requirement, and ALPN as a table route shares that route's config and ticketer. That is safe only because the cache key covers every input a resumed session may skip; a future ServerConfig input not added to the key would break it. Easy to split later.

❓ Your call: MIN_EVICT_AT = 256 caps idle resolved configs at roughly 256 × ~125 KB ≈ 32 MB when few configs are referenced; above that the threshold tracks 2× the referenced set. Lower saves memory, higher keeps resumption for more idle tenants.

❓ Your call: affinity eviction is a once-per-minute DashMap::retain on the blocking pool; a pick() that hashes to the shard under scan waits for that shard. Measured on 1 M entries (release build, unthrottled 20-core dev box, 128 shards): 14 ms total when nothing expires, 129 ms when everything does (~1 ms per shard). Evicting inside pick() instead would run the same full scan on a tokio worker in one connection's path, since a DashMap has no cheap way to find expired entries.

🔧 Changes

✅ Verification

🤖 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-1

Review-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

kriszyp and others added 5 commits October 8, 2026 09:12
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

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread src/router.rs
Comment thread src/router.rs
@kriszyp
kriszyp marked this pull request as ready for review October 12, 2026 14:51
@kriszyp
kriszyp merged commit d429cdd into main Oct 12, 2026
11 checks passed
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>
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.

1 participant